diff --git a/app/projects/forms.py b/app/projects/forms.py index 8680d534..4415c1bc 100644 --- a/app/projects/forms.py +++ b/app/projects/forms.py @@ -1,47 +1,32 @@ +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.helpers import ( - parameters_helper, - PARAMETERS, - DualNumberField, - parse_input_timeseries, - TimeseriesField, - TS_SELECT_TYPE, - TS_UPLOAD_TYPE, - TS_MANUAL_TYPE, -) + from projects.constants import ASSET_TO_TIMESERIES_ASSET_TYPE +from projects.constants import CURRENCY_SYMBOLS +from projects.constants import RENEWABLE_ASSETS +from projects.helpers import PARAMETERS +from projects.helpers import TS_MANUAL_TYPE +from projects.helpers import TS_SELECT_TYPE +from projects.helpers import TS_UPLOAD_TYPE +from projects.helpers import DualNumberField +from projects.helpers import TimeseriesField +from projects.helpers import parameters_helper +from projects.models import * def gettext_variables(some_string, lang="de"): @@ -109,9 +94,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 +121,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 +134,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 +151,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 +162,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 +314,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 +695,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) @@ -882,37 +865,6 @@ def is_input_timeseries_empty(self): else: return True - def clean_input_timeseries_old(self): - """Override built-in Form method which is called upon form validation""" - try: - input_timeseries_values = [] - timeseries_file = self.files.get("input_timeseries_file", None) - # read the timeseries from file if any - 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 - ) - return input_timeseries_values - except json.decoder.JSONDecodeError as ex: - raise ValidationError( - _( - "File not properly formatted. Please ensure you upload a comma separated array of values. E.g. [1,2,0.32]" - ) - ) - except TypeError as e: - raise ValidationError(str(e)) - except Exception as ex: - raise ValidationError( - _( - f"Could not parse a file due to the following error: {ex}. Did you upload a file?" - ) - ) - def clean_efficiency_multiple(self): data = self.cleaned_data["efficiency_multiple"] if self.asset_type_name == "chp_fixed_ratio": @@ -1078,11 +1030,6 @@ class Meta: "lifetime": forms.NumberInput( attrs={"placeholder": "e.g. 10 years", "min": "0", "step": "1"} ), - "input_timeseries_old": forms.FileInput( - attrs={ - "onchange": "plot_file_trace(obj=this.files, plot_id='timeseries_trace')" - } - ), "crate": forms.NumberInput( attrs={ "placeholder": "factor of total capacity (kWh), e.g. 0.7", @@ -1159,7 +1106,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 @@ -1226,3 +1173,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/helpers.py b/app/projects/helpers.py index e2fe3574..0d9bb93a 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 @@ -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", @@ -354,7 +356,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 @@ -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 @@ -567,11 +574,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 +601,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 +626,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/migrations/0029_remove_asset_input_timeseries_old.py b/app/projects/migrations/0029_remove_asset_input_timeseries_old.py new file mode 100644 index 00000000..a4b9b4d6 --- /dev/null +++ b/app/projects/migrations/0029_remove_asset_input_timeseries_old.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.13 on 2026-08-11 16:27 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0028_sensitivityanalysis_server_simulation_server'), + ] + + operations = [ + migrations.RemoveField( + model_name='asset', + name='input_timeseries_old', + ), + ] diff --git a/app/projects/migrations/0030_timeseries_description_and_more.py b/app/projects/migrations/0030_timeseries_description_and_more.py new file mode 100644 index 00000000..c47385b6 --- /dev/null +++ b/app/projects/migrations/0030_timeseries_description_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.13 on 2026-08-13 08:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0029_remove_asset_input_timeseries_old'), + ] + + 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), + ), + ] diff --git a/app/projects/models/base_models.py b/app/projects/models/base_models.py index e6b4d4ef..aea1b523 100644 --- a/app/projects/models/base_models.py +++ b/app/projects/models/base_models.py @@ -1,44 +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.core.validators import MaxValueValidator, MinValueValidator +from django.contrib.postgres.fields import ArrayField +from django.core.validators import MaxValueValidator +from django.core.validators import 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 projects.constants import ( - ASSET_CATEGORY, - ASSET_TYPE, - COUNTRY, - CURRENCY, - ENERGY_VECTOR, - COP_MODES, - FLOW_DIRECTION, - MVS_TYPE, - SIMULATION_STATUS, - SIMULATION_SERVERS, - PENDING, - TRUE_FALSE_CHOICES, - BOOL_CHOICES, - USER_RATING, - TIMESERIES_UNITS, - TIMESERIES_CATEGORIES, - TIMESERIES_TYPES, - TIMESERIES_ASSET_TYPES, -) +from oemof.datapackage.datapackage import export_dp_to_json from users.models import CustomUser +from projects.constants import ASSET_CATEGORY +from projects.constants import ASSET_TYPE +from projects.constants import BOOL_CHOICES +from projects.constants import COP_MODES +from projects.constants import COUNTRY +from projects.constants import CURRENCY +from projects.constants import ENERGY_VECTOR +from projects.constants import FLOW_DIRECTION +from projects.constants import MVS_TYPE +from projects.constants import PENDING +from projects.constants import SIMULATION_SERVERS +from projects.constants import SIMULATION_STATUS +from projects.constants import TIMESERIES_ASSET_TYPES +from projects.constants import TIMESERIES_CATEGORIES +from projects.constants import TIMESERIES_UNITS +from projects.constants import TRUE_FALSE_CHOICES +from projects.constants import USER_RATING + class Feedback(models.Model): name = models.CharField(max_length=100) @@ -141,12 +139,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 +151,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 +474,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 +537,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 +554,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 +562,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 +575,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() @@ -646,6 +642,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, @@ -851,9 +855,6 @@ def save(self, *args, **kwargs): lifetime = models.IntegerField( null=True, blank=False, validators=[MinValueValidator(0)] ) - input_timeseries_old = models.TextField( - null=True, blank=False - ) # , validators=[validate_timeseries]) input_timeseries = models.ForeignKey( Timeseries, on_delete=models.CASCADE, null=True, blank=False ) @@ -932,7 +933,6 @@ def get_field_value(self, field_name): "efficiency_multiple", "energy_price", "feedin_tariff", - "input_timeseries_old", ): if answer: try: diff --git a/app/projects/urls.py b/app/projects/urls.py index ad7edbbe..9e246502 100644 --- a/app/projects/urls.py +++ b/app/projects/urls.py @@ -1,4 +1,6 @@ -from django.urls import path, re_path +from django.urls import path +from django.urls import re_path + from .views import * urlpatterns = [ @@ -240,6 +242,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 a89396b2..ec721e7b 100644 --- a/app/projects/views.py +++ b/app/projects/views.py @@ -1,101 +1,91 @@ # 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 +from django.http import HttpResponseRedirect +from django.http import 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 +from django.shortcuts import redirect +from django.shortcuts import 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 epa.settings import EZP_GET_URL +from epa.settings import MVS_GET_URL +from epa.settings import MVS_LP_FILE_URL +from epa.settings import MVS_SA_GET_URL +from epa.settings import SHOW_EZP 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 ( - MVS_GET_URL, - MVS_LP_FILE_URL, - MVS_SA_GET_URL, - EZP_GET_URL, - SHOW_EZP, -) +from projects.decorators import user_has_edit_rights +from projects.decorators import user_has_read_rights +from projects.decorators import user_is_owner +from projects.helpers import PARAMETERS +from projects.helpers import format_scenario_for_mvs +from projects.models import Asset +from projects.models import AssetChangeTracker +from projects.models import AssetType +from projects.models import Bus +from projects.models import Comment +from projects.models import ConnectionLink +from projects.models import COPCalculator +from projects.models import EconomicData +from projects.models import MaxEmissionConstraint +from projects.models import MinDOAConstraint +from projects.models import MinRenewableConstraint +from projects.models import NZEConstraint +from projects.models import ParameterChangeTracker +from projects.models import Project +from projects.models import Scenario +from projects.models import SensitivityAnalysis +from projects.models import Simulation +from projects.models import Timeseries +from projects.models import UseCase + +from .constants import DONE +from .constants import ERROR +from .constants import MAX_STEP +from .constants import MODIFIED +from .constants import PENDING +from .constants import STEP_LIST 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 projects.models import ( - Project, - EconomicData, - Comment, - ConnectionLink, - AssetType, - UseCase, - Scenario, - Simulation, - ParameterChangeTracker, - AssetChangeTracker, - SensitivityAnalysis, - Asset, - Bus, - COPCalculator, - Timeseries, - MinDOAConstraint, - MinRenewableConstraint, - MaxEmissionConstraint, - NZEConstraint, -) -from projects.decorators import ( - user_is_owner, - user_has_read_rights, - user_has_edit_rights, -) -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, - load_project_from_dict, -) -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, -) -import traceback +from .requests import ezp_simulation_request +from .requests import fetch_ezp_simulation_results +from .requests import fetch_mvs_sa_results +from .requests import fetch_mvs_simulation_results +from .requests import mvs_sensitivity_analysis_request +from .requests import mvs_simulation_request +from .requests import parse_ezp_results +from .requests import parse_mvs_results +from .scenario_topology_helpers import NodeObject +from .scenario_topology_helpers import duplicate_scenario_connections +from .scenario_topology_helpers import duplicate_scenario_objects +from .scenario_topology_helpers import handle_asset_form_post +from .scenario_topology_helpers import handle_bus_form_post +from .scenario_topology_helpers import handle_storage_unit_form_post +from .scenario_topology_helpers import load_project_from_dict +from .scenario_topology_helpers import load_scenario_from_dict +from .scenario_topology_helpers import load_scenario_topology_from_db +from .scenario_topology_helpers import update_deleted_objects_from_database +from .services import excuses_design_under_development +from .services import get_selected_scenarios_in_cache +from .services import send_feedback_email logger = logging.getLogger(__name__) @@ -201,7 +191,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 +277,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 +294,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 +379,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 +865,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 +897,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 +1074,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 +1110,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: @@ -1709,6 +1695,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, @@ -1716,6 +1704,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) @@ -1794,6 +1783,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): @@ -1805,7 +1851,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 @@ -1841,9 +1887,9 @@ 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") + form_html = get_template("asset/asset_subform.html") return JsonResponse( {"success": False, "form_html": form_html.render({"form": form})}, status=422 ) diff --git a/app/static/css/main.css b/app/static/css/main.css index ea7f80ac..897f6aea 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/js/grid_model_topology.js b/app/static/js/grid_model_topology.js index 3d2c04d1..7a4589cd 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/static/scss/components/_modals.scss b/app/static/scss/components/_modals.scss index d0f2530d..0fdab04f 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/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 6b15476a..d6e6d0b7 100644 --- a/app/templates/asset/timeseries_input.html +++ b/app/templates/asset/timeseries_input.html @@ -15,20 +15,36 @@
- {% 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 %} + + {% include subwidget.template_name with widget=subwidget %} + {% if asset_type in custom_form_assets %} + +
+
+ {% csrf_token %} + +
+ +
+ {% endif %}
{% endif %} {% endfor %}{% endspaceless %} diff --git a/app/templates/scenario/scenario_step2.html b/app/templates/scenario/scenario_step2.html index c31cc66b..ba15296f 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' %}`;