From b1f54040c3126848f0a96122c353fc739f2fe8f5 Mon Sep 17 00:00:00 2001 From: CelinaKellinghaus Date: Tue, 4 Aug 2026 14:55:00 +0200 Subject: [PATCH 01/14] Add description and generation_parameters fields to Timeseries model --- app/projects/models/base_models.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/projects/models/base_models.py b/app/projects/models/base_models.py index e6b4d4ef8..b72ddbed1 100644 --- a/app/projects/models/base_models.py +++ b/app/projects/models/base_models.py @@ -646,6 +646,14 @@ class Timeseries(models.Model): blank=True, null=True, ) + generation_parameters = models.JSONField( + blank=True, + null=True, + ) + description = models.TextField( + blank=True, + null=True, + ) # TODO user or scenario can be both null only if open_source attribute is True --> by way of saving # TODO if the timeseries is open_source and the user is deleted, the timeseries user should just be set to null, From 4fffffa457f93793cdb3514f3b020e6c7693b09e Mon Sep 17 00:00:00 2001 From: paulapreuss Date: Thu, 13 Aug 2026 14:35:29 +0200 Subject: [PATCH 02/14] Fix ruff issues --- app/projects/forms.py | 69 ++++---- app/projects/helpers.py | 38 ++--- app/projects/models/base_models.py | 75 +++++---- app/projects/urls.py | 1 + app/projects/views.py | 148 ++++++++---------- app/static/js/grid_model_topology.js | 63 +++++++- ...sset_cops_form.html => asset_subform.html} | 0 app/templates/asset/timeseries_input.html | 19 +++ app/templates/scenario/scenario_step2.html | 2 + 9 files changed, 231 insertions(+), 184 deletions(-) rename app/templates/asset/{asset_cops_form.html => asset_subform.html} (100%) diff --git a/app/projects/forms.py b/app/projects/forms.py index 8680d5343..0f936baec 100644 --- a/app/projects/forms.py +++ b/app/projects/forms.py @@ -1,47 +1,36 @@ +import json import logging -import pickle import os -import json -import io -import csv -from django.db.models import Q -from django.utils.html import format_html -from django.utils.safestring import mark_safe -from openpyxl import load_workbook -import numpy as np +import pickle -from crispy_forms.bootstrap import AppendedText, PrependedText, FormActions +import numpy as np from crispy_forms.helper import FormHelper -from crispy_forms.layout import ( - Submit, - Layout, - Row, - Column, - Field, - Fieldset, - ButtonHolder, -) +from crispy_forms.layout import Submit +from dashboard.helpers import KPI_PARAMETERS_ASSETS from django import forms -from django.forms import ModelForm +from django.conf import settings as django_settings from django.core.exceptions import ValidationError -from django.core.validators import MaxValueValidator, MinValueValidator +from django.db.models import Q +from django.forms import ModelForm +from django.utils.html import format_html +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ -from django.conf import settings as django_settings -from projects.models import * -from projects.constants import MAP_EPA_MVS, RENEWABLE_ASSETS, CURRENCY_SYMBOLS -from dashboard.helpers import KPI_PARAMETERS_ASSETS, KPIFinder +from projects.constants import ( + ASSET_TO_TIMESERIES_ASSET_TYPE, + CURRENCY_SYMBOLS, + RENEWABLE_ASSETS, +) from projects.helpers import ( - parameters_helper, PARAMETERS, - DualNumberField, - parse_input_timeseries, - TimeseriesField, + TS_MANUAL_TYPE, TS_SELECT_TYPE, TS_UPLOAD_TYPE, - TS_MANUAL_TYPE, + DualNumberField, + TimeseriesField, + parameters_helper, ) -from projects.constants import ASSET_TO_TIMESERIES_ASSET_TYPE +from projects.models import * def gettext_variables(some_string, lang="de"): @@ -109,9 +98,7 @@ def set_parameter_info(param_name, field, parameters=PARAMETERS): unit = PARAMETERS[param_name][":Unit:"] verbose = PARAMETERS[param_name]["verbose"] default_value = PARAMETERS[param_name][":Default:"] - if unit == "None" or unit == "": - unit = None - elif unit == "Factor": + if unit == "None" or unit == "" or unit == "Factor": unit = None if verbose == "None": verbose = None @@ -138,7 +125,7 @@ class OpenPlanModelForm(ModelForm): """Class to automatize the assignation and translation of the labels, help_text and units""" def __init__(self, *args, **kwargs): - super(OpenPlanModelForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) for fieldname, field in self.fields.items(): set_parameter_info(fieldname, field) @@ -151,7 +138,7 @@ class OpenPlanForm(forms.Form): """Class to automatize the assignation and translation of the labels, help_text and units""" def __init__(self, *args, **kwargs): - super(OpenPlanForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) for fieldname, field in self.fields.items(): set_parameter_info(fieldname, field) @@ -168,7 +155,7 @@ class Meta: exclude = ["date_created", "date_updated", "economic_data", "user", "viewers"] def __init__(self, *args, **kwargs): - super(ProjectDetailForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) for field in self.fields.values(): field.disabled = True @@ -179,7 +166,7 @@ class Meta: fields = "__all__" def __init__(self, *args, **kwargs): - super(EconomicDataDetailForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) for field in self.fields.values(): field.disabled = True @@ -331,7 +318,7 @@ class ProjectCreateForm(OpenPlanForm): # Render form def __init__(self, *args, **kwargs): - super(ProjectCreateForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.helper = FormHelper() self.helper.form_id = "project_form_id" # self.helper.form_class = 'blueForm' @@ -712,7 +699,7 @@ def __init__(self, *args, **kwargs): proj_id = kwargs.pop("proj_id", None) scenario_id = kwargs.pop("scenario_id", None) view_only = kwargs.pop("view_only", False) - self.existing_asset = kwargs.get("instance", None) + self.existing_asset = kwargs.get("instance") # get the connections with busses self.input_output_mapping = kwargs.pop("input_output_mapping", None) @@ -1159,7 +1146,7 @@ class Meta: class StorageForm(AssetCreateForm): def __init__(self, *args, **kwargs): asset_type_name = kwargs.pop("asset_type", None) - super(StorageForm, self).__init__(*args, asset_type="capacity", **kwargs) + super().__init__(*args, asset_type="capacity", **kwargs) self.fields["dispatchable"].widget = forms.HiddenInput() self.initial["dispatchable"] = True diff --git a/app/projects/helpers.py b/app/projects/helpers.py index e2fe35748..e1b14d096 100644 --- a/app/projects/helpers.py +++ b/app/projects/helpers.py @@ -1,19 +1,19 @@ +import csv +import io import json import logging -import os -import io -import csv -from openpyxl import load_workbook + +from dashboard.helpers import KPIFinder from django import forms from django.core.exceptions import ValidationError -from django.utils.translation import gettext_lazy as _ from django.utils.html import html_safe - +from django.utils.translation import gettext_lazy as _ from epa.settings import RESOURCES_DIR -from projects.dtos import convert_to_dto -from projects.models import Timeseries, AssetType +from openpyxl import load_workbook + from projects.constants import MAP_MVS_EPA -from dashboard.helpers import KPIFinder +from projects.dtos import convert_to_dto +from projects.models import Timeseries TS_SELECT_TYPE = "select" TS_UPLOAD_TYPE = "upload" @@ -199,7 +199,7 @@ def __init__(self, **kwargs): } ), } - super(DualInputWidget, self).__init__(widgets=widgets, **kwargs) + super().__init__(widgets=widgets, **kwargs) def use_required_attribute(self, initial): # overwrite the method of the Widget class of the django.form.widgets module @@ -354,7 +354,7 @@ def __init__(self, select_widget, **kwargs): ), } - super(TimeseriesInputWidget, self).__init__(widgets=widgets, **kwargs) + super().__init__(widgets=widgets, **kwargs) def use_required_attribute(self, initial): # overwrite the method of the Widget class of the django.form.widgets module @@ -567,11 +567,10 @@ def parse_csv_timeseries(file_str): delimiter = "," elif not has_timestamp: raise ValidationError(msg) - else: - # safe to assume decimal comma in single-column case - if comma_per_line and all(c <= 1 for c in comma_per_line): - is_comma_decimal = True - delimiter = ";" + # safe to assume decimal comma in single-column case + elif comma_per_line and all(c <= 1 for c in comma_per_line): + is_comma_decimal = True + delimiter = ";" # check for number of columns, throw error if more then 2 if any(len(line.split(delimiter)) > 2 for line in lines if line.strip()): @@ -595,11 +594,8 @@ def parse_csv_timeseries(file_str): value = value.strip() # --- decimal normalization --- - if is_comma_decimal: + if is_comma_decimal or ("," in value and "." not in value): value = value.replace(",", ".") - else: - if "," in value and "." not in value: - value = value.replace(",", ".") if value.isalpha(): # catch if there is a header, then the file cannot be parsed raise ValidationError(msg) @@ -623,7 +619,7 @@ def parse_xlsx_timeseries(file_buffer): if n_col > 1: col_idx = 1 - for j in range(0, worksheet.max_row): + for j in range(worksheet.max_row): try: timeseries_values.append( float(worksheet.cell(row=j + 1, column=col_idx + 1).value) diff --git a/app/projects/models/base_models.py b/app/projects/models/base_models.py index b72ddbed1..a25955dae 100644 --- a/app/projects/models/base_models.py +++ b/app/projects/models/base_models.py @@ -1,43 +1,42 @@ import datetime import json import logging +import tempfile import uuid from datetime import timedelta -import pandas as pd from pathlib import Path -import numpy as np -import tempfile -from oemof.datapackage.datapackage import building, export_dp_to_json - +import numpy as np import oemof.thermal.compression_heatpumps_and_chillers as cmpr_hp_chiller +import pandas as pd from django.conf import settings +from django.contrib.postgres.fields import ArrayField from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.forms.models import model_to_dict -from django.contrib.postgres.fields import ArrayField from django.utils.translation import gettext_lazy as _ +from oemof.datapackage.datapackage import export_dp_to_json +from users.models import CustomUser + from projects.constants import ( ASSET_CATEGORY, ASSET_TYPE, + BOOL_CHOICES, + COP_MODES, COUNTRY, CURRENCY, ENERGY_VECTOR, - COP_MODES, FLOW_DIRECTION, MVS_TYPE, - SIMULATION_STATUS, - SIMULATION_SERVERS, PENDING, + SIMULATION_SERVERS, + SIMULATION_STATUS, + TIMESERIES_ASSET_TYPES, + TIMESERIES_CATEGORIES, + TIMESERIES_UNITS, TRUE_FALSE_CHOICES, - BOOL_CHOICES, USER_RATING, - TIMESERIES_UNITS, - TIMESERIES_CATEGORIES, - TIMESERIES_TYPES, - TIMESERIES_ASSET_TYPES, ) -from users.models import CustomUser class Feedback(models.Model): @@ -141,12 +140,11 @@ def add_viewer_if_not_exist(self, email=None, share_rights=""): viewers = Viewer.objects.filter(user=user, share_rights=share_rights) if viewers.exists(): viewer = viewers.get() + elif user == self.user: + viewer = None + message = _("You cannot share a project with yourself") else: - if user == self.user: - viewer = None - message = _("You cannot share a project with yourself") - else: - viewer = Viewer.objects.create(user=user, share_rights=share_rights) + viewer = Viewer.objects.create(user=user, share_rights=share_rights) if viewer not in self.viewers.all() and viewer is not None: self.viewers.add(viewer) @@ -154,19 +152,18 @@ def add_viewer_if_not_exist(self, email=None, share_rights=""): message = _( f"'{email}' belongs to a valid user, they will be able to {share_rights} the project '{self.name}'" ) - else: - if viewer is not None: - if viewer.share_rights != share_rights: - success = True - message = _( - f"The share rights of the user registered under {email} for the project '{self.name}' have been changed from '{viewer.share_rights}' to '{share_rights}'" - ) - viewer.share_rights = share_rights - viewer.save() - else: - message = _( - f"The user registered under {email} for the project '{self.name}' already have '{share_rights}' access" - ) + elif viewer is not None: + if viewer.share_rights != share_rights: + success = True + message = _( + f"The share rights of the user registered under {email} for the project '{self.name}' have been changed from '{viewer.share_rights}' to '{share_rights}'" + ) + viewer.share_rights = share_rights + viewer.save() + else: + message = _( + f"The user registered under {email} for the project '{self.name}' already have '{share_rights}' access" + ) else: message = ( @@ -478,7 +475,7 @@ def clean_dir_str(name): resource_metadata["schema"].update(schema) datapackage_metadata_dict["resources"].append(resource_metadata) - out_path = data_folder / f"project.csv" + out_path = data_folder / "project.csv" Path(out_path).parent.mkdir(parents=True, exist_ok=True) df = pd.DataFrame([proj_dp]) df.drop_duplicates("name").to_csv(out_path, index=False) @@ -541,7 +538,7 @@ def clean_dir_str(name): # Save all unique busses to a elements resource if bus_resource_records: resource_metadata = { - "path": f"data/elements/bus.csv", + "path": "data/elements/bus.csv", "profile": "tabular-data-resource", "name": "bus", "format": "csv", @@ -558,7 +555,7 @@ def clean_dir_str(name): resource_metadata["schema"].update(schema) datapackage_metadata_dict["resources"].append(resource_metadata) - out_path = elements_folder / f"bus.csv" + out_path = elements_folder / "bus.csv" Path(out_path).parent.mkdir(parents=True, exist_ok=True) df_bus = pd.DataFrame(bus_resource_records) df_bus.drop_duplicates("name").to_csv(out_path, index=False) @@ -566,7 +563,7 @@ def clean_dir_str(name): # Save all profiles to a sequences resource if profile_resource_records: resource_metadata = { - "path": f"data/sequences/profiles.csv", + "path": "data/sequences/profiles.csv", "profile": "tabular-data-resource", "name": "profiles", "format": "csv", @@ -579,13 +576,13 @@ def clean_dir_str(name): "missingValues": [""], }, } - for k in profile_resource_records.keys(): + for k in profile_resource_records: resource_metadata["schema"]["fields"].append( {"name": k, "type": "number", "format": "default"} ) datapackage_metadata_dict["resources"].append(resource_metadata) - out_path = sequences_folder / f"profiles.csv" + out_path = sequences_folder / "profiles.csv" Path(out_path).parent.mkdir(parents=True, exist_ok=True) # add timestamps to the profiles profile_resource_records["timeindex"] = self.get_timestamps() diff --git a/app/projects/urls.py b/app/projects/urls.py index ad7edbbe9..3608c14a0 100644 --- a/app/projects/urls.py +++ b/app/projects/urls.py @@ -1,4 +1,5 @@ from django.urls import path, re_path + from .views import * urlpatterns = [ diff --git a/app/projects/views.py b/app/projects/views.py index a89396b2f..64c5188be 100644 --- a/app/projects/views.py +++ b/app/projects/views.py @@ -1,101 +1,93 @@ # from bootstrap_modal_forms.generic import BSModalCreateView +import datetime import tempfile -from pathlib import Path +import traceback import zipfile +from pathlib import Path - +from dashboard.helpers import fetch_user_projects +from dashboard.models import FancyResults +from django.contrib import messages from django.contrib.auth.decorators import login_required -import datetime -from django.http import ( - HttpResponseForbidden, - JsonResponse, - HttpResponseRedirect, - HttpResponse, -) +from django.core.exceptions import PermissionDenied +from django.db.models import Q +from django.http import HttpResponse, HttpResponseRedirect, JsonResponse from django.http.response import Http404 -from django.template.loader import get_template -from django.utils.translation import gettext_lazy as _ -from django.utils.safestring import mark_safe # from django.shortcuts import * -from django.shortcuts import get_object_or_404, render, redirect +from django.shortcuts import get_object_or_404, redirect, render +from django.template.loader import get_template from django.urls import reverse -from django.core.exceptions import PermissionDenied +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ from django.views.decorators.http import require_http_methods -from django.contrib import messages -from django.template.loader import get_template - -from jsonview.decorators import json_view -from django.db.models import Q - -from oemof.datapackage.datapackage import export_dp_to_json - from epa.settings import ( + EZP_GET_URL, MVS_GET_URL, MVS_LP_FILE_URL, MVS_SA_GET_URL, - EZP_GET_URL, SHOW_EZP, ) -from .forms import * -from .requests import ( - mvs_simulation_request, - fetch_mvs_simulation_results, - ezp_simulation_request, - fetch_ezp_simulation_results, - mvs_sensitivity_analysis_request, - fetch_mvs_sa_results, - parse_mvs_results, - parse_ezp_results, +from jsonview.decorators import json_view +from oemof.datapackage.datapackage import export_dp_to_json + +from projects.decorators import ( + user_has_edit_rights, + user_has_read_rights, + user_is_owner, ) +from projects.helpers import PARAMETERS, format_scenario_for_mvs from projects.models import ( - Project, - EconomicData, - Comment, - ConnectionLink, - AssetType, - UseCase, - Scenario, - Simulation, - ParameterChangeTracker, - AssetChangeTracker, - SensitivityAnalysis, Asset, + AssetChangeTracker, + AssetType, Bus, + Comment, + ConnectionLink, COPCalculator, - Timeseries, + EconomicData, + MaxEmissionConstraint, MinDOAConstraint, MinRenewableConstraint, - MaxEmissionConstraint, NZEConstraint, + ParameterChangeTracker, + Project, + Scenario, + SensitivityAnalysis, + Simulation, + Timeseries, + UseCase, ) -from projects.decorators import ( - user_is_owner, - user_has_read_rights, - user_has_edit_rights, + +from .constants import DONE, ERROR, MAX_STEP, MODIFIED, PENDING, STEP_LIST +from .forms import * +from .requests import ( + ezp_simulation_request, + fetch_ezp_simulation_results, + fetch_mvs_sa_results, + fetch_mvs_simulation_results, + mvs_sensitivity_analysis_request, + mvs_simulation_request, + parse_ezp_results, + parse_mvs_results, ) -from dashboard.models import FancyResults from .scenario_topology_helpers import ( - handle_storage_unit_form_post, - handle_bus_form_post, - handle_asset_form_post, - load_scenario_topology_from_db, NodeObject, - update_deleted_objects_from_database, - duplicate_scenario_objects, duplicate_scenario_connections, - load_scenario_from_dict, + duplicate_scenario_objects, + handle_asset_form_post, + handle_bus_form_post, + handle_storage_unit_form_post, load_project_from_dict, + load_scenario_from_dict, + load_scenario_topology_from_db, + update_deleted_objects_from_database, ) -from projects.helpers import format_scenario_for_mvs, PARAMETERS -from dashboard.helpers import fetch_user_projects -from .constants import DONE, PENDING, ERROR, MODIFIED, STEP_LIST, MAX_STEP from .services import ( excuses_design_under_development, - send_feedback_email, get_selected_scenarios_in_cache, + send_feedback_email, ) -import traceback logger = logging.getLogger(__name__) @@ -201,7 +193,7 @@ def user_feedback(request): body = f"Feedback form for OpenPlan Tool online api\n\nReceived Feedback\n-----------------\n\nTopic: {feedback.subject}\nContent: {feedback.feedback}\n\nInformation about sender\n------------------------\nName: {feedback.name}\n E-mail Address: {feedback.email}" try: send_feedback_email(subject, body) - messages.success(request, f"Thank you for your feedback.") + messages.success(request, "Thank you for your feedback.") except Exception as e: messages.success(request, e) return HttpResponseRedirect(reverse("project_search")) @@ -287,7 +279,7 @@ def ajax_project_viewers_form(request): @user_has_read_rights def project_detail(request, proj_id): project = get_object_or_404(Project, pk=proj_id) - logger.info(f"Populating project and economic details in forms.") + logger.info("Populating project and economic details in forms.") project_form = ProjectDetailForm(None, instance=project) economic_data_form = EconomicDataDetailForm(None, instance=project.economic_data) @@ -304,7 +296,7 @@ def project_create(request): if request.POST: form = ProjectCreateForm(request.POST) if form.is_valid(): - logger.info(f"Creating new project with economic data.") + logger.info("Creating new project with economic data.") economic_data = EconomicData.objects.create( duration=form.cleaned_data["duration"], currency=form.cleaned_data["currency"], @@ -389,7 +381,7 @@ def project_update(request, proj_id): ) if project_form.is_valid() and economic_data_form.is_valid(): - logger.info(f"Updating project with economic data...") + logger.info("Updating project with economic data...") project_form.save() economic_data_form.save() @@ -875,11 +867,11 @@ def scenario_create_topology(request, proj_id, scen_id, step_id=2, max_step=3): "solar_thermal_plant": _("Solar Thermal Plant"), }, "conversion": { - "transformer_station_in": _("Transformer Station (in)"), # - "transformer_station_out": _("Transformer Station (out)"), # - "storage_charge_controller_in": _("Storage Charge Controller (in)"), # - "storage_charge_controller_out": _("Storage Charge Controller (out)"), # - "solar_inverter": _("Solar Inverter"), # + "transformer_station_in": _("Transformer Station (in)"), + "transformer_station_out": _("Transformer Station (out)"), + "storage_charge_controller_in": _("Storage Charge Controller (in)"), + "storage_charge_controller_out": _("Storage Charge Controller (out)"), + "solar_inverter": _("Solar Inverter"), "diesel_generator": _("Diesel Generator"), "fuel_cell": _(" Fuel Cell"), "gas_boiler": _("Gas Boiler"), @@ -907,7 +899,7 @@ def scenario_create_topology(request, proj_id, scen_id, step_id=2, max_step=3): "bus-h2": _("Hydrogen Bus"), }, } - group_names = {group: _(group) for group in components.keys()} + group_names = {group: _(group) for group in components} # TODO: if the scenario exists, load it, otherwise default form @@ -1084,7 +1076,7 @@ def scenario_review(request, proj_id, scen_id, step_id=4, max_step=MAX_STEP): scenario = get_object_or_404(Scenario, pk=scen_id) if request.method == "GET": - html_template = f"scenario/simulation/no-status.html" + html_template = "scenario/simulation/no-status.html" context = { "scenario": scenario, "scen_id": scen_id, @@ -1120,11 +1112,7 @@ def scenario_review(request, proj_id, scen_id, step_id=4, max_step=MAX_STEP): "rating": simulation.user_rating, "sim_server": simulation.server, "mvs_token": simulation.mvs_token, - "mvs_version": ( - simulation.mvs_version - if simulation.mvs_version - else "undefined" - ), + "mvs_version": (simulation.mvs_version or "undefined"), } ) if simulation.status == DONE: @@ -1841,7 +1829,7 @@ def asset_cops_create_or_update( except: return JsonResponse({"success": False, "cop_id": cop.id}, status=422) - logger.warning(f"The submitted asset has erroneous field values.") + logger.warning("The submitted asset has erroneous field values.") form_html = get_template("asset/asset_cops_form.html") return JsonResponse( diff --git a/app/static/js/grid_model_topology.js b/app/static/js/grid_model_topology.js index 3d2c04d10..7a4589cd0 100644 --- a/app/static/js/grid_model_topology.js +++ b/app/static/js/grid_model_topology.js @@ -721,7 +721,65 @@ function updateInputTimeseries(){ // COP calculation from temperature -function toggle_cop_modal(event){ +function toggle_sub_modal(){ + // get the parameters which uniquely identify the asset + const assetTypeName = guiModalDOM.getAttribute("data-node-type"); + const topologyNodeId = guiModalDOM.getAttribute("data-node-topo-id"); // e.g. 'node-2' + + let getUrl = createTimeseriesGetUrl + assetTypeName; + if (nodesToDB.has(topologyNodeId)) + getUrl; + + fetch(getUrl).then(response => response.text()).then(formContent => { + // assign the content of the form to the form tag of the modal + guiModalDOM.querySelector('form .modal-addendum').innerHTML = formContent; + // enable Bootstrap tooltips (help text icons) + $('[data-bs-toggle="tooltip"]').tooltip(); + }).catch(error => { + console.error(error); + }); +} + +function computeCustomTimeseries(event){ + + // get the parameters which uniquely identify the asset + const assetTypeName = guiModalDOM.getAttribute("data-node-type"); + const topologyNodeId = guiModalDOM.getAttribute("data-node-topo-id"); // e.g. 'node-2' + + const form = event.target.closest('.modal-content').querySelector('#timeseriesForm'); + const formData = new FormData(form); + + let postUrl = copPostUrl + assetTypeName; + if (nodesToDB.has(topologyNodeId)) + postUrl += "/" + nodesToDB.get(topologyNodeId).uid; + + fetch(postUrl, { + method: 'POST', + headers: {'X-CSRFToken': csrfToken}, + body: formData, + }).then(response => response.json()).then(jsonRes => { + if (jsonRes.success) { + // close the cop area + copCollapse.hide(); + + efficiencyDOM = guiModalDOM.querySelector('input[name="efficiency_scalar"]'); + if(efficiencyDOM){ + efficiencyDOM.value = jsonRes.cops; efficiencyDOM.dispatchEvent(new Event('change')); + } + copDOM = guiModalDOM.querySelector('input[name="copId"]'); + if(copDOM){ + copDOM.value = jsonRes.cop_id; + } + } else { + // not success: assign the content of the form to the form tag of the modal + guiModalDOM.querySelector('form .modal-addendum').innerHTML = jsonRes.form_html; + } + }).catch(error => { + console.error(error); + alert(error.message); + }); +} +function toggle_cop_modal(){ // get the parameters which uniquely identify the asset const assetTypeName = guiModalDOM.getAttribute("data-node-type"); const topologyNodeId = guiModalDOM.getAttribute("data-node-topo-id"); // e.g. 'node-2' @@ -767,8 +825,7 @@ function computeCOP(event){ efficiencyDOM = guiModalDOM.querySelector('input[name="efficiency_scalar"]'); if(efficiencyDOM){ - efficiencyDOM.value = jsonRes.cops; - efficiencyDOM.dispatchEvent(new Event('change')); + efficiencyDOM.value = jsonRes.cops; efficiencyDOM.dispatchEvent(new Event('change')); } copDOM = guiModalDOM.querySelector('input[name="copId"]'); if(copDOM){ diff --git a/app/templates/asset/asset_cops_form.html b/app/templates/asset/asset_subform.html similarity index 100% rename from app/templates/asset/asset_cops_form.html rename to app/templates/asset/asset_subform.html diff --git a/app/templates/asset/timeseries_input.html b/app/templates/asset/timeseries_input.html index 6b15476a7..61cc45202 100644 --- a/app/templates/asset/timeseries_input.html +++ b/app/templates/asset/timeseries_input.html @@ -28,7 +28,26 @@ {% else %}
+ {% include widget.template_name %} +
{{asset_type_name}}
+
{{custom_form_assets}}
+ +
+
+
+
+ {% csrf_token %} + +
+ +
+
{% endif %} {% endfor %}{% endspaceless %} diff --git a/app/templates/scenario/scenario_step2.html b/app/templates/scenario/scenario_step2.html index c31cc66b8..ba15296fa 100644 --- a/app/templates/scenario/scenario_step2.html +++ b/app/templates/scenario/scenario_step2.html @@ -141,6 +141,8 @@

