From bf9d3bc8a74dd7e02657ffa25acf3b219900ab22 Mon Sep 17 00:00:00 2001 From: "pierre-francois.duc" Date: Tue, 24 Mar 2026 20:53:46 +0100 Subject: [PATCH 1/5] Move connection port mapping to method of ConnectionLink --- app/projects/models/base_models.py | 42 +++++++++++++++++++++++ app/projects/scenario_topology_helpers.py | 38 ++------------------ 2 files changed, 44 insertions(+), 36 deletions(-) diff --git a/app/projects/models/base_models.py b/app/projects/models/base_models.py index 8a17cdaf..169a82ea 100644 --- a/app/projects/models/base_models.py +++ b/app/projects/models/base_models.py @@ -1042,6 +1042,48 @@ def __str__(self): asset_connection_port = "input_1" return f"{self.bus.name}.{self.bus_connection_port} → {self.asset.name}.{asset_connection_port} (scenario {self.scenario.name})" + def assign_port_if_missing(self): + asset_connection_port = self.asset_connection_port + flow_direction = self.flow_direction + if asset_connection_port == "no_mapping": + logging.warning( + "A connection had no mapping to asset port, probably old scenario, assigning a mapping ..." + ) + + energy_vector = self.bus.type + asset_type = self.asset.asset_type + if flow_direction == "A2B": + asset_connection_port = "output_1" + if asset_type.n_outputs > 1: + qs = asset_type.ports.filter( + energy_vector=energy_vector, direction="output" + ) + if qs.exists(): + asset_connection_port = qs.first().port_key + else: + logging.warning( + f"No output port with energy carrier for {energy_vector} found within the port mapping of the component {db_connection.asset.name}" + ) + + elif flow_direction == "B2A": + asset_connection_port = "input_1" + if asset_type.n_inputs > 1: + qs = asset_type.ports.filter( + energy_vector=energy_vector, direction="input" + ) + if qs.exists(): + asset_connection_port = qs.first().port_key + else: + logging.warning( + f"No input port with energy carrier for {energy_vector} found within the port mapping of the component {db_connection.asset.name}" + ) + self.asset_connection_port = asset_connection_port + self.save(update_fields=["asset_connection_port"]) + logging.warning( + f"... the asset {self.asset.name} port to connect to the bus {self.bus.name} was set to {asset_connection_port}" + ) + return asset_connection_port + class Constraint(models.Model): scenario = models.ForeignKey(Scenario, on_delete=models.CASCADE, null=False) diff --git a/app/projects/scenario_topology_helpers.py b/app/projects/scenario_topology_helpers.py index 659e76c4..7d9ee424 100644 --- a/app/projects/scenario_topology_helpers.py +++ b/app/projects/scenario_topology_helpers.py @@ -438,43 +438,9 @@ def db_connection_links_to_list(scen_id): all_db_connection_links = ConnectionLink.objects.filter(scenario_id=scen_id) connections_list = list() for db_connection in all_db_connection_links: - asset_connection_port = db_connection.asset_connection_port - flow_direction = db_connection.flow_direction - if asset_connection_port == "no_mapping": - logging.warning( - "A connection had no mapping to asset port, probably old scenario, assigning a mapping ..." - ) - - energy_vector = db_connection.bus.type - asset_type = db_connection.asset.asset_type - if flow_direction == "A2B": - asset_connection_port = "output_1" - if asset_type.n_outputs > 1: - qs = asset_type.ports.filter( - energy_vector=energy_vector, direction="output" - ) - if qs.exists(): - asset_connection_port = qs.first().port_key - else: - logging.warning( - f"No output port with energy carrier for {energy_vector} found within the port mapping of the component {db_connection.asset.name}" - ) + asset_connection_port = db_connection.assign_port_if_missing() - elif flow_direction == "B2A": - asset_connection_port = "input_1" - if asset_type.n_inputs > 1: - qs = asset_type.ports.filter( - energy_vector=energy_vector, direction="input" - ) - if qs.exists(): - asset_connection_port = qs.first().port_key - else: - logging.warning( - f"No input port with energy carrier for {energy_vector} found within the port mapping of the component {db_connection.asset.name}" - ) - logging.warning( - f"... the asset {db_connection.asset.name} port to connect to the bus {db_connection.bus.name} was set to {asset_connection_port}" - ) + flow_direction = db_connection.flow_direction db_connection_dict = { "bus_id": db_connection.bus_id, "asset_id": db_connection.asset.unique_id, From 51105c5fa6e6a29ef6cb76c3a7001ef45cf18ab3 Mon Sep 17 00:00:00 2001 From: "pierre-francois.duc" Date: Tue, 24 Mar 2026 20:55:54 +0100 Subject: [PATCH 2/5] Assign a port when creating datapackage --- app/projects/models/base_models.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/projects/models/base_models.py b/app/projects/models/base_models.py index 169a82ea..8b49d5b7 100644 --- a/app/projects/models/base_models.py +++ b/app/projects/models/base_models.py @@ -859,6 +859,12 @@ def to_datapackage(self): # to collect the bus(ses) used by the asset bus_resource_rec = [] + qs_unmapped_ports = self.connectionlink_set.filter( + asset_connection_port="no_mapping" + ) + for connection in qs_unmapped_ports: + connection.assign_port_if_missing() + if hasattr(self.asset_type, "connection_ports"): # port mapping contains the information to what bus is expected to be connected to which port port_mapping = self.asset_type.connection_ports From eff6699e70b719f46d9b88d88841711bd8c70a31 Mon Sep 17 00:00:00 2001 From: "pierre-francois.duc" Date: Tue, 24 Mar 2026 20:56:42 +0100 Subject: [PATCH 3/5] Allow one to reduce the size of the timeindex of datapackage This is useful to get a smaller datapackage for quick testing --- app/projects/models/base_models.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/app/projects/models/base_models.py b/app/projects/models/base_models.py index 8b49d5b7..93c1ecf4 100644 --- a/app/projects/models/base_models.py +++ b/app/projects/models/base_models.py @@ -313,7 +313,19 @@ def export(self, bind_project_data=False): dm["busses"] = busses return dm - def to_datapackage(self, destination_path): + def to_datapackage(self, destination_path, number=None): + """ + + Parameters + ---------- + destination_path: Path + Path where the datapackage should be saved + number: int + Number of timesteps which should be considered for the exported datapackage + Returns + ------- + A Path to the scenario datapackage + """ # Create a folder with a datapackage structure scenario_folder = destination_path / f"scenario_{self.name}".replace(" ", "_") @@ -392,6 +404,10 @@ def to_datapackage(self, destination_path): f"Some profiles have more timesteps that other profiles in scenario {self.name}({self.id}) --> the shorter profiles will be expanded with NaN values" ) # TODO check if there are column duplicates + + # restrict the size of the profiles + if number is not None: + df = df.iloc[:number] df.set_index("timeindex").to_csv(out_path, index=True) # creating datapackage.json metadata file at the root of the datapackage From 932b2b7ce5387ba739f2b764a2278eeead89dcf6 Mon Sep 17 00:00:00 2001 From: "pierre-francois.duc" Date: Tue, 24 Mar 2026 21:16:18 +0100 Subject: [PATCH 4/5] Allow exporting scenaroi datapackage with less timesteps --- app/projects/urls.py | 5 +++++ app/projects/views.py | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/projects/urls.py b/app/projects/urls.py index 642f48b6..940c54f2 100644 --- a/app/projects/urls.py +++ b/app/projects/urls.py @@ -148,6 +148,11 @@ scenario_export_as_datapackage, name="scenario_export_as_datapackage", ), + path( + "scenario/export/datapackage//number/", + scenario_export_as_datapackage, + name="scenario_export_as_datapackage", + ), path("scenario/upload/", scenario_upload, name="scenario_upload"), # path('scenario/upload/', LoadScenarioFromFileView.as_view(), name='scenario_upload'), # Timeseries Model diff --git a/app/projects/views.py b/app/projects/views.py index f2d036c0..92cffe6a 100644 --- a/app/projects/views.py +++ b/app/projects/views.py @@ -1351,7 +1351,7 @@ def scenario_export(request, proj_id): @login_required @require_http_methods(["GET"]) -def scenario_export_as_datapackage(request, scen_id): +def scenario_export_as_datapackage(request, scen_id, n_timestamps=None): scenario = get_object_or_404(Scenario, id=int(scen_id)) if scenario.project.user != request.user: @@ -1360,7 +1360,7 @@ def scenario_export_as_datapackage(request, scen_id): with tempfile.TemporaryDirectory() as temp_dir: destination_path = Path(temp_dir) # write the content of the scenario into a temp directory - scenario_folder = scenario.to_datapackage(destination_path) + scenario_folder = scenario.to_datapackage(destination_path, number=n_timestamps) # Place the temp directory into a zip folder zip_buffer = io.BytesIO() From 48b03925d2c22c0150ca1a41bc0e421e3df34982 Mon Sep 17 00:00:00 2001 From: "pierre-francois.duc" Date: Tue, 24 Mar 2026 21:17:20 +0100 Subject: [PATCH 5/5] Allow exporting project datapackages The project consist simply of two datapackages --- app/projects/urls.py | 10 ++++++++++ app/projects/views.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/app/projects/urls.py b/app/projects/urls.py index 940c54f2..8314f7d9 100644 --- a/app/projects/urls.py +++ b/app/projects/urls.py @@ -153,6 +153,16 @@ scenario_export_as_datapackage, name="scenario_export_as_datapackage", ), + path( + "project/export/datapackage/", + project_export_as_datapackage, + name="project_export_as_datapackage", + ), + path( + "project/export/datapackage//number/", + project_export_as_datapackage, + name="project_export_as_datapackage", + ), path("scenario/upload/", scenario_upload, name="scenario_upload"), # path('scenario/upload/', LoadScenarioFromFileView.as_view(), name='scenario_upload'), # Timeseries Model diff --git a/app/projects/views.py b/app/projects/views.py index 92cffe6a..5937e005 100644 --- a/app/projects/views.py +++ b/app/projects/views.py @@ -1382,6 +1382,40 @@ def scenario_export_as_datapackage(request, scen_id, n_timestamps=None): return response +@login_required +@require_http_methods(["GET"]) +def project_export_as_datapackage(request, proj_id, n_timestamps=None): + project = get_object_or_404(Project, id=int(proj_id)) + + if project.user != request.user: + raise PermissionDenied + + with tempfile.TemporaryDirectory() as temp_dir: + destination_path = Path(temp_dir) + + for scenario in project.scenario_set.all(): + scenario.to_datapackage(destination_path, number=n_timestamps) + + # Place the temp directory into a zip folder + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zip_file: + for file_path in destination_path.rglob("*"): # Recursively walk all files + if file_path.is_file(): + # Relative path inside ZIP + arcname = file_path.relative_to(destination_path) + with open(file_path, "rb") as f: + zip_file.writestr(str(arcname), f.read()) + + # Let the user download the zip file + zip_buffer.seek(0) + response = HttpResponse(zip_buffer.getvalue(), content_type="application/zip") + response["Content-Disposition"] = ( + f'attachment; filename="datapackage_project_{proj_id}.zip"' + ) + + return response + + @login_required @require_http_methods(["POST"]) def scenario_delete(request, scen_id):