{{ group_names|get_item:group_name|title }}

const assetPortInfoUrl = `{% url 'asset_connection_ports_info' %}`; const postAssetFormUrl = `{% url 'asset_create_or_update' scenario.id %}`; const scenarioBelongsToUser = {% if scenario.project.user == request.user %}true{% else %}false{% endif %}; + const createTimeseriesGetUrl = `{% url 'get_timeseries_create_form' scenario.id %}`; + const createTimeseriesPostUrl = `{% url 'custom_timeseries_create' scenario.id %}`; const copGetUrl = `{% url 'get_asset_cops_form' scenario.id %}`; const copPostUrl = `{% url 'asset_cops_create_or_update' scenario.id %}`; const tsGetUrl = `{% url 'get_timeseries' %}`; From 4061d0d2ede0775d3592b5c1663c356d5f19a1ee Mon Sep 17 00:00:00 2001 From: paulapreuss Date: Thu, 13 Aug 2026 14:44:31 +0200 Subject: [PATCH 03/14] Create custom forms for timeseries creation --- app/projects/forms.py | 191 ++++++++++++++++++++++++++++++++++++++++++ app/projects/urls.py | 10 +++ app/projects/views.py | 64 +++++++++++++- 3 files changed, 263 insertions(+), 2 deletions(-) diff --git a/app/projects/forms.py b/app/projects/forms.py index 0f936baec..e0d628f46 100644 --- a/app/projects/forms.py +++ b/app/projects/forms.py @@ -1213,3 +1213,194 @@ class Meta: }, ) } + + +class CreatePVProductionTimeseriesForm(OpenPlanForm): + mounting_type_choices = ( + ("fix_tilt", _("Fix Tilt")), + ("fix_tilt_two_dir", _("Fix Tilt Two Directions Back To Back")), + ("tracker", _("Tracker")), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # TODO: these parameters would not be manual inputs but come from weather data, I assume? check with Markus + + # direct_irradiation_horizontal = + # diffuse_irradiation_horizontal = + azimuth = forms.FloatField( + label=_("Azimuth"), + widget=forms.NumberInput( + attrs={ + "placeholder": _("e.g. 180"), + "data-bs-toggle": "tooltip", + "title": _( + "For fix tilt: Azimuth angle of the module orientation in degrees (North is 0°, East is 90°...); For tracker: Azimuth angle of the rotation-axis for tracking systems" + ), + } + ), + ) + + tilt = forms.FloatField( + label=_("Tilt"), + widget=forms.NumberInput( + attrs={ + "placeholder": _("e.g. 180"), + "data-bs-toggle": "tooltip", + "title": _("Tilt angle in degrees (0° is horizontal, 90° is vertical)"), + } + ), + ) + + system_efficiency = forms.FloatField( + label=_("System Efficiency"), + widget=forms.NumberInput( + attrs={ + "placeholder": _("e.g. 0.8"), + "data-bs-toggle": "tooltip", + "title": _( + "Performance ratio of the total PV-System (usually around 0.8)" + ), + } + ), + ) + + gcr = forms.FloatField( + label=_("Ground Coverage Ratio"), + widget=forms.NumberInput( + attrs={ + "data-bs-toggle": "tooltip", + "title": _( + "Ground Coverage Ratio (Ratio of the module area to the ground area of the module field), only needed for tracker" + ), + } + ), + required=False, + ) + + mounting_type = forms.ChoiceField( + choices=mounting_type_choices, + label=_("Mounting Type"), + widget=forms.Select( + attrs={ + "data-bs-toggle": "tooltip", + "title": _( + "Static systems, east-west like system or 1-axis tracking system" + ), + } + ), + ) + albedo = forms.FloatField( + label=_("Albedo"), + widget=forms.NumberInput( + attrs={ + "data-bs-toggle": "tooltip", + "title": _("Reflection fraction of sunlight in the surrounding area"), + } + ), + ) + + # TODO: Add validation that checks e.g. that this field is only filled in if tracker is selected + max_angle = forms.FloatField( + label=_("Max. tilt angle"), + widget=forms.NumberInput( + attrs={ + "data-bs-toggle": "tooltip", + "title": _( + "Maximum tilt angle for tracking systems. This value is only used for 'tracker' systems" + ), + } + ), + required=False, + ) + + +class CreateHeatDemandForm(OpenPlanForm): + profile_type_choices = ( + ("EFH", "Single-family house"), + ("MFH", "Apartment building"), + ("GHD", "Commerce/Services general"), + ("GMF", "Household-like business enterprises"), + ("GGA", "Restaurants"), + ("GBH", "Retail and wholesale"), + ("GMK", "Metal and automotive"), + ("GBH", "Accommodation"), + ("GKO", "Local authorities, credit institutions and insurance companies"), + ("GBD", "Other operational services"), + ("GWA", "Laundries, dry cleaning"), + ("GGB", "Horticulture"), + ("GBA", "Bakery"), + ("GPD", "Paper and printing"), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # TODO: is this meant to be a DualNumberField? + outdoor_temperature = forms.FloatField( + label=_("Outdoor Temperature"), + widget=forms.NumberInput( + attrs={ + "placeholder": _("e.g. 25"), + "data-bs-toggle": "tooltip", + "title": _("Outside air temperature in °C"), + } + ), + ) + + profile_type = forms.ChoiceField( + choices=profile_type_choices, + label=_("Profile Type"), + widget=forms.Select( + attrs={ + "data-bs-toggle": "tooltip", + "title": _("Select from one of the available BDEW heat profiles"), + } + ), + ) + + annual_heat_demand = forms.FloatField( + label=_("Annual Heat Demand"), + widget=forms.NumberInput( + attrs={ + "placeholder": _("e.g. 1000"), + "data-bs-toggle": "tooltip", + "title": _("Total heat demand in the chosen timeperiod"), + } + ), + ) + + # TODO: here also check the validation of when the field is required + building_year = forms.FloatField( + label=_("Building Year"), + widget=forms.NumberInput( + attrs={ + "placeholder": _("e.g. 1970"), + "data-bs-toggle": "tooltip", + "title": _( + "Only for residential buildings, used for estimating insulation" + ), + } + ), + required=False, + ) + + wind_class = forms.ChoiceField( + label=_("Wind class"), + choices=(("windy", _("Windy")), ("not_windy", _("Not Windy"))), + widget=forms.Select( + attrs={ + "data-bs-toggle": "tooltip", + "title": _( + "Windy for exposed buildings on free fields, near coast or high ground. Not windy for unexposed buildings in villages/cities" + ), + } + ), + ) + + +CUSTOM_TIMESERIES_FORMS = { + "pv_plant": CreatePVProductionTimeseriesForm, + "heat_demand": CreateHeatDemandForm, +} diff --git a/app/projects/urls.py b/app/projects/urls.py index 3608c14a0..1e8d58871 100644 --- a/app/projects/urls.py +++ b/app/projects/urls.py @@ -241,6 +241,16 @@ asset_cops_create_or_update, name="asset_cops_create_or_update", ), + re_path( + r"^asset/get_timeseries_create_form/(?P\d+)/(?P[\w-]+)?$", + get_timeseries_create_form, + name="get_timeseries_create_form", + ), + re_path( + r"^asset/custom_timeseries_create/(?P\d+)/(?P[\w-]+)?(/(?P[0-9a-f-]+))?$", + custom_timeseries_create, + name="custom_timeseries_create", + ), # ParameterChangeTracker (track of simulated scenario changes) path( "reset_scenario_changes/", diff --git a/app/projects/views.py b/app/projects/views.py index 64c5188be..d646cc848 100644 --- a/app/projects/views.py +++ b/app/projects/views.py @@ -1697,6 +1697,8 @@ def get_asset_create_form(request, scen_id=0, asset_type_name="", asset_uuid=Non ) input_timeseries_data = "" + # these are the assets for which a function to create a custom timeseries is available via eesyplan + custom_form_assets = CUSTOM_TIMESERIES_FORMS.keys() context = { "form": form, "asset_type_name": asset_type_name, @@ -1704,6 +1706,7 @@ def get_asset_create_form(request, scen_id=0, asset_type_name="", asset_uuid=Non "input_timeseries_timestamps": json.dumps( scenario.get_timestamps(json_format=True) ), + "custom_form_assets": custom_form_assets, } return render(request, "asset/asset_create_form.html", context) @@ -1782,6 +1785,63 @@ def asset_connection_ports_info(request, asset_type_name=None): return answer +@login_required +@require_http_methods(["GET"]) +def get_timeseries_create_form(request, scen_id=0, asset_type_name=""): + if asset_type_name not in CUSTOM_TIMESERIES_FORMS: + logger.error( + "The given asset type does not have a custom timeseries creation form" + ) + raise Http404() + form = CUSTOM_TIMESERIES_FORMS[asset_type_name] + context = {"form": form} + + return render(request, "asset/asset_subform.html", context) + + +@login_required +@require_http_methods(["POST"]) +def custom_timeseries_create(request, scen_id=0, asset_type_name="", asset_uuid=None): + from oemof.eesyplan.importer.create_timeseries_pv import ( + create_pv_production_timeseries, + ) + from oemof.eesyplan.importer.heat_demand import create_heat_demand + + if asset_uuid: + existing_asset = get_object_or_404(Asset, unique_id=asset_uuid) + custom_form = CUSTOM_TIMESERIES_FORMS[asset_type_name] + form = custom_form(request.POST) + + custom_timeseries_functions = { + "pv_plant": create_pv_production_timeseries, + "heat_demand": create_heat_demand, + } + + scenario = get_object_or_404(Scenario, id=scen_id) + if form.is_valid(): + try: + # TODO: calculate from relevant function + # for pv timeseries, add lat/lon to the params dict + # should be able to just pass the validated form as dict as **form + custom_ts_fun = custom_timeseries_functions[asset_type_name] + timeseries = custom_ts_fun(**form.cleaned_data) + + # TODO: assign the timeseries to the asset field and also return it for display (same format as get_timeseries) + return JsonResponse( + {"success": True, "timeseries": timeseries}, + status=200, + ) + except: + return JsonResponse({"success": False}, status=422) + + logger.warning("The submitted asset has erroneous field values.") + + form_html = get_template("asset/asset_subform.html") + return JsonResponse( + {"success": False, "form_html": form_html.render({"form": form})}, status=422 + ) + + @login_required @require_http_methods(["GET"]) def get_asset_cops_form(request, scen_id=0, asset_type_name="", asset_uuid=None): @@ -1793,7 +1853,7 @@ def get_asset_cops_form(request, scen_id=0, asset_type_name="", asset_uuid=None) opts["instance"] = existing_cop.get() context = {"form": COPCalculatorForm(**opts)} - return render(request, "asset/asset_cops_form.html", context) + return render(request, "asset/asset_subform.html", context) @login_required @@ -1831,7 +1891,7 @@ def asset_cops_create_or_update( logger.warning("The submitted asset has erroneous field values.") - form_html = get_template("asset/asset_cops_form.html") + form_html = get_template("asset/asset_subform.html") return JsonResponse( {"success": False, "form_html": form_html.render({"form": form})}, status=422 ) From 88d21adb27e126ed9502cf36f9cfb17eddc5ba80 Mon Sep 17 00:00:00 2001 From: paulapreuss Date: Thu, 13 Aug 2026 15:27:29 +0200 Subject: [PATCH 04/14] Display custom form only on specific assets --- app/projects/helpers.py | 9 +++++++- app/templates/asset/timeseries_input.html | 26 +++++++++++------------ 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/app/projects/helpers.py b/app/projects/helpers.py index e1b14d096..0d9bb93a4 100644 --- a/app/projects/helpers.py +++ b/app/projects/helpers.py @@ -320,6 +320,7 @@ def set_widget_error(self): class TimeseriesInputWidget(forms.MultiWidget): template_name = "asset/timeseries_input.html" + custom_form_assets = ["pv_plant", "heat_demand"] # class Media: # # TODO: currently not loading the content as not within head @@ -330,6 +331,7 @@ def __init__(self, select_widget, **kwargs): self.default = kwargs.pop("default", None) self.param_name = kwargs.pop("param_name", None) + self.asset_type = kwargs.pop("asset_type", None) select_widget.attrs.update( { "class": "form-select", @@ -393,6 +395,8 @@ def get_context(self, name, value, attrs): active = "select" # default ctx["active_tab"] = active + ctx["asset_type"] = self.asset_type + ctx["custom_form_assets"] = self.custom_form_assets return ctx @@ -422,7 +426,10 @@ def __init__( self.max = kwargs.pop("max", None) select_widget = fields[2].widget kwargs["widget"] = TimeseriesInputWidget( - default=default, param_name=param_name, select_widget=select_widget + default=default, + param_name=param_name, + asset_type=asset_type, + select_widget=select_widget, ) super().__init__(fields=fields, require_all_fields=False, **kwargs) self.label = label diff --git a/app/templates/asset/timeseries_input.html b/app/templates/asset/timeseries_input.html index 61cc45202..4037a929f 100644 --- a/app/templates/asset/timeseries_input.html +++ b/app/templates/asset/timeseries_input.html @@ -15,24 +15,23 @@
- {% spaceless %}{% for widget in widget.subwidgets %} - {{ widget.id }} - {% if 'scalar' in widget.name %} + {% spaceless %}{% for subwidget in widget.subwidgets %} + {{ subwidget.id }} + {% if 'scalar' in subwidget.name %}
- {% include widget.template_name %} + {% include subwidget.template_name with widget=subwidget %}
- {% elif 'select' in widget.name %} + {% elif 'select' in subwidget.name %}
- {% include widget.template_name %} + {% include subwidget.template_name with widget=subwidget %}
{% else %}
- {% include widget.template_name %} -
{{asset_type_name}}
-
{{custom_form_assets}}
- + {% include subwidget.template_name with widget=subwidget %} + {% if asset_type in custom_form_assets %} +
@@ -41,13 +40,14 @@
+ {% endif %}
{% endif %} {% endfor %}{% endspaceless %} From 2730791be2fd2714ef253386ed19604ffb307d1f Mon Sep 17 00:00:00 2001 From: paulapreuss Date: Thu, 13 Aug 2026 15:56:35 +0200 Subject: [PATCH 05/14] Improve submodal form layout --- app/static/css/main.css | 12 ++++++----- app/static/scss/components/_modals.scss | 12 ++++++++--- app/templates/asset/timeseries_input.html | 25 ++++++++++------------- 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/app/static/css/main.css b/app/static/css/main.css index ea7f80ac3..897f6aea7 100644 --- a/app/static/css/main.css +++ b/app/static/css/main.css @@ -1428,9 +1428,13 @@ form .btn { .modal .modal-body { padding: 3rem; } -.modal .modal-addendum { - padding-right: 3rem; - padding-left: 3rem; } +.modal #form-createTS .modal-addendum { + display: flex; + flex-wrap: wrap; + gap: 1rem; } + .modal #form-createTS .modal-addendum .form-group { + flex: 1 1 220px; + margin-bottom: 0; } .system-design-error .modal-body { display: flex; @@ -4598,5 +4602,3 @@ nav.navbar { height: 100%; background-color: #E3EAEE; z-index: 1; } - -/*# sourceMappingURL=main.css.map */ diff --git a/app/static/scss/components/_modals.scss b/app/static/scss/components/_modals.scss index d0f2530d1..0fdab04ff 100644 --- a/app/static/scss/components/_modals.scss +++ b/app/static/scss/components/_modals.scss @@ -12,9 +12,15 @@ padding: 3rem; } - .modal-addendum { - padding-right: 3rem; - padding-left: 3rem; + #form-createTS .modal-addendum { + display: flex; + flex-wrap: wrap; + gap: 1rem; + + .form-group { + flex: 1 1 220px; + margin-bottom: 0; + } } } diff --git a/app/templates/asset/timeseries_input.html b/app/templates/asset/timeseries_input.html index 4037a929f..d6e6d0b70 100644 --- a/app/templates/asset/timeseries_input.html +++ b/app/templates/asset/timeseries_input.html @@ -32,20 +32,17 @@ {% include subwidget.template_name with widget=subwidget %} {% if asset_type in custom_form_assets %} -
-
-
-
- {% csrf_token %} - -
- -
+
+
+ {% csrf_token %} + +
+
{% endif %}
From 1d7fd15899eb67efa01413f82f29bd677c240990 Mon Sep 17 00:00:00 2001 From: paulapreuss Date: Wed, 26 Aug 2026 15:29:52 +0200 Subject: [PATCH 06/14] Replace outdoor temperature with DualNumberField --- app/projects/forms.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/app/projects/forms.py b/app/projects/forms.py index e0d628f46..c483db962 100644 --- a/app/projects/forms.py +++ b/app/projects/forms.py @@ -1337,16 +1337,9 @@ class CreateHeatDemandForm(OpenPlanForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # TODO: is this meant to be a DualNumberField? - outdoor_temperature = forms.FloatField( + outdoor_temperature = DualNumberField( label=_("Outdoor Temperature"), - widget=forms.NumberInput( - attrs={ - "placeholder": _("e.g. 25"), - "data-bs-toggle": "tooltip", - "title": _("Outside air temperature in °C"), - } - ), + param_name="outdoor_temperature", ) profile_type = forms.ChoiceField( From f03f8e71a20bba8e03919184a189008ae2c759e6 Mon Sep 17 00:00:00 2001 From: paulapreuss Date: Wed, 26 Aug 2026 15:45:55 +0200 Subject: [PATCH 07/14] Compute timeseries from eesyplan and return to modal --- app/projects/views.py | 20 +++++++++++++---- app/static/js/grid_model_topology.js | 32 +++++++++++++++------------- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/app/projects/views.py b/app/projects/views.py index d646cc848..7bf426be7 100644 --- a/app/projects/views.py +++ b/app/projects/views.py @@ -1823,15 +1823,27 @@ def custom_timeseries_create(request, scen_id=0, asset_type_name="", asset_uuid= # TODO: calculate from relevant function # for pv timeseries, add lat/lon to the params dict # should be able to just pass the validated form as dict as **form + cleaned_data = form.cleaned_data + outdoor_temperature = cleaned_data.get("outdoor_temperature") + if outdoor_temperature is not None and not isinstance( + outdoor_temperature, list + ): + cleaned_data["outdoor_temperature"] = [ + outdoor_temperature + ] * scenario.get_num_timesteps + custom_ts_fun = custom_timeseries_functions[asset_type_name] - timeseries = custom_ts_fun(**form.cleaned_data) + timeseries = custom_ts_fun(**cleaned_data) - # TODO: assign the timeseries to the asset field and also return it for display (same format as get_timeseries) return JsonResponse( - {"success": True, "timeseries": timeseries}, + {"success": True, "timeseries": timeseries.values.tolist()}, status=200, ) - except: + except Exception: + logger.exception( + "Failed to compute custom timeseries for asset type %s", + asset_type_name, + ) return JsonResponse({"success": False}, status=422) logger.warning("The submitted asset has erroneous field values.") diff --git a/app/static/js/grid_model_topology.js b/app/static/js/grid_model_topology.js index 7a4589cd0..b993157f6 100644 --- a/app/static/js/grid_model_topology.js +++ b/app/static/js/grid_model_topology.js @@ -747,38 +747,40 @@ function computeCustomTimeseries(event){ const topologyNodeId = guiModalDOM.getAttribute("data-node-topo-id"); // e.g. 'node-2' const form = event.target.closest('.modal-content').querySelector('#timeseriesForm'); - const formData = new FormData(form); + const formData = new FormData(); + // because we can't have nested forms in the html, we construct the form data manually from the fields here instead of relying on the form tag + // TODO: if some of the custom forms rely on more than input, select, need to adapt + form.querySelectorAll('input, select').forEach(el => { + formData.append(el.name, el.value); + }); - let postUrl = copPostUrl + assetTypeName; + let postUrl = createTimeseriesPostUrl + assetTypeName; if (nodesToDB.has(topologyNodeId)) postUrl += "/" + nodesToDB.get(topologyNodeId).uid; + const createTsDOM = event.target.closest('#form-createTS'); + const paramName = createTsDOM.dataset.paramName; + fetch(postUrl, { method: 'POST', headers: {'X-CSRFToken': csrfToken}, body: formData, }).then(response => response.json()).then(jsonRes => { if (jsonRes.success) { - // close the cop area - copCollapse.hide(); - - efficiencyDOM = guiModalDOM.querySelector('input[name="efficiency_scalar"]'); - if(efficiencyDOM){ - efficiencyDOM.value = jsonRes.cops; efficiencyDOM.dispatchEvent(new Event('change')); - } - copDOM = guiModalDOM.querySelector('input[name="copId"]'); - if(copDOM){ - copDOM.value = jsonRes.cop_id; - } + // show the computed timeseries in the usual field in the modal (same as select/upload do), + // and store it there as the manual/scalar value so it gets saved with the asset + const tsValues = jsonRes.timeseries; + updateScalarInput(tsValues.map(v => [v]), paramName); + plotTimeseriesInputTrace(tsValues, paramName); } else { - // not success: assign the content of the form to the form tag of the modal - guiModalDOM.querySelector('form .modal-addendum').innerHTML = jsonRes.form_html; + form.innerHTML = jsonRes.form_html; } }).catch(error => { console.error(error); alert(error.message); }); } + function toggle_cop_modal(){ // get the parameters which uniquely identify the asset const assetTypeName = guiModalDOM.getAttribute("data-node-type"); From 45b5a71fab5e7eccf2a0d4fcbf1af9521e26e0bb Mon Sep 17 00:00:00 2001 From: paulapreuss Date: Wed, 26 Aug 2026 15:46:26 +0200 Subject: [PATCH 08/14] Handle custom timeseries saving --- app/projects/forms.py | 10 ++++++---- app/projects/helpers.py | 7 ++----- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/app/projects/forms.py b/app/projects/forms.py index c483db962..71c8e4e0d 100644 --- a/app/projects/forms.py +++ b/app/projects/forms.py @@ -1002,9 +1002,11 @@ def assign_timeseries_from_input(self, input_timeseries): ts_asset_type = ASSET_TO_TIMESERIES_ASSET_TYPE.get(asset_type_name) if input_timeseries["input_method"]["type"] == TS_MANUAL_TYPE: - timeseries_name = f"constant value = {timeseries_values[0]}" - timeseries_values = timeseries_values - ts_default_settings["ts_type"] = "scalar" + if len(timeseries_values) == 1: + timeseries_name = f"constant value = {timeseries_values[0]}" + ts_default_settings["ts_type"] = "scalar" + else: + timeseries_name = f"Created timeseries ({self.asset_type_name})" timeseries, created = Timeseries.objects.get_or_create( values=timeseries_values, @@ -1381,7 +1383,7 @@ def __init__(self, *args, **kwargs): wind_class = forms.ChoiceField( label=_("Wind class"), - choices=(("windy", _("Windy")), ("not_windy", _("Not Windy"))), + choices=(("Windy", _("Windy")), ("Not windy", _("Not Windy"))), widget=forms.Select( attrs={ "data-bs-toggle": "tooltip", diff --git a/app/projects/helpers.py b/app/projects/helpers.py index 0d9bb93a4..5727b2986 100644 --- a/app/projects/helpers.py +++ b/app/projects/helpers.py @@ -371,11 +371,8 @@ def decompress(self, value): logging.error("The value of timeseries index is not an integer") ts_qs = Timeseries.objects.filter(id=value) if ts_qs.exists(): - ts_name = ts_qs.values_list("name", flat=True).get() - if "constant value = " in ts_name: - scalar_value = float(ts_name.replace("constant value = ", "")) - else: - scalar_value = None + ts = ts_qs.get() + scalar_value = ts.values[0] if ts.ts_type == "scalar" else None answer = [scalar_value, value, ""] return answer From e3f9766847c4aa7387a2ef107f9ed672d65b7ccf Mon Sep 17 00:00:00 2001 From: paulapreuss Date: Wed, 26 Aug 2026 15:47:12 +0200 Subject: [PATCH 09/14] Fix nested form issues --- app/templates/asset/timeseries_input.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/templates/asset/timeseries_input.html b/app/templates/asset/timeseries_input.html index d6e6d0b70..b371dd4f8 100644 --- a/app/templates/asset/timeseries_input.html +++ b/app/templates/asset/timeseries_input.html @@ -32,14 +32,14 @@ {% include subwidget.template_name with widget=subwidget %} {% if asset_type in custom_form_assets %} -
-
- {% csrf_token %} +
+
- +
From 6269b618700abf9485ab96a344564e7b0141b537 Mon Sep 17 00:00:00 2001 From: paulapreuss Date: Wed, 26 Aug 2026 18:44:29 +0200 Subject: [PATCH 10/14] Add generation parameters metadata to timeseries display --- app/projects/forms.py | 5 +++++ app/projects/helpers.py | 17 +++++++++++++---- app/projects/views.py | 22 ++++++++++++++++++++-- app/static/js/grid_model_topology.js | 5 ++++- app/static/js/traceplot.js | 15 +++++++++++++++ app/templates/asset/timeseries_input.html | 1 + 6 files changed, 58 insertions(+), 7 deletions(-) diff --git a/app/projects/forms.py b/app/projects/forms.py index 71c8e4e0d..754ec8344 100644 --- a/app/projects/forms.py +++ b/app/projects/forms.py @@ -1007,6 +1007,11 @@ def assign_timeseries_from_input(self, input_timeseries): ts_default_settings["ts_type"] = "scalar" else: timeseries_name = f"Created timeseries ({self.asset_type_name})" + generation_parameters = input_timeseries["input_method"].get( + "generation_parameters" + ) + if generation_parameters: + ts_default_settings["generation_parameters"] = generation_parameters timeseries, created = Timeseries.objects.get_or_create( values=timeseries_values, diff --git a/app/projects/helpers.py b/app/projects/helpers.py index 5727b2986..b174d0f3f 100644 --- a/app/projects/helpers.py +++ b/app/projects/helpers.py @@ -459,17 +459,26 @@ def clean(self, values): input_dict = dict(type=TS_SELECT_TYPE, extra_info=timeseries_id) elif scalar_value != "": - # check the input string is a number or a list + # check the input string is a number, a list, or a + # {"values": [...], "generation_parameters": {...}} payload + generation_parameters = None try: answer = [float(scalar_value.replace(",", "."))] except ValueError: try: - answer = json.loads(scalar_value) - if not isinstance(answer, list): + parsed = json.loads(scalar_value) + if isinstance(parsed, list): + answer = parsed + elif isinstance(parsed, dict) and "values" in parsed: + answer = parsed["values"] + generation_parameters = parsed.get("generation_parameters") + else: scalar_value = "" except json.decoder.JSONDecodeError: scalar_value = "" - input_dict = dict(type=TS_MANUAL_TYPE) + input_dict = dict( + type=TS_MANUAL_TYPE, generation_parameters=generation_parameters + ) elif scalar_value == "": self.set_widget_error() raise ValidationError( diff --git a/app/projects/views.py b/app/projects/views.py index 7bf426be7..30ccbf510 100644 --- a/app/projects/views.py +++ b/app/projects/views.py @@ -1554,7 +1554,12 @@ def get_timeseries(request, ts_id=None): if request.method == "GET": if ts_id is not None: ts = Timeseries.objects.get(id=ts_id) - return JsonResponse({"values": ts.get_values}) + return JsonResponse( + { + "values": ts.get_values, + "generation_parameters": ts.generation_parameters, + } + ) @json_view @@ -1824,6 +1829,15 @@ def custom_timeseries_create(request, scen_id=0, asset_type_name="", asset_uuid= # for pv timeseries, add lat/lon to the params dict # should be able to just pass the validated form as dict as **form cleaned_data = form.cleaned_data + # outdoor_temperature is excluded from the saved generation parameters: + # it can itself be a full timeseries and we don't want to save a whole + # other timeseries as metadata + generation_parameters = { + key: value + for key, value in cleaned_data.items() + if key != "outdoor_temperature" + } + outdoor_temperature = cleaned_data.get("outdoor_temperature") if outdoor_temperature is not None and not isinstance( outdoor_temperature, list @@ -1836,7 +1850,11 @@ def custom_timeseries_create(request, scen_id=0, asset_type_name="", asset_uuid= timeseries = custom_ts_fun(**cleaned_data) return JsonResponse( - {"success": True, "timeseries": timeseries.values.tolist()}, + { + "success": True, + "timeseries": timeseries.values.tolist(), + "generation_parameters": generation_parameters, + }, status=200, ) except Exception: diff --git a/app/static/js/grid_model_topology.js b/app/static/js/grid_model_topology.js index b993157f6..fa9534f87 100644 --- a/app/static/js/grid_model_topology.js +++ b/app/static/js/grid_model_topology.js @@ -768,10 +768,13 @@ function computeCustomTimeseries(event){ }).then(response => response.json()).then(jsonRes => { if (jsonRes.success) { // show the computed timeseries in the usual field in the modal (same as select/upload do), - // and store it there as the manual/scalar value so it gets saved with the asset + // and store it (together with the parameters used to generate it) as the manual/scalar + // value so it gets saved with the asset const tsValues = jsonRes.timeseries; + const generationParameters = jsonRes.generation_parameters; updateScalarInput(tsValues.map(v => [v]), paramName); plotTimeseriesInputTrace(tsValues, paramName); + showGenerationParameters(generationParameters, paramName); } else { form.innerHTML = jsonRes.form_html; } diff --git a/app/static/js/traceplot.js b/app/static/js/traceplot.js index cc0ae18b1..02b5a288b 100644 --- a/app/static/js/traceplot.js +++ b/app/static/js/traceplot.js @@ -84,10 +84,25 @@ function getTimeseriesValues(ts_id, param_name=""){ else { plotTimeseriesInputTrace(ts_values, param_name=param_name); } + showGenerationParameters(data["generation_parameters"], param_name); console.log("retrieved values", ts_values); }); } +/* Show the parameters a timeseries was generated from (if any) in a small +
 block under its trace plot, e.g. templates/asset/timeseries_input.html */
+function showGenerationParameters(generationParameters, param_name=""){
+    const el = document.getElementById(param_name + "_generation_parameters");
+    if (!el) return;
+    if (generationParameters && Object.keys(generationParameters).length > 0){
+        el.textContent = JSON.stringify(generationParameters, null, 2);
+        el.style.display = "block";
+    } else {
+        el.textContent = "";
+        el.style.display = "none";
+    }
+}
+
 
 function getConstantTimeseriesId(value){
     //findtsGetUrl is defined in scenario_step2.html
diff --git a/app/templates/asset/timeseries_input.html b/app/templates/asset/timeseries_input.html
index b371dd4f8..ae7455c4d 100644
--- a/app/templates/asset/timeseries_input.html
+++ b/app/templates/asset/timeseries_input.html
@@ -50,5 +50,6 @@
 	{% endfor %}{% endspaceless %}
 
+ {% url 'get_timeseries' as ts_url %} {{ ts_url|json_script:"tsUrl" }} From 96a75ce02ba064e8b0be35c6bca94f3823f72a2d Mon Sep 17 00:00:00 2001 From: paulapreuss Date: Wed, 26 Aug 2026 18:46:32 +0200 Subject: [PATCH 11/14] Fix new timeseries on same asset not saving --- app/static/js/grid_model_topology.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/static/js/grid_model_topology.js b/app/static/js/grid_model_topology.js index fa9534f87..8bbf9365f 100644 --- a/app/static/js/grid_model_topology.js +++ b/app/static/js/grid_model_topology.js @@ -772,7 +772,15 @@ function computeCustomTimeseries(event){ // value so it gets saved with the asset const tsValues = jsonRes.timeseries; const generationParameters = jsonRes.generation_parameters; - updateScalarInput(tsValues.map(v => [v]), paramName); + // clear the other subfields first: the scalar field's onchange handler + // (initTimeseriesManualValue) re-triggers the select field's own change handler + // with whatever it currently holds, which would otherwise re-fetch and clobber + // the value we're about to set here with a stale, previously-selected timeseries + document.getElementById('id_' + paramName + '_1').value = ''; + document.getElementById('id_' + paramName + '_2').value = ''; + const scalarInput = document.getElementById('id_' + paramName + '_0'); + scalarInput.value = JSON.stringify({values: tsValues, generation_parameters: generationParameters}); + scalarInput.dispatchEvent(new Event('change')); plotTimeseriesInputTrace(tsValues, paramName); showGenerationParameters(generationParameters, paramName); } else { From 989e4c90de6b89eb9fed2b6e6add576cb1bff902 Mon Sep 17 00:00:00 2001 From: paulapreuss Date: Wed, 26 Aug 2026 19:00:11 +0200 Subject: [PATCH 12/14] Add migration file --- .../0029_timeseries_description_and_more.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 app/projects/migrations/0029_timeseries_description_and_more.py diff --git a/app/projects/migrations/0029_timeseries_description_and_more.py b/app/projects/migrations/0029_timeseries_description_and_more.py new file mode 100644 index 000000000..edb0bf47d --- /dev/null +++ b/app/projects/migrations/0029_timeseries_description_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.13 on 2026-08-26 18:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0028_sensitivityanalysis_server_simulation_server'), + ] + + operations = [ + migrations.AddField( + model_name='timeseries', + name='description', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='timeseries', + name='generation_parameters', + field=models.JSONField(blank=True, null=True), + ), + ] From ca2568bffbc7ac3aa8e381e0d5fe5424a0c9002e Mon Sep 17 00:00:00 2001 From: paulapreuss Date: Wed, 26 Aug 2026 19:13:32 +0200 Subject: [PATCH 13/14] Disable custom PV timeseries for now --- app/projects/forms.py | 12 +++++------- app/projects/helpers.py | 3 ++- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/app/projects/forms.py b/app/projects/forms.py index 754ec8344..6cf1faddd 100644 --- a/app/projects/forms.py +++ b/app/projects/forms.py @@ -878,12 +878,9 @@ def clean_input_timeseries_old(self): if timeseries_file is not None: input_timeseries_values = parse_input_timeseries(timeseries_file) # TODO here list the possible options - else: - # set the previous timeseries from the asset if any - if self.is_input_timeseries_empty() is False: - input_timeseries_values = ( - self.existing_asset.input_timeseries_values - ) + # set the previous timeseries from the asset if any + elif self.is_input_timeseries_empty() is False: + input_timeseries_values = self.existing_asset.input_timeseries_values return input_timeseries_values except json.decoder.JSONDecodeError as ex: raise ValidationError( @@ -1401,6 +1398,7 @@ def __init__(self, *args, **kwargs): CUSTOM_TIMESERIES_FORMS = { - "pv_plant": CreatePVProductionTimeseriesForm, + # TODO: re-enable PV timeseries creation when weather data handling is settled + # "pv_plant": CreatePVProductionTimeseriesForm, "heat_demand": CreateHeatDemandForm, } diff --git a/app/projects/helpers.py b/app/projects/helpers.py index b174d0f3f..9a2369cb8 100644 --- a/app/projects/helpers.py +++ b/app/projects/helpers.py @@ -320,7 +320,8 @@ def set_widget_error(self): class TimeseriesInputWidget(forms.MultiWidget): template_name = "asset/timeseries_input.html" - custom_form_assets = ["pv_plant", "heat_demand"] + # TODO: currently hardcoded instead of taken from CUSTOM_FORM_ASSETS to avoid circular import with forms + custom_form_assets = ["heat_demand"] # class Media: # # TODO: currently not loading the content as not within head From 481a4da93caf9213cb4b8657fd74acf1f71cce9d Mon Sep 17 00:00:00 2001 From: paulapreuss Date: Thu, 27 Aug 2026 08:13:21 +0200 Subject: [PATCH 14/14] Update changelog --- app/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index 8a431628d..fb790136c 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog ## [Unreleased] +### Added +- Allow user to generate custom timeseries (currently only heat demand) from eesyplan functions [[#511](https://github.com/open-plan-tool/gui/pull/511)] ### Fixed - Fix issues with read and edit rights on shared projects [[#478](https://github.com/open-plan-tool/gui/pull/478)]