From 18145c05d5219629107ab8a11d1edb735e9da67d Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE <43534797+laurent-laporte-pro@users.noreply.github.com> Date: Mon, 10 Jun 2024 18:15:02 +0200 Subject: [PATCH 01/47] fix(variants): display variants in reverse chronological order in the variants tree (#2059) --- antarest/study/storage/variantstudy/repository.py | 2 +- .../variant_blueprint/test_variant_manager.py | 5 +++-- tests/storage/business/test_repository.py | 14 +++++++------- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/antarest/study/storage/variantstudy/repository.py b/antarest/study/storage/variantstudy/repository.py index d2c676c2eb..3f5ca51fdf 100644 --- a/antarest/study/storage/variantstudy/repository.py +++ b/antarest/study/storage/variantstudy/repository.py @@ -50,7 +50,7 @@ def get_children(self, parent_id: str) -> t.List[VariantStudy]: List of `VariantStudy` objects, ordered by creation date. """ q = self.session.query(VariantStudy).filter(Study.parent_id == parent_id) - q = q.order_by(Study.created_at.asc()) + q = q.order_by(Study.created_at.desc()) studies = t.cast(t.List[VariantStudy], q.all()) return studies diff --git a/tests/integration/variant_blueprint/test_variant_manager.py b/tests/integration/variant_blueprint/test_variant_manager.py index a0e4a68108..82fa7ab95c 100644 --- a/tests/integration/variant_blueprint/test_variant_manager.py +++ b/tests/integration/variant_blueprint/test_variant_manager.py @@ -57,8 +57,9 @@ def test_variant_manager( assert len(children["children"]) == 1 assert children["children"][0]["node"]["name"] == "Variant1" assert len(children["children"][0]["children"]) == 2 - assert children["children"][0]["children"][0]["node"]["name"] == "bar" - assert children["children"][0]["children"][1]["node"]["name"] == "baz" + # Variant children are sorted by creation date in reverse order + assert children["children"][0]["children"][0]["node"]["name"] == "baz" + assert children["children"][0]["children"][1]["node"]["name"] == "bar" # George creates a base study # He creates a variant from this study : assert that no command is created diff --git a/tests/storage/business/test_repository.py b/tests/storage/business/test_repository.py index 9719c65228..5da1d6ef33 100644 --- a/tests/storage/business/test_repository.py +++ b/tests/storage/business/test_repository.py @@ -14,7 +14,7 @@ def test_get_children(self, db_session: Session) -> None: """ Given a root study with children and a grandchild When getting the children of the root study - Then the children are returned in chronological order + Then the children are returned in reverse chronological order """ repository = VariantStudyRepository(cache_service=Mock(spec=ICache), session=db_session) @@ -41,8 +41,8 @@ def test_get_children(self, db_session: Session) -> None: # Ensure the root study has 2 children children = repository.get_children(parent_id=raw_study.id) - assert children == [variant1, variant2] - assert children[0].created_at < children[1].created_at + assert children == [variant2, variant1] + assert children[0].created_at > children[1].created_at # Ensure variants have no children children = repository.get_children(parent_id=variant1.id) @@ -50,15 +50,15 @@ def test_get_children(self, db_session: Session) -> None: children = repository.get_children(parent_id=variant2.id) assert children == [] - # Add a variant study between the two existing ones (in chronological order) + # Add a variant study between the two existing ones (in reverse chronological order) variant3 = VariantStudy(name="My Variant 3", parent_id=raw_study.id, created_at=day2) db_session.add(variant3) db_session.commit() # Ensure the root study has 3 children in chronological order children = repository.get_children(parent_id=raw_study.id) - assert children == [variant1, variant3, variant2] - assert children[0].created_at < children[1].created_at < children[2].created_at + assert children == [variant2, variant3, variant1] + assert children[0].created_at > children[1].created_at > children[2].created_at # Add a variant of a variant variant3a = VariantStudy(name="My Variant 3a", parent_id=variant3.id, created_at=day4) @@ -67,4 +67,4 @@ def test_get_children(self, db_session: Session) -> None: # Ensure the root study has the 3 same children children = repository.get_children(parent_id=raw_study.id) - assert children == [variant1, variant3, variant2] + assert children == [variant2, variant3, variant1] From b771b5275a93bae526ffcf3cc2ce2e15dadc5904 Mon Sep 17 00:00:00 2001 From: MartinBelthle Date: Tue, 11 Jun 2024 09:35:06 +0200 Subject: [PATCH 02/47] fix(table-mode): do not alter existing links that are not updated (#2055) [ANT-1808](https://gopro-tickets.rte-france.com/browse/ANT-1808) --- antarest/study/business/link_management.py | 4 +-- .../study_data_blueprint/test_table_mode.py | 25 ++++++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/antarest/study/business/link_management.py b/antarest/study/business/link_management.py index 375a539fd8..744401772a 100644 --- a/antarest/study/business/link_management.py +++ b/antarest/study/business/link_management.py @@ -130,10 +130,10 @@ def update_links_props( # Convert the DTO to a configuration object and update the configuration file. properties = LinkProperties(**new_link_dto.dict(by_alias=False)) - path = f"{_ALL_LINKS_PATH}/{area1}/properties" + path = f"{_ALL_LINKS_PATH}/{area1}/properties/{area2}" cmd = UpdateConfig( target=path, - data={area2: properties.to_config()}, + data=properties.to_config(), command_context=self.storage_service.variant_study_service.command_factory.command_context, ) commands.append(cmd) diff --git a/tests/integration/study_data_blueprint/test_table_mode.py b/tests/integration/study_data_blueprint/test_table_mode.py index 45ca2cc961..704bdb263b 100644 --- a/tests/integration/study_data_blueprint/test_table_mode.py +++ b/tests/integration/study_data_blueprint/test_table_mode.py @@ -1,3 +1,4 @@ +import copy import typing as t import pytest @@ -44,6 +45,10 @@ def test_lifecycle__nominal( task = wait_task_completion(client, user_access_token, task_id) assert task.status == TaskStatus.COMPLETED, task + # Create another link to test specific bug. + res = client.post(f"/v1/studies/{study_id}/links", json={"area1": "de", "area2": "it"}, headers=user_headers) + assert res.status_code in [200, 201], res.json() + # Table Mode - Area # ================= @@ -226,6 +231,20 @@ def test_lifecycle__nominal( "transmissionCapacities": "ignore", "usePhaseShifter": False, }, + "de / it": { + "assetType": "ac", + "colorRgb": "#707070", + "comments": "", + "displayComments": True, + "filterSynthesis": "hourly, daily, weekly, monthly, annual", + "filterYearByYear": "hourly, daily, weekly, monthly, annual", + "hurdlesCost": False, + "linkStyle": "plain", + "linkWidth": 1, + "loopFlow": False, + "transmissionCapacities": "enabled", + "usePhaseShifter": False, + }, "es / fr": { "assetType": "ac", "colorRgb": "#FF6347", @@ -255,12 +274,16 @@ def test_lifecycle__nominal( "usePhaseShifter": False, }, } + # asserts actual equals expected without the non-updated link. actual = res.json() - assert actual == expected_links + expected_result = copy.deepcopy(expected_links) + del expected_result["de / it"] + assert actual == expected_result res = client.get(f"/v1/studies/{study_id}/table-mode/links", headers=user_headers) assert res.status_code == 200, res.json() actual = res.json() + # asserts the `de / it` link is not removed. assert actual == expected_links # Table Mode - Thermal Clusters From 3290ecae513ccbad868b661d5347c087b9848d69 Mon Sep 17 00:00:00 2001 From: MartinBelthle Date: Tue, 11 Jun 2024 10:09:24 +0200 Subject: [PATCH 03/47] fix(bc): only remove terms when asked (#2060) Rollback to old behavior (prior to https://github.com/AntaresSimulatorTeam/AntaREST/pull/2052) + handle last term deletion --- .../model/command/update_binding_constraint.py | 13 +++++++------ .../test_binding_constraints.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/antarest/study/storage/variantstudy/model/command/update_binding_constraint.py b/antarest/study/storage/variantstudy/model/command/update_binding_constraint.py index 0dcf9804f4..3f84ecd334 100644 --- a/antarest/study/storage/variantstudy/model/command/update_binding_constraint.py +++ b/antarest/study/storage/variantstudy/model/command/update_binding_constraint.py @@ -79,12 +79,13 @@ def _apply(self, study_data: FileStudy) -> CommandOutput: updated_cfg = binding_constraints[index] updated_cfg.update(obj) - updated_terms = set(self.coeffs) if self.coeffs else set() - - # Remove the terms not in the current update but existing in the config - terms_to_remove = {key for key in updated_cfg if ("%" in key or "." in key) and key not in updated_terms} - for term_id in terms_to_remove: - updated_cfg.pop(term_id, None) + excluded_fields = set(ICommand.__fields__) | {"id"} + updated_properties = self.dict(exclude=excluded_fields, exclude_none=True) + # This 2nd check is here to remove the last term. + if self.coeffs or updated_properties == {"coeffs": {}}: + # Remove terms which IDs contain a "%" or a "." in their name + term_ids = {k for k in updated_cfg if "%" in k or "." in k} + binding_constraints[index] = {k: v for k, v in updated_cfg.items() if k not in term_ids} return super().apply_binding_constraint(study_data, binding_constraints, index, self.id, old_groups=old_groups) diff --git a/tests/integration/study_data_blueprint/test_binding_constraints.py b/tests/integration/study_data_blueprint/test_binding_constraints.py index fff973ae20..af537fcc7f 100644 --- a/tests/integration/study_data_blueprint/test_binding_constraints.py +++ b/tests/integration/study_data_blueprint/test_binding_constraints.py @@ -426,6 +426,23 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st ] assert constraint_terms == expected + # Update random field, shouldn't remove the term. + res = client.put( + f"v1/studies/{study_id}/bindingconstraints/{bc_id}", + json={"enabled": False}, + headers=user_headers, + ) + assert res.status_code == 200, res.json() + + res = client.get( + f"/v1/studies/{study_id}/bindingconstraints/{bc_id}", + headers=user_headers, + ) + assert res.status_code == 200, res.json() + binding_constraint = res.json() + constraint_terms = binding_constraint["terms"] + assert constraint_terms == expected + # ============================= # GENERAL EDITION # ============================= From 5555babd2a25996d315fe68b3e66423b89208cae Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE <43534797+laurent-laporte-pro@users.noreply.github.com> Date: Tue, 11 Jun 2024 10:37:00 +0200 Subject: [PATCH 04/47] fix(table-mode): correct the update of the `average_spilled_energy_cost` field in table mode (#2062) --- antarest/study/business/area_management.py | 2 +- .../study_data_blueprint/test_table_mode.py | 51 ++++++++----------- 2 files changed, 21 insertions(+), 32 deletions(-) diff --git a/antarest/study/business/area_management.py b/antarest/study/business/area_management.py index 8f0758d9dc..6c87d8cb80 100644 --- a/antarest/study/business/area_management.py +++ b/antarest/study/business/area_management.py @@ -381,7 +381,7 @@ def update_areas_props( if old_area.average_spilled_energy_cost != new_area.average_spilled_energy_cost: commands.append( UpdateConfig( - target=f"input/thermal/areas/spilledenergycost:{area_id}", + target=f"input/thermal/areas/spilledenergycost/{area_id}", data=new_area.average_spilled_energy_cost, command_context=command_context, ) diff --git a/tests/integration/study_data_blueprint/test_table_mode.py b/tests/integration/study_data_blueprint/test_table_mode.py index 704bdb263b..d61289041f 100644 --- a/tests/integration/study_data_blueprint/test_table_mode.py +++ b/tests/integration/study_data_blueprint/test_table_mode.py @@ -25,7 +25,7 @@ class TestTableMode: def test_lifecycle__nominal( self, client: TestClient, user_access_token: str, study_id: str, study_version: int ) -> None: - user_headers = {"Authorization": f"Bearer {user_access_token}"} + client.headers = {"Authorization": f"Bearer {user_access_token}"} # In order to test the table mode for renewable clusters and short-term storage, # it is required that the study is either in version 8.1 for renewable energies @@ -36,7 +36,6 @@ def test_lifecycle__nominal( if study_version: res = client.put( f"/v1/studies/{study_id}/upgrade", - headers={"Authorization": f"Bearer {user_access_token}"}, params={"target_version": study_version}, ) assert res.status_code == 200, res.json() @@ -53,10 +52,7 @@ def test_lifecycle__nominal( # ================= # Get the schema of the areas table - res = client.get( - "/v1/table-schema/areas", - headers=user_headers, - ) + res = client.get("/v1/table-schema/areas") assert res.status_code == 200, res.json() actual = res.json() assert set(actual["properties"]) == { @@ -88,7 +84,6 @@ def test_lifecycle__nominal( res = client.put( f"/v1/studies/{study_id}/table-mode/areas", - headers=user_headers, json={ "de": _de_values, "es": _es_values, @@ -152,18 +147,31 @@ def test_lifecycle__nominal( actual = res.json() assert actual == expected_areas - res = client.get(f"/v1/studies/{study_id}/table-mode/areas", headers=user_headers) + res = client.get(f"/v1/studies/{study_id}/table-mode/areas") assert res.status_code == 200, res.json() actual = res.json() assert actual == expected_areas + # Specific tests for averageSpilledEnergyCost and averageUnsuppliedEnergyCost + _de_values = { + "averageSpilledEnergyCost": 123, + "averageUnsuppliedEnergyCost": 456, + } + res = client.put( + f"/v1/studies/{study_id}/table-mode/areas", + json={"de": _de_values}, + ) + assert res.status_code == 200, res.json() + actual = res.json()["de"] + assert actual["averageSpilledEnergyCost"] == 123 + assert actual["averageUnsuppliedEnergyCost"] == 456 + # Table Mode - Links # ================== # Get the schema of the links table res = client.get( "/v1/table-schema/links", - headers=user_headers, ) assert res.status_code == 200, res.json() actual = res.json() @@ -184,7 +192,6 @@ def test_lifecycle__nominal( res = client.put( f"/v1/studies/{study_id}/table-mode/links", - headers=user_headers, json={ "de / fr": { "colorRgb": "#FFA500", @@ -280,7 +287,7 @@ def test_lifecycle__nominal( del expected_result["de / it"] assert actual == expected_result - res = client.get(f"/v1/studies/{study_id}/table-mode/links", headers=user_headers) + res = client.get(f"/v1/studies/{study_id}/table-mode/links") assert res.status_code == 200, res.json() actual = res.json() # asserts the `de / it` link is not removed. @@ -292,7 +299,6 @@ def test_lifecycle__nominal( # Get the schema of the thermals table res = client.get( "/v1/table-schema/thermals", - headers=user_headers, ) assert res.status_code == 200, res.json() actual = res.json() @@ -349,7 +355,6 @@ def test_lifecycle__nominal( res = client.put( f"/v1/studies/{study_id}/table-mode/thermals", - headers=user_headers, json={ "de / 01_solar": _solar_values, "de / 02_wind_on": _wind_on_values, @@ -433,7 +438,6 @@ def test_lifecycle__nominal( res = client.get( f"/v1/studies/{study_id}/table-mode/thermals", - headers=user_headers, params={"columns": ",".join(["group", "unitCount", "nominalCapacity", "so2"])}, ) assert res.status_code == 200, res.json() @@ -497,7 +501,6 @@ def test_lifecycle__nominal( } res = client.post( f"/v1/studies/{study_id}/commands", - headers={"Authorization": f"Bearer {user_access_token}"}, json=[{"action": "update_config", "args": args}], ) assert res.status_code == 200, res.json() @@ -557,7 +560,6 @@ def test_lifecycle__nominal( for generator_id, generator in generators.items(): res = client.post( f"/v1/studies/{study_id}/areas/{area_id}/clusters/renewable", - headers=user_headers, json=generator, ) res.raise_for_status() @@ -565,7 +567,6 @@ def test_lifecycle__nominal( # Get the schema of the renewables table res = client.get( "/v1/table-schema/renewables", - headers=user_headers, ) assert res.status_code == 200, res.json() actual = res.json() @@ -584,7 +585,6 @@ def test_lifecycle__nominal( # Update some generators using the table mode res = client.put( f"/v1/studies/{study_id}/table-mode/renewables", - headers=user_headers, json={ "fr / Dieppe": {"enabled": False}, "fr / La Rochelle": {"enabled": True, "nominalCapacity": 3.1, "unitCount": 2}, @@ -595,7 +595,6 @@ def test_lifecycle__nominal( res = client.get( f"/v1/studies/{study_id}/table-mode/renewables", - headers=user_headers, params={"columns": ",".join(["group", "enabled", "unitCount", "nominalCapacity"])}, ) assert res.status_code == 200, res.json() @@ -618,7 +617,6 @@ def test_lifecycle__nominal( # Get the schema of the short-term storages table res = client.get( "/v1/table-schema/st-storages", - headers=user_headers, ) assert res.status_code == 200, res.json() actual = res.json() @@ -682,7 +680,6 @@ def test_lifecycle__nominal( for storage_id, storage in storages.items(): res = client.post( f"/v1/studies/{study_id}/areas/{area_id}/storages", - headers=user_headers, json=storage, ) res.raise_for_status() @@ -696,7 +693,6 @@ def test_lifecycle__nominal( res = client.put( f"/v1/studies/{study_id}/table-mode/st-storages", - headers=user_headers, json={ "fr / siemens": _fr_siemes_values, "fr / tesla": _fr_tesla_values, @@ -765,7 +761,6 @@ def test_lifecycle__nominal( res = client.get( f"/v1/studies/{study_id}/table-mode/st-storages", - headers=user_headers, params={ "columns": ",".join( [ @@ -816,7 +811,6 @@ def test_lifecycle__nominal( fr_id = "fr" res = client.post( f"/v1/studies/{study_id}/areas/{fr_id}/clusters/thermal", - headers=user_headers, json={ "name": "Cluster 1", "group": "Nuclear", @@ -835,7 +829,6 @@ def test_lifecycle__nominal( "time_step": "hourly", "operator": "less", }, - headers=user_headers, ) assert res.status_code == 200, res.json() @@ -849,14 +842,12 @@ def test_lifecycle__nominal( "comments": "This is a binding constraint", "filter_synthesis": "hourly, daily, weekly", }, - headers=user_headers, ) assert res.status_code == 200, res.json() # Get the schema of the binding constraints table res = client.get( "/v1/table-schema/binding-constraints", - headers=user_headers, ) assert res.status_code == 200, res.json() actual = res.json() @@ -884,7 +875,6 @@ def test_lifecycle__nominal( res = client.put( f"/v1/studies/{study_id}/table-mode/binding-constraints", - headers=user_headers, json={ "binding constraint 1": _bc1_values, "binding constraint 2": _bc2_values, @@ -920,7 +910,6 @@ def test_lifecycle__nominal( res = client.get( f"/v1/studies/{study_id}/table-mode/binding-constraints", - headers=user_headers, params={"columns": ""}, ) assert res.status_code == 200, res.json() @@ -933,8 +922,8 @@ def test_table_type_aliases(client: TestClient, user_access_token: str) -> None: """ Ensure that we can use the old table type aliases to get the schema of the tables. """ - user_headers = {"Authorization": f"Bearer {user_access_token}"} + client.headers = {"Authorization": f"Bearer {user_access_token}"} # do not use `pytest.mark.parametrize`, because it is too slow for table_type in ["area", "link", "cluster", "renewable", "binding constraint"]: - res = client.get(f"/v1/table-schema/{table_type}", headers=user_headers) + res = client.get(f"/v1/table-schema/{table_type}") assert res.status_code == 200, f"Failed to get schema for {table_type}: {res.json()}" From e4b76f6869eb0140defc7fef8e3426e3f3602307 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE <43534797+laurent-laporte-pro@users.noreply.github.com> Date: Tue, 11 Jun 2024 10:37:54 +0200 Subject: [PATCH 05/47] fix(ui): hide "upgrade" menu item for variant studies or studies with children (#2063) [ANT-1775](https://gopro-tickets.rte-france.com/browse/ANT-1775) --- webapp/src/components/App/Singlestudy/NavHeader/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/webapp/src/components/App/Singlestudy/NavHeader/index.tsx b/webapp/src/components/App/Singlestudy/NavHeader/index.tsx index 4a2f134418..8518e8112e 100644 --- a/webapp/src/components/App/Singlestudy/NavHeader/index.tsx +++ b/webapp/src/components/App/Singlestudy/NavHeader/index.tsx @@ -64,6 +64,8 @@ function NavHeader({ const isLatestVersion = study?.version === latestVersion; const isManaged = !!study?.managed; const isArchived = !!study?.archived; + const isVariant = study?.type === "variantstudy"; + const hasChildren = childrenTree && childrenTree.children.length > 0; //////////////////////////////////////////////////////////////// // Event Handlers @@ -154,7 +156,7 @@ function NavHeader({ key: "study.upgrade", icon: UpgradeIcon, action: () => setOpenUpgradeDialog(true), - condition: !isArchived && !isLatestVersion, + condition: !isArchived && !isLatestVersion && !isVariant && !hasChildren, }, { key: "global.export", From 64d31d881ff32cacd03af555836298c2f16cab8e Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE <43534797+laurent-laporte-pro@users.noreply.github.com> Date: Mon, 27 May 2024 10:01:09 +0200 Subject: [PATCH 06/47] feat(desktop): update Antares Web Desktop version (#2036) Changes: - update the sample study to version v8.8 - update Antares Solver to version v8.8.5 - remove `AntaresWebWorker` from distribution --- .github/workflows/deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 50e9abf4fe..76e52d1b87 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,6 +5,7 @@ on: branches: - "master" - "hotfix/**" + - 'feature/remove-AntaresWebWorker' jobs: binary: From f747173aa5ec205f7fc983d140dff6843efe4b8f Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 31 May 2024 18:03:49 +0200 Subject: [PATCH 07/47] build: update change log with #2042 --- .github/workflows/deploy.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 76e52d1b87..50e9abf4fe 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,7 +5,6 @@ on: branches: - "master" - "hotfix/**" - - 'feature/remove-AntaresWebWorker' jobs: binary: From 89a6db4e3205c97d53bddf127e2372f931943376 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Thu, 25 Apr 2024 19:29:39 +0200 Subject: [PATCH 08/47] fix(bc): handle BC `group` in `BindingConstraintDTO` --- .../rawstudy/model/filesystem/config/files.py | 16 +++++++--------- .../rawstudy/model/filesystem/config/model.py | 10 ++++++++++ .../business/utils_binding_constraint.py | 16 +++++++--------- .../model/command/create_binding_constraint.py | 4 +--- tests/storage/test_model.py | 2 +- 5 files changed, 26 insertions(+), 22 deletions(-) diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/files.py b/antarest/study/storage/rawstudy/model/filesystem/config/files.py index bfa490dac0..70846f4d5f 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/config/files.py +++ b/antarest/study/storage/rawstudy/model/filesystem/config/files.py @@ -226,16 +226,14 @@ def _parse_bindings(root: Path) -> t.List[BindingConstraintDTO]: area_set.add(key.split(".", 1)[0]) group = bind.get("group", DEFAULT_GROUP) - - output_list.append( - BindingConstraintDTO( - id=bind["id"], - areas=area_set, - clusters=cluster_set, - time_step=time_step, - group=group, - ) + bc = BindingConstraintDTO( + id=bind["id"], + areas=area_set, + clusters=cluster_set, + time_step=time_step, + group=group, ) + output_list.append(bc) return output_list diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/model.py b/antarest/study/storage/rawstudy/model/filesystem/config/model.py index cf9e67167c..612c50e786 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/config/model.py +++ b/antarest/study/storage/rawstudy/model/filesystem/config/model.py @@ -116,6 +116,16 @@ def get_file(self) -> str: class BindingConstraintDTO(BaseModel): + """ + Object linked to `input/bindingconstraints/bindingconstraints.ini` information + + Attributes: + id: The ID of the binding constraint. + group: The group for the scenario of BC (optional, required since v8.7). + areas: List of area IDs on which the BC applies (links or clusters). + clusters: List of thermal cluster IDs on which the BC applies (format: "area.cluster"). + """ + id: str areas: t.Set[str] clusters: t.Set[str] diff --git a/antarest/study/storage/variantstudy/business/utils_binding_constraint.py b/antarest/study/storage/variantstudy/business/utils_binding_constraint.py index 039a3b2252..f236cfbca3 100644 --- a/antarest/study/storage/variantstudy/business/utils_binding_constraint.py +++ b/antarest/study/storage/variantstudy/business/utils_binding_constraint.py @@ -23,16 +23,14 @@ def parse_bindings_coeffs_and_save_into_config( elif "." in k: clusters_set.add(k) areas_set.add(k.split(".")[0]) - - study_data_config.bindings.append( - BindingConstraintDTO( - id=bd_id, - areas=areas_set, - clusters=clusters_set, - time_step=time_step, - group=group, - ) + bc = BindingConstraintDTO( + id=bd_id, + group=group, + areas=areas_set, + clusters=clusters_set, + time_step=time_step, ) + study_data_config.bindings.append(bc) def remove_area_cluster_from_binding_constraints( diff --git a/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py b/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py index 3e047ac02d..ee9162241d 100644 --- a/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py +++ b/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py @@ -1,5 +1,4 @@ import json -import typing import typing as t from abc import ABCMeta @@ -339,7 +338,6 @@ def apply_binding_constraint( self.coeffs or {}, group=group, ) - study_data.tree.save( binding_constraints, ["input", "bindingconstraints", "bindingconstraints"], @@ -462,7 +460,7 @@ def match(self, other: "ICommand", equal: bool = False) -> bool: return super().match(other, equal) -def remove_bc_from_scenario_builder(study_data: FileStudy, removed_groups: typing.Set[str]) -> None: +def remove_bc_from_scenario_builder(study_data: FileStudy, removed_groups: t.Set[str]) -> None: """ Update the scenario builder by removing the rows that correspond to the BC groups to remove. diff --git a/tests/storage/test_model.py b/tests/storage/test_model.py index 4986073713..d17d6c89a4 100644 --- a/tests/storage/test_model.py +++ b/tests/storage/test_model.py @@ -55,5 +55,5 @@ def test_file_study_tree_config_dto(): enr_modelling="aggregated", ) config_dto = FileStudyTreeConfigDTO.from_build_config(config) - assert sorted(list(config_dto.dict().keys()) + ["cache"]) == sorted(list(config.__dict__.keys())) + assert sorted(list(config_dto.dict()) + ["cache"]) == sorted(list(config.__dict__)) assert config_dto.to_build_config() == config From 2bf1009719bfb38f6dc74696ec9ed3fab6b80c69 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Wed, 5 Jun 2024 18:06:23 +0200 Subject: [PATCH 09/47] feat(ini-reader): add section and option filtering capabilities to improve performance --- antarest/study/storage/rawstudy/ini_reader.py | 181 +++++++++++++++--- .../model/filesystem/ini_file_node.py | 65 +++++-- .../model/filesystem/json_file_node.py | 2 +- .../antares_io/reader/test_ini_reader.py | 80 ++++++++ 4 files changed, 280 insertions(+), 48 deletions(-) diff --git a/antarest/study/storage/rawstudy/ini_reader.py b/antarest/study/storage/rawstudy/ini_reader.py index 84be7c099b..b35df2f0e0 100644 --- a/antarest/study/storage/rawstudy/ini_reader.py +++ b/antarest/study/storage/rawstudy/ini_reader.py @@ -1,8 +1,10 @@ +import dataclasses +import re import typing as t from abc import ABC, abstractmethod from pathlib import Path -from antarest.core.model import JSON, SUB_JSON +from antarest.core.model import JSON def convert_value(value: str) -> t.Union[str, int, float, bool]: @@ -22,34 +24,89 @@ def convert_value(value: str) -> t.Union[str, int, float, bool]: return value -def convert_obj(item: t.Any) -> SUB_JSON: - """Convert object to the appropriate type for JSON (scalar, dictionary or list).""" +@dataclasses.dataclass +class IniFilter: + """ + Filter sections and options in an INI file based on regular expressions. + + Attributes: + section_regex: A compiled regex for matching section names. + option_regex: A compiled regex for matching option names. + """ + + section_regex: t.Optional[t.Pattern[str]] = None + option_regex: t.Optional[t.Pattern[str]] = None + + @classmethod + def from_kwargs( + cls, + section: str = "", + option: str = "", + section_regex: t.Optional[t.Union[str, t.Pattern[str]]] = None, + option_regex: t.Optional[t.Union[str, t.Pattern[str]]] = None, + **_unused: t.Any, # ignore unknown options + ) -> "IniFilter": + """ + Create an instance from given filtering parameters. + + When using `section` or `option` parameters, an exact match is done. + Alternatively, one can use `section_regex` or `option_regex` to perform a full match using a regex. + + Args: + section: The section name to match (by default, all sections are matched) + option: The option name to match (by default, all options are matched) + section_regex: The regex for matching section names. + option_regex: The regex for matching option names. + _unused: Placeholder for any unknown options. + + Returns: + The newly created instance + """ + if section: + section_regex = re.compile(re.escape(section)) + if option: + option_regex = re.compile(re.escape(option)) + if isinstance(section_regex, str): + section_regex = re.compile(section_regex) if section_regex else None + if isinstance(option_regex, str): + option_regex = re.compile(option_regex) if option_regex else None + return cls(section_regex=section_regex, option_regex=option_regex) + + def select_section_option(self, section: str, option: str = "") -> bool: + """ + Check if a given section and option match the regular expressions. + + Args: + section: The section name to match. + option: The option name to match (optional). - if isinstance(item, dict): - return {key: convert_obj(value) for key, value in item.items()} - elif isinstance(item, list): - return [convert_obj(value) for value in item] - else: - return convert_value(item) + Returns: + Whether the section and option match their respective regular expressions. + """ + if self.section_regex and not self.section_regex.fullmatch(section): + return False + if self.option_regex and option and not self.option_regex.fullmatch(option): + return False + return True class IReader(ABC): """ - Init file Reader interface + File reader interface. """ @abstractmethod - def read(self, path: t.Any) -> JSON: + def read(self, path: t.Any, **kwargs: t.Any) -> JSON: """ Parse `.ini` file to json object. Args: path: Path to `.ini` file or file-like object. + kwargs: Additional options used for reading. Returns: Dictionary of parsed `.ini` file which can be converted to JSON. """ - raise NotImplementedError() class IniReader(IReader): @@ -90,6 +147,15 @@ def __init__(self, special_keys: t.Sequence[str] = (), section_name: str = "sett # List of keys which should be parsed as list. self._section_name = section_name + # Dictionary of parsed sections and options + self._curr_sections: t.Dict[str, t.Dict[str, t.Any]] = {} + + # Current section name used during paring + self._curr_section = "" + + # Current option name used during paring + self._curr_option = "" + def __repr__(self) -> str: # pragma: no cover """Return a string representation of the object.""" cls = self.__class__.__name__ @@ -98,15 +164,15 @@ def __repr__(self) -> str: # pragma: no cover section_name = getattr(self, "_section_name", "settings") return f"{cls}(special_keys={special_keys!r}, section_name={section_name!r})" - def read(self, path: t.Any) -> JSON: + def read(self, path: t.Any, **kwargs: t.Any) -> JSON: if isinstance(path, (Path, str)): try: with open(path, mode="r", encoding="utf-8") as f: - sections = self._parse_ini_file(f) + sections = self._parse_ini_file(f, **kwargs) except UnicodeDecodeError: # On windows, `.ini` files may use "cp1252" encoding with open(path, mode="r", encoding="cp1252") as f: - sections = self._parse_ini_file(f) + sections = self._parse_ini_file(f, **kwargs) except FileNotFoundError: # If the file is missing, an empty dictionary is returned. # This is required to mimic the behavior of `configparser.ConfigParser`. @@ -114,14 +180,14 @@ def read(self, path: t.Any) -> JSON: elif hasattr(path, "read"): with path: - sections = self._parse_ini_file(path) + sections = self._parse_ini_file(path, **kwargs) else: # pragma: no cover raise TypeError(repr(type(path))) - return t.cast(JSON, convert_obj(sections)) + return t.cast(JSON, sections) - def _parse_ini_file(self, ini_file: t.TextIO) -> JSON: + def _parse_ini_file(self, ini_file: t.TextIO, **kwargs: t.Any) -> JSON: """ Parse `.ini` file to JSON object. @@ -151,31 +217,91 @@ def _parse_ini_file(self, ini_file: t.TextIO) -> JSON: Args: ini_file: file or file-like object. + Keywords: + - section: The section name to match (by default, all sections are matched) + - option: The option name to match (by default, all options are matched) + - section_regex: The regex for matching section names. + - option_regex: The regex for matching option names. + Returns: Dictionary of parsed `.ini` file which can be converted to JSON. """ + ini_filter = IniFilter.from_kwargs(**kwargs) + # NOTE: This algorithm is 1.93x faster than configparser.ConfigParser - sections: t.Dict[str, t.Dict[str, t.Any]] = {} section_name = self._section_name + # reset the current values + self._curr_sections.clear() + self._curr_section = "" + self._curr_option = "" + for line in ini_file: line = line.strip() if not line or line.startswith(";") or line.startswith("#"): continue elif line.startswith("["): section_name = line[1:-1] - sections.setdefault(section_name, {}) + stop = self._handle_section(ini_filter, section_name) elif "=" in line: key, value = map(str.strip, line.split("=", 1)) - section = sections.setdefault(section_name, {}) - if key in self._special_keys: - section.setdefault(key, []).append(value) - else: - section[key] = value + stop = self._handle_option(ini_filter, section_name, key, value) else: raise ValueError(f"☠☠☠ Invalid line: {line!r}") - return sections + # Stop parsing if the filter don't match + if stop: + break + + return self._curr_sections + + def _handle_section(self, ini_filter: IniFilter, section: str) -> bool: + # state: a new section is found + match = ini_filter.select_section_option(section) + + if self._curr_section: + # state: option parsing is finished + if match: + self._append_section(section) + return False + # prematurely stop parsing if the filter don't match + return True + + if match: + self._append_section(section) + + # continue parsing to the next section + return False + + def _append_section(self, section: str) -> None: + self._curr_sections.setdefault(section, {}) + self._curr_section = section + self._curr_option = "" + + def _handle_option(self, ini_filter: IniFilter, section: str, key: str, value: str) -> bool: + # state: a new option is found (which may be a duplicate) + match = ini_filter.select_section_option(section, key) + + if self._curr_option: + if match: + self._append_option(section, key, value) + return False + # prematurely stop parsing if the filter don't match + return not ini_filter.select_section_option(section) + + if match: + self._append_option(section, key, value) + # continue parsing to the next option + return False + + def _append_option(self, section: str, key: str, value: str) -> None: + self._curr_sections.setdefault(section, {}) + values = self._curr_sections[section] + if key in self._special_keys: + values.setdefault(key, []).append(convert_value(value)) + else: + values[key] = convert_value(value) + self._curr_option = key class SimpleKeyValueReader(IniReader): @@ -183,7 +309,7 @@ class SimpleKeyValueReader(IniReader): Simple INI reader for "settings.ini" file which has no section. """ - def read(self, path: t.Any) -> JSON: + def read(self, path: t.Any, **kwargs: t.Any) -> JSON: """ Parse `.ini` file which has no section to JSON object. @@ -191,6 +317,7 @@ def read(self, path: t.Any) -> JSON: Args: path: Path to `.ini` file or file-like object. + kwargs: Additional options used for reading. Returns: Dictionary of parsed key/value pairs. diff --git a/antarest/study/storage/rawstudy/model/filesystem/ini_file_node.py b/antarest/study/storage/rawstudy/model/filesystem/ini_file_node.py index d44c89f4f0..ba75363abd 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/ini_file_node.py +++ b/antarest/study/storage/rawstudy/model/filesystem/ini_file_node.py @@ -5,10 +5,10 @@ import logging import os import tempfile +import typing as t import zipfile from json import JSONDecodeError from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Union, cast from filelock import FileLock @@ -36,7 +36,7 @@ def __init__(self, config: FileStudyTreeConfig, message: str) -> None: super().__init__(f"INI File error '{relpath}': {message}") -def log_warning(f: Callable[..., Any]) -> Callable[..., Any]: +def log_warning(f: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: """ Decorator to suppress `UserWarning` exceptions by logging them as warnings. @@ -48,7 +48,7 @@ def log_warning(f: Callable[..., Any]) -> Callable[..., Any]: """ @functools.wraps(f) - def wrapper(*args: Any, **kwargs: Any) -> Any: + def wrapper(*args: t.Any, **kwargs: t.Any) -> t.Any: try: return f(*args, **kwargs) except UserWarning as w: @@ -63,9 +63,9 @@ def __init__( self, context: ContextServer, config: FileStudyTreeConfig, - types: Optional[Dict[str, Any]] = None, - reader: Optional[IReader] = None, - writer: Optional[IniWriter] = None, + types: t.Optional[t.Dict[str, t.Any]] = None, + reader: t.Optional[IReader] = None, + writer: t.Optional[IniWriter] = None, ): super().__init__(config) self.context = context @@ -76,11 +76,11 @@ def __init__( def _get( self, - url: Optional[List[str]] = None, + url: t.Optional[t.List[str]] = None, depth: int = -1, expanded: bool = False, get_node: bool = False, - ) -> Union[SUB_JSON, INode[SUB_JSON, SUB_JSON, JSON]]: + ) -> t.Union[SUB_JSON, INode[SUB_JSON, SUB_JSON, JSON]]: if get_node: return self @@ -89,15 +89,17 @@ def _get( if depth == 0: return {} + url = url or [] + kwargs = self._get_filtering_kwargs(url) if self.config.zip_path: with zipfile.ZipFile(self.config.zip_path, mode="r") as zipped_folder: inside_zip_path = self.config.path.relative_to(self.config.zip_path.with_suffix("")).as_posix() with io.TextIOWrapper(zipped_folder.open(inside_zip_path)) as f: - data = self.reader.read(f) + data = self.reader.read(f, **kwargs) else: - data = self.reader.read(self.path) + data = self.reader.read(self.path, **kwargs) if len(url) == 2: data = data[url[0]][url[1]] @@ -105,11 +107,34 @@ def _get( data = data[url[0]] else: data = {k: {} for k in data} if depth == 1 else data - return cast(SUB_JSON, data) + + return t.cast(SUB_JSON, data) + + # noinspection PyMethodMayBeStatic + def _get_filtering_kwargs(self, url: t.List[str]) -> t.Dict[str, str]: + """ + Extracts the filtering arguments from the URL components. + + Note: this method can be overridden in subclasses to provide additional filtering arguments. + + Args: + url: URL components [section_name, key_name]. + + Returns: + Keyword arguments used by the INI reader to filter the data. + """ + if len(url) > 2: + raise ValueError(f"Invalid URL: {url!r}") + elif len(url) == 2: + return {"section": url[0], "option": url[1]} + elif len(url) == 1: + return {"section": url[0]} + else: + return {} def get( self, - url: Optional[List[str]] = None, + url: t.Optional[t.List[str]] = None, depth: int = -1, expanded: bool = False, formatted: bool = True, @@ -120,13 +145,13 @@ def get( def get_node( self, - url: Optional[List[str]] = None, + url: t.Optional[t.List[str]] = None, ) -> INode[SUB_JSON, SUB_JSON, JSON]: output = self._get(url, get_node=True) assert isinstance(output, INode) return output - def save(self, data: SUB_JSON, url: Optional[List[str]] = None) -> None: + def save(self, data: SUB_JSON, url: t.Optional[t.List[str]] = None) -> None: self._assert_not_in_zipped_file() url = url or [] with FileLock( @@ -147,11 +172,11 @@ def save(self, data: SUB_JSON, url: Optional[List[str]] = None) -> None: elif len(url) == 1: info[url[0]] = obj else: - info = cast(JSON, obj) + info = t.cast(JSON, obj) self.writer.write(info, self.path) @log_warning - def delete(self, url: Optional[List[str]] = None) -> None: + def delete(self, url: t.Optional[t.List[str]] = None) -> None: """ Deletes the specified section or key from the INI file, or the entire INI file if no URL is provided. @@ -216,9 +241,9 @@ def delete(self, url: Optional[List[str]] = None) -> None: def check_errors( self, data: JSON, - url: Optional[List[str]] = None, + url: t.Optional[t.List[str]] = None, raising: bool = False, - ) -> List[str]: + ) -> t.List[str]: errors = [] for section, params in self.types.items(): if section not in data: @@ -240,9 +265,9 @@ def denormalize(self) -> None: def _validate_param( self, section: str, - params: Any, + params: t.Any, data: JSON, - errors: List[str], + errors: t.List[str], raising: bool, ) -> None: for param, typing in params.items(): diff --git a/antarest/study/storage/rawstudy/model/filesystem/json_file_node.py b/antarest/study/storage/rawstudy/model/filesystem/json_file_node.py index 80f607485d..e2ddfbab98 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/json_file_node.py +++ b/antarest/study/storage/rawstudy/model/filesystem/json_file_node.py @@ -15,7 +15,7 @@ class JsonReader(IReader): JSON file reader. """ - def read(self, path: t.Any) -> JSON: + def read(self, path: t.Any, **kwargs: t.Any) -> JSON: content: t.Union[str, bytes] if isinstance(path, (Path, str)): diff --git a/tests/storage/repository/antares_io/reader/test_ini_reader.py b/tests/storage/repository/antares_io/reader/test_ini_reader.py index fb87061ff7..4ff38a32c6 100644 --- a/tests/storage/repository/antares_io/reader/test_ini_reader.py +++ b/tests/storage/repository/antares_io/reader/test_ini_reader.py @@ -232,6 +232,86 @@ def test_read__sets(self) -> None: } assert actual == expected + def test_read__filtered_section(self, tmp_path) -> None: + path = Path(tmp_path) / "test.ini" + path.write_text( + textwrap.dedent( + """ + [part1] + foo = 5 + bar = hello + + [part2] + foo = 6 + bar = salut + + [other] + pi = 3.14 + """ + ) + ) + + reader = IniReader() + + # exact match + actual = reader.read(path, section="part1") + expected = {"part1": {"foo": 5, "bar": "hello"}} + assert actual == expected + + # regex match + actual = reader.read(path, section_regex="part.*") + expected = { + "part1": {"foo": 5, "bar": "hello"}, + "part2": {"foo": 6, "bar": "salut"}, + } + assert actual == expected + + def test_read__filtered_option(self, tmp_path) -> None: + path = Path(tmp_path) / "test.ini" + path.write_text( + textwrap.dedent( + """ + [part1] + foo = 5 + bar = hello + + [part2] + foo = 6 + bar = salut + + [other] + pi = 3.14 + """ + ) + ) + + reader = IniReader() + + # exact match + actual = reader.read(path, option="foo") + expected = {"part1": {"foo": 5}, "part2": {"foo": 6}, "other": {}} + assert actual == expected + + # regex match + actual = reader.read(path, option_regex="fo.*") + expected = {"part1": {"foo": 5}, "part2": {"foo": 6}, "other": {}} + assert actual == expected + + # exact match with section + actual = reader.read(path, section="part2", option="foo") + expected = {"part2": {"foo": 6}} + assert actual == expected + + # regex match with section + actual = reader.read(path, section_regex="part.*", option="foo") + expected = {"part1": {"foo": 5}, "part2": {"foo": 6}} + assert actual == expected + + # regex match with section and option + actual = reader.read(path, section_regex="part.*", option_regex=".*a.*") + expected = {"part1": {"bar": "hello"}, "part2": {"bar": "salut"}} + assert actual == expected + class TestSimpleKeyValueReader: def test_read(self) -> None: From 07dfcaa3c30ae21e54502db0985aeeb3700afbb1 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Sat, 8 Jun 2024 07:52:43 +0200 Subject: [PATCH 10/47] feat(sb): add `RulesetMatrices` to read, write and convert scenario builder rules --- .../filesystem/config/ruleset_matrices.py | 382 +++++++++++++ .../config/test_ruleset_matrices.py | 533 ++++++++++++++++++ 2 files changed, 915 insertions(+) create mode 100644 antarest/study/storage/rawstudy/model/filesystem/config/ruleset_matrices.py create mode 100644 tests/storage/repository/filesystem/config/test_ruleset_matrices.py diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/ruleset_matrices.py b/antarest/study/storage/rawstudy/model/filesystem/config/ruleset_matrices.py new file mode 100644 index 0000000000..c8663075ea --- /dev/null +++ b/antarest/study/storage/rawstudy/model/filesystem/config/ruleset_matrices.py @@ -0,0 +1,382 @@ +import typing as t + +import pandas as pd +import typing_extensions as te + +SCENARIO_TYPES = { + "l": "load", + "h": "hydro", + "w": "wind", + "s": "solar", + "t": "thermal", + "r": "renewable", + "ntc": "link", + "bc": "binding-constraints", + "hl": "hydro-initial-levels", + "hfl": "hydro-final-levels", + "hgp": "hydro-generation-power", +} + +_Value: te.TypeAlias = t.Union[int, float] +_SimpleScenario: te.TypeAlias = pd.DataFrame +_ClusterScenario: te.TypeAlias = t.MutableMapping[str, pd.DataFrame] +_Scenario: te.TypeAlias = t.Union[_SimpleScenario, _ClusterScenario] +_ScenarioMapping: te.TypeAlias = t.MutableMapping[str, _Scenario] + +SimpleTableForm: te.TypeAlias = t.Dict[str, t.Dict[str, t.Union[int, float, str]]] +ClusterTableForm: te.TypeAlias = t.Dict[str, SimpleTableForm] +TableForm: te.TypeAlias = t.Union[SimpleTableForm, ClusterTableForm] + +_AREA_RELATED_SYMBOLS = "l", "h", "w", "s", "hgp" +_BINDING_CONSTRAINTS_RELATED_SYMBOLS = ("bc",) +_LINK_RELATED_SYMBOLS = ("ntc",) +_HYDRO_LEVEL_RELATED_SYMBOLS = "hl", "hfl" +_CLUSTER_RELATED_SYMBOLS = "t", "r" + + +# ======================================== +# Formating functions for matrix indexes +# ======================================== + + +def idx_area(area: str, /) -> str: + return area + + +def idx_link(area1: str, area2: str, /) -> str: + return f"{area1} / {area2}" + + +def idx_cluster(_: str, cluster: str, /) -> str: + return cluster + + +def idx_group(group: str, /) -> str: + return group + + +# ========================== +# Scenario Builder Ruleset +# ========================== + + +class RulesetMatrices: + """ + Scenario Builder Ruleset -- Manage rules of each scenario type as DataFrames. + + This class allows to manage the conversion of data from or to rules in INI format. + It can also convert the data to a table form (a dictionary of dictionaries) for the frontend. + """ + + def __init__( + self, + *, + nb_years: int, + areas: t.Iterable[str], + links: t.Iterable[t.Tuple[str, str]], + thermals: t.Mapping[str, t.Iterable[str]], + renewables: t.Mapping[str, t.Iterable[str]], + groups: t.Iterable[str], + scenario_types: t.Optional[t.Mapping[str, str]] = None, + ): + # List of Monte Carlo years + self.columns = [str(i) for i in range(nb_years)] + # Dictionaries used to manage case insensitivity + self.areas = {a.lower(): a for a in areas} + self.links = {(a1.lower(), a2.lower()): (a1, a2) for a1, a2 in links} + self.thermals = {a.lower(): {cl.lower(): cl for cl in clusters} for a, clusters in thermals.items()} + self.renewables = {a.lower(): {cl.lower(): cl for cl in clusters} for a, clusters in renewables.items()} + self.clusters_by_symbols = {"t": self.thermals, "r": self.renewables} # for easier access + self.groups = {g.lower(): g for g in groups} + # Dictionary used to convert symbols to scenario types + self.scenario_types = scenario_types or SCENARIO_TYPES + # Dictionary used to store the scenario matrices + self.scenarios: _ScenarioMapping = {} + self._setup() + + def __str__(self) -> str: + lines = [] + for symbol, scenario_type in self.scenario_types.items(): + lines.append(f"# {scenario_type}") + scenario = self.scenarios[scenario_type] + if isinstance(scenario, pd.DataFrame): + lines.append(scenario.to_string()) + lines.append("") + else: + for area, scenario in scenario.items(): + lines.append(f"## {scenario_type} in {area}") + lines.append(scenario.to_string()) + lines.append("") + return "\n".join(lines) + + def get_area_index(self) -> t.List[str]: + return [idx_area(area) for area in self.areas.values()] + + def get_link_index(self) -> t.List[str]: + return [idx_link(a1, a2) for a1, a2 in self.links.values()] + + def get_cluster_index(self, symbol: str, area: str) -> t.List[str]: + clusters = self.clusters_by_symbols[symbol][area.lower()] + return [idx_cluster(area, cluster) for cluster in clusters.values()] + + def get_group_index(self) -> t.List[str]: + return [idx_group(group) for group in self.groups.values()] + + def _setup(self) -> None: + """ + Prepare the scenario matrices for each scenario type. + """ + area_index = self.get_area_index() + group_index = self.get_group_index() + link_index = self.get_link_index() + for symbol, scenario_type in self.scenario_types.items(): + if symbol in _AREA_RELATED_SYMBOLS: + self.scenarios[scenario_type] = pd.DataFrame(index=area_index, columns=self.columns, dtype=int) + elif symbol in _BINDING_CONSTRAINTS_RELATED_SYMBOLS: + self.scenarios[scenario_type] = pd.DataFrame(index=group_index, columns=self.columns, dtype=int) + elif symbol in _LINK_RELATED_SYMBOLS: + self.scenarios[scenario_type] = pd.DataFrame(index=link_index, columns=self.columns, dtype=int) + elif symbol in _HYDRO_LEVEL_RELATED_SYMBOLS: + self.scenarios[scenario_type] = pd.DataFrame(index=area_index, columns=self.columns, dtype=float) + elif symbol in _CLUSTER_RELATED_SYMBOLS: + # We only take the areas that are defined in the thermals and renewables dictionaries + # Keys are the names of the areas (and not the identifiers) + self.scenarios[scenario_type] = { + self.areas[area_id]: pd.DataFrame( + index=self.get_cluster_index(symbol, self.areas[area_id]), columns=self.columns, dtype=int + ) + for area_id, cluster in self.clusters_by_symbols[symbol].items() + if cluster + } + else: + raise NotImplementedError(f"Unknown symbol {symbol}") + + def sort_scenarios(self) -> None: + """ + Sort the indexes of the scenario matrices (case-insensitive). + """ + for symbol, scenario_type in self.scenario_types.items(): + scenario = self.scenarios[scenario_type] + if isinstance(scenario, pd.DataFrame): + scenario = scenario.sort_index(key=lambda x: x.str.lower()) + else: + scenario = {area: df.sort_index(key=lambda x: x.str.lower()) for area, df in scenario.items()} + self.scenarios[scenario_type] = scenario + + def update_rules(self, rules: t.Mapping[str, _Value]) -> None: + """ + Update the scenario matrices with the given rules read from an INI file. + + Args: + rules: Dictionary of rules with the following format + + :: + + { + "symbol,area_id,year": value, # load, hydro, wind, solar... + "symbol,area1_id,area2_id,year": value, # links + "symbol,area_id,cluster_id,year": value, # thermal and renewable clusters + "symbol,group_id,year": value, # binding constraints + } + """ + for key, value in rules.items(): + symbol, *parts = key.split(",") + scenario_type = self.scenario_types[symbol] + # Common values + area_id = parts[0].lower() # or group_id for BC + year = parts[2] if symbol in _LINK_RELATED_SYMBOLS else parts[1] + if symbol in _AREA_RELATED_SYMBOLS: + area = self.areas[area_id] + scenario = t.cast(pd.DataFrame, self.scenarios[scenario_type]) + scenario.at[idx_area(area), str(year)] = value + elif symbol in _LINK_RELATED_SYMBOLS: + area1 = self.areas[area_id] + area2 = self.areas[parts[1].lower()] + scenario = t.cast(pd.DataFrame, self.scenarios[scenario_type]) + scenario.at[idx_link(area1, area2), str(year)] = value + elif symbol in _HYDRO_LEVEL_RELATED_SYMBOLS: + area = self.areas[area_id] + scenario = t.cast(pd.DataFrame, self.scenarios[scenario_type]) + scenario.at[idx_area(area), str(year)] = value * 100 + elif symbol in _CLUSTER_RELATED_SYMBOLS: + area = self.areas[area_id] + clusters = self.clusters_by_symbols[symbol][area_id] + cluster = clusters[parts[2].lower()] + scenario = t.cast(pd.DataFrame, self.scenarios[scenario_type][area]) + scenario.at[idx_cluster(area, cluster), str(year)] = value + elif symbol in _BINDING_CONSTRAINTS_RELATED_SYMBOLS: + group = self.groups[area_id] + scenario = t.cast(pd.DataFrame, self.scenarios[scenario_type]) + scenario.at[idx_group(group), str(year)] = value + else: + raise NotImplementedError(f"Unknown symbol {symbol}") + + def get_rules(self) -> t.Dict[str, _Value]: + """ + Get the rules from the scenario matrices in INI format. + + Returns: + Dictionary of rules with the following format + + :: + + { + "symbol,area_id,year": value, # load, hydro, wind, solar... + "symbol,area1_id,area2_id,year": value, # links + "symbol,area_id,cluster_id,year": value, # thermal and renewable clusters + "symbol,group_id,year": value, # binding constraints + } + """ + rules: t.Dict[str, _Value] = {} + for symbol, scenario_type in self.scenario_types.items(): + scenario = self.scenarios[scenario_type] + scenario_rules = self.get_scenario_rules(scenario, symbol) + rules.update(scenario_rules) + return rules + + def get_scenario_rules(self, scenario: _Scenario, symbol: str) -> t.Dict[str, _Value]: + """ + Get the rules for a specific scenario matrix and symbol. + + Args: + scenario: Matrix or dictionary of matrices. + symbol: Rule symbol. + + Returns: + Dictionary of rules. + """ + if symbol in _AREA_RELATED_SYMBOLS: + scenario_rules = { + f"{symbol},{area_id},{year}": value + for area_id, area in self.areas.items() + for year, value in scenario.loc[idx_area(area)].items() # type: ignore + if not pd.isna(value) + } + elif symbol in _LINK_RELATED_SYMBOLS: + scenario_rules = { + f"{symbol},{area1_id},{area2_id},{year}": value + for (area1_id, area2_id), (area1, area2) in self.links.items() + for year, value in scenario.loc[idx_link(area1, area2)].items() # type: ignore + if not pd.isna(value) + } + elif symbol in _HYDRO_LEVEL_RELATED_SYMBOLS: + scenario_rules = { + f"{symbol},{area_id},{year}": value / 100 + for area_id, area in self.areas.items() + for year, value in scenario.loc[idx_area(area)].items() # type: ignore + if not pd.isna(value) + } + elif symbol in _CLUSTER_RELATED_SYMBOLS: + clusters_mapping = self.clusters_by_symbols[symbol] + scenario_rules = { + f"{symbol},{area_id},{year},{cluster_id}": value + for area_id, clusters in clusters_mapping.items() + for cluster_id, cluster in clusters.items() + for year, value in scenario[self.areas[area_id]].loc[idx_cluster(self.areas[area_id], cluster)].items() + if not pd.isna(value) + } + elif symbol in _BINDING_CONSTRAINTS_RELATED_SYMBOLS: + scenario_rules = { + f"{symbol},{group_id},{year}": value + for group_id, group in self.groups.items() + for year, value in scenario.loc[idx_group(group)].items() # type: ignore + if not pd.isna(value) + } + else: + raise NotImplementedError(f"Unknown symbol {symbol}") + return scenario_rules + + def get_table_form(self, scenario_type: str, *, nan_value: t.Union[str, None] = "") -> TableForm: + """ + Get the scenario matrices in table form for the frontend. + + Args: + scenario_type: Scenario type. + nan_value: Value to replace NaNs. + + Returns: + Dictionary of dictionaries with the following format + + :: + + { + "area_id": { + "year": value, + ... + }, + ... + } + + For thermal and renewable clusters, the dictionary is nested: + + :: + + { + "area_id": { + "cluster_id": { + "year": value, + ... + }, + ... + }, + ... + } + """ + scenario = self.scenarios[scenario_type] + if isinstance(scenario, pd.DataFrame): + simple_scenario: _SimpleScenario = scenario.fillna(nan_value) + simple_table_form = simple_scenario.to_dict(orient="index") + return t.cast(SimpleTableForm, simple_table_form) + else: + cluster_scenario: _ClusterScenario = {area: df.fillna(nan_value) for area, df in scenario.items()} + cluster_table_form = {area: df.to_dict(orient="index") for area, df in cluster_scenario.items()} + return t.cast(ClusterTableForm, cluster_table_form) + + def set_table_form( + self, + table_form: TableForm, + scenario_type: str, + *, + nan_value: t.Union[str, None] = "", + ) -> None: + """ + Set the scenario matrix from table form data, for a specific scenario type. + + Args: + table_form: Simple or cluster table form data (see :meth:`get_table_form` for the format). + scenario_type: Scenario type. + nan_value: Value to replace NaNs. + """ + actual_scenario = self.scenarios[scenario_type] + if isinstance(actual_scenario, pd.DataFrame): + scenario = pd.DataFrame.from_dict(table_form, orient="index") + scenario = scenario.replace(nan_value, pd.NA) + self.scenarios[scenario_type] = scenario + else: + self.scenarios[scenario_type] = { + area: pd.DataFrame.from_dict(df, orient="index").replace(nan_value, pd.NA) + for area, df in table_form.items() + } + + def update_table_form(self, table_form: TableForm, scenario_type: str, *, nan_value: str = "") -> None: + """ + Update the scenario matrices from table form data (partial update). + + Args: + table_form: Simple or cluster table form data (see :meth:`get_table_form` for the format). + scenario_type: Scenario type. + nan_value: Value to replace NaNs. for instance: ``{"& psp x1": {"0": 10}}``. + """ + + def to_nan(x: t.Union[int, float, str]) -> _Value: + return t.cast(_Value, pd.NA if x == nan_value else x) + + scenario = self.scenarios[scenario_type] + if isinstance(scenario, pd.DataFrame): + simple_table_form = t.cast(SimpleTableForm, table_form) + scenario.update(pd.DataFrame(simple_table_form).transpose().applymap(to_nan)) + else: + cluster_table_form = t.cast(ClusterTableForm, table_form) + for area, simple_table_form in cluster_table_form.items(): + scenario = t.cast(pd.DataFrame, self.scenarios[scenario_type][area]) + scenario.update(pd.DataFrame(simple_table_form).transpose().applymap(to_nan)) diff --git a/tests/storage/repository/filesystem/config/test_ruleset_matrices.py b/tests/storage/repository/filesystem/config/test_ruleset_matrices.py new file mode 100644 index 0000000000..99a833bdbd --- /dev/null +++ b/tests/storage/repository/filesystem/config/test_ruleset_matrices.py @@ -0,0 +1,533 @@ +import pytest + +from antarest.study.storage.rawstudy.model.filesystem.config.ruleset_matrices import RulesetMatrices + +SCENARIO_TYPES = { + "l": "load", + "h": "hydro", + "w": "wind", + "s": "solar", + "t": "thermal", + "r": "renewable", + "ntc": "link", + "bc": "bindingConstraints", + "hl": "hydroInitialLevels", + "hfl": "hydroFinalLevels", + "hgp": "hydroGenerationPower", +} + + +@pytest.fixture(name="ruleset") +def ruleset_fixture(): + return RulesetMatrices( + nb_years=4, + areas=["France", "Germany", "Italy"], + links=[("Germany", "France"), ("Italy", "France")], + thermals={"France": ["nuclear", "coal"], "Italy": ["nuclear", "fuel"], "Germany": ["gaz", "fuel"]}, + renewables={"France": ["wind offshore", "wind onshore"], "Germany": ["wind onshore"]}, + groups=["Main", "Secondary"], + scenario_types=SCENARIO_TYPES, + ) + + +class TestRuleset: + def test_ruleset__init(self, ruleset: RulesetMatrices) -> None: + assert ruleset.columns == ["0", "1", "2", "3"] + assert ruleset.scenarios["load"].shape == (3, 4) + assert ruleset.scenarios["load"].index.tolist() == ["France", "Germany", "Italy"] + assert ruleset.scenarios["hydro"].shape == (3, 4) + assert ruleset.scenarios["hydro"].index.tolist() == ["France", "Germany", "Italy"] + assert ruleset.scenarios["wind"].shape == (3, 4) + assert ruleset.scenarios["wind"].index.tolist() == ["France", "Germany", "Italy"] + assert ruleset.scenarios["solar"].shape == (3, 4) + assert ruleset.scenarios["solar"].index.tolist() == ["France", "Germany", "Italy"] + thermal = ruleset.scenarios["thermal"] + assert thermal["France"].shape == (2, 4) + assert thermal["France"].index.tolist() == ["nuclear", "coal"] + assert thermal["Italy"].shape == (2, 4) + assert thermal["Italy"].index.tolist() == ["nuclear", "fuel"] + assert thermal["Germany"].shape == (2, 4) + renewable = ruleset.scenarios["renewable"] + assert renewable["France"].shape == (2, 4) + assert renewable["France"].index.tolist() == ["wind offshore", "wind onshore"] + assert renewable["Germany"].shape == (1, 4) + assert renewable["Germany"].index.tolist() == ["wind onshore"] + assert ruleset.scenarios["link"].shape == (2, 4) + assert ruleset.scenarios["link"].index.tolist() == ["Germany / France", "Italy / France"] + assert ruleset.scenarios["bindingConstraints"].shape == (2, 4) + assert ruleset.scenarios["bindingConstraints"].index.tolist() == ["Main", "Secondary"] + assert ruleset.scenarios["hydroInitialLevels"].shape == (3, 4) + assert ruleset.scenarios["hydroInitialLevels"].index.tolist() == ["France", "Germany", "Italy"] + assert ruleset.scenarios["hydroFinalLevels"].shape == (3, 4) + assert ruleset.scenarios["hydroFinalLevels"].index.tolist() == ["France", "Germany", "Italy"] + assert ruleset.scenarios["hydroGenerationPower"].shape == (3, 4) + assert ruleset.scenarios["hydroGenerationPower"].index.tolist() == ["France", "Germany", "Italy"] + + @pytest.mark.parametrize( + "symbol, scenario_type", + [ + ("l", "load"), + ("h", "hydro"), + ("w", "wind"), + ("s", "solar"), + ("hgp", "hydroGenerationPower"), + ], + ) + def test_update_rules__load(self, ruleset: RulesetMatrices, symbol: str, scenario_type: str) -> None: + rules = { + f"{symbol},france,0": 1, + f"{symbol},germany,0": 2, + f"{symbol},italy,0": 3, + f"{symbol},france,1": 4, + f"{symbol},germany,1": 5, + f"{symbol},italy,1": 6, + f"{symbol},france,2": 7, + f"{symbol},germany,2": 8, + } + ruleset.update_rules(rules) + actual = ruleset.scenarios[scenario_type] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "France": {"0": 1.0, "1": 4.0, "2": 7.0, "3": "NaN"}, + "Germany": {"0": 2.0, "1": 5.0, "2": 8.0, "3": "NaN"}, + "Italy": {"0": 3.0, "1": 6.0, "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual_rules = ruleset.get_rules() + assert actual_rules == rules + + def test_update_rules__link(self, ruleset: RulesetMatrices) -> None: + rules = { + "ntc,germany,france,0": 1, + "ntc,italy,france,0": 2, + "ntc,germany,france,1": 3, + "ntc,italy,france,1": 4, + "ntc,germany,france,2": 5, + } + ruleset.update_rules(rules) + actual = ruleset.scenarios["link"] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "Germany / France": {"0": 1.0, "1": 3.0, "2": 5.0, "3": "NaN"}, + "Italy / France": {"0": 2.0, "1": 4.0, "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual_rules = ruleset.get_rules() + assert actual_rules == rules + + def test_update_rules__thermal(self, ruleset: RulesetMatrices) -> None: + rules = { + "t,france,0,nuclear": 1, + "t,france,0,coal": 2, + "t,italy,0,nuclear": 3, + "t,italy,0,fuel": 4, + "t,france,1,nuclear": 5, + "t,france,1,coal": 6, + "t,italy,1,nuclear": 7, + "t,italy,1,fuel": 8, + } + ruleset.update_rules(rules) + actual_map = ruleset.scenarios["thermal"] + + actual = actual_map["France"] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "coal": {"0": 2.0, "1": 6.0, "2": "NaN", "3": "NaN"}, + "nuclear": {"0": 1.0, "1": 5.0, "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual = actual_map["Italy"] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "fuel": {"0": 4.0, "1": 8.0, "2": "NaN", "3": "NaN"}, + "nuclear": {"0": 3.0, "1": 7.0, "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual = actual_map["Germany"] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "gaz": {"0": "NaN", "1": "NaN", "2": "NaN", "3": "NaN"}, + "fuel": {"0": "NaN", "1": "NaN", "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual_rules = ruleset.get_rules() + assert actual_rules == rules + + def test_update_rules__renewable(self, ruleset: RulesetMatrices) -> None: + rules = { + "r,france,0,wind offshore": 1, + "r,france,0,wind onshore": 2, + "r,germany,0,wind onshore": 3, + "r,france,1,wind offshore": 4, + "r,france,1,wind onshore": 5, + "r,germany,1,wind onshore": 6, + } + ruleset.update_rules(rules) + actual_map = ruleset.scenarios["renewable"] + + actual = actual_map["France"] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "wind offshore": {"0": 1.0, "1": 4.0, "2": "NaN", "3": "NaN"}, + "wind onshore": {"0": 2.0, "1": 5.0, "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual = actual_map["Germany"] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "wind onshore": {"0": 3.0, "1": 6.0, "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual_rules = ruleset.get_rules() + assert actual_rules == rules + + def test_update_rules__hydro(self, ruleset: RulesetMatrices) -> None: + rules = { + "h,france,0": 1, + "h,germany,0": 2, + "h,italy,0": 3, + "h,france,1": 4, + "h,germany,1": 5, + "h,italy,1": 6, + } + ruleset.update_rules(rules) + actual = ruleset.scenarios["hydro"] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "France": {"0": 1.0, "1": 4.0, "2": "NaN", "3": "NaN"}, + "Germany": {"0": 2.0, "1": 5.0, "2": "NaN", "3": "NaN"}, + "Italy": {"0": 3.0, "1": 6.0, "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual_rules = ruleset.get_rules() + assert actual_rules == rules + + def test_update_rules__hydro_generation_power(self, ruleset: RulesetMatrices) -> None: + rules = { + "hgp,france,0": 1, + "hgp,germany,0": 2, + "hgp,italy,0": 3, + "hgp,france,1": 4, + "hgp,germany,1": 5, + "hgp,italy,1": 6, + } + ruleset.update_rules(rules) + actual = ruleset.scenarios["hydroGenerationPower"] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "France": {"0": 1.0, "1": 4.0, "2": "NaN", "3": "NaN"}, + "Germany": {"0": 2.0, "1": 5.0, "2": "NaN", "3": "NaN"}, + "Italy": {"0": 3.0, "1": 6.0, "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual_rules = ruleset.get_rules() + assert actual_rules == rules + + def test_update_rules__binding_constraints(self, ruleset: RulesetMatrices) -> None: + rules = { + "bc,main,0": 1, + "bc,secondary,0": 2, + "bc,main,1": 3, + "bc,secondary,1": 4, + "bc,main,2": 5, + } + ruleset.update_rules(rules) + actual = ruleset.scenarios["bindingConstraints"] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "Main": {"0": 1.0, "1": 3.0, "2": 5.0, "3": "NaN"}, + "Secondary": {"0": 2.0, "1": 4.0, "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual_rules = ruleset.get_rules() + assert actual_rules == rules + + def test_update_rules__hydro_initial_levels(self, ruleset: RulesetMatrices) -> None: + rules = { + "hl,france,0": 0.1, + "hl,germany,0": 0.2, + "hl,italy,0": 0.3, + "hl,france,1": 0.4, + "hl,germany,1": 0.5, + "hl,italy,1": 0.6, + } + ruleset.update_rules(rules) + actual = ruleset.scenarios["hydroInitialLevels"] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "France": {"0": 10, "1": 40, "2": "NaN", "3": "NaN"}, + "Germany": {"0": 20, "1": 50, "2": "NaN", "3": "NaN"}, + "Italy": {"0": 30, "1": 60, "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual_rules = ruleset.get_rules() + assert actual_rules == rules + + def test_update_rules__hydro_final_levels(self, ruleset: RulesetMatrices) -> None: + rules = { + "hfl,france,0": 0.1, + "hfl,germany,0": 0.2, + "hfl,italy,0": 0.3, + "hfl,france,1": 0.4, + "hfl,germany,1": 0.5, + "hfl,italy,1": 0.6, + } + ruleset.update_rules(rules) + actual = ruleset.scenarios["hydroFinalLevels"] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "France": {"0": 10, "1": 40, "2": "NaN", "3": "NaN"}, + "Germany": {"0": 20, "1": 50, "2": "NaN", "3": "NaN"}, + "Italy": {"0": 30, "1": 60, "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual_rules = ruleset.get_rules() + assert actual_rules == rules + + def test_update_rules__invalid(self, ruleset: RulesetMatrices) -> None: + rules = { + "invalid,france,0": 1, + "invalid,germany,0": 2, + "invalid,italy,0": 3, + "invalid,france,1": 4, + "invalid,germany,1": 5, + "invalid,italy,1": 6, + } + with pytest.raises(KeyError): + ruleset.update_rules(rules) + assert ruleset.get_rules() == {} + + def test_set_table_form(self, ruleset: RulesetMatrices) -> None: + table_form = { + "load": { + "France": {"0": 1, "1": 2, "2": 3, "3": 4}, + "Germany": {"0": 5, "1": 6, "2": 7, "3": 8}, + "Italy": {"0": 9, "1": "", "2": "", "3": ""}, + }, + "hydro": { + "France": {"0": 5, "1": 6, "2": "", "3": 8}, + "Germany": {"0": 9, "1": 10, "2": 11, "3": 12}, + "Italy": {"0": 13, "1": "", "2": 15, "3": 16}, + }, + "wind": { + "France": {"0": 17, "1": 18, "2": 19, "3": 20}, + "Germany": {"0": 21, "1": 22, "2": 23, "3": 24}, + "Italy": {"0": 25, "1": 26, "2": 27, "3": 28}, + }, + "solar": { + "France": {"0": 29, "1": 30, "2": 31, "3": 32}, + "Germany": {"0": 33, "1": 34, "2": 35, "3": 36}, + "Italy": {"0": 37, "1": 38, "2": 39, "3": 40}, + }, + "thermal": { + "France": { + "nuclear": {"0": 41, "1": 42, "2": 43, "3": 44}, + "coal": {"0": 45, "1": 46, "2": 47, "3": 48}, + }, + "Germany": { + "gaz": {"0": 49, "1": 50, "2": 51, "3": 52}, + "fuel": {"0": 53, "1": 54, "2": 55, "3": 56}, + }, + "Italy": { + "nuclear": {"0": 57, "1": 58, "2": 59, "3": 60}, + "fuel": {"0": 61, "1": 62, "2": 63, "3": 64}, + }, + }, + "renewable": { + "France": { + "wind offshore": {"0": 65, "1": 66, "2": 67, "3": 68}, + "wind onshore": {"0": 69, "1": 70, "2": 71, "3": 72}, + }, + "Germany": { + "wind onshore": {"0": 73, "1": 74, "2": 75, "3": 76}, + }, + }, + "link": { + "Germany / France": {"0": 77, "1": 78, "2": 79, "3": 80}, + "Italy / France": {"0": 81, "1": 82, "2": 83, "3": 84}, + }, + "bindingConstraints": { + "Main": {"0": 85, "1": 86, "2": 87, "3": 88}, + "Secondary": {"0": 89, "1": 90, "2": 91, "3": 92}, + }, + "hydroInitialLevels": { + "France": {"0": 93, "1": 94, "2": 95, "3": 96}, + "Germany": {"0": 97, "1": 98, "2": 99, "3": 100}, + "Italy": {"0": 101, "1": 102, "2": 103, "3": 104}, + }, + "hydroFinalLevels": { + "France": {"0": 105, "1": 106, "2": 107, "3": 108}, + "Germany": {"0": 109, "1": 110, "2": 111, "3": 112}, + "Italy": {"0": 113, "1": 114, "2": 115, "3": 116}, + }, + "hydroGenerationPower": { + "France": {"0": 117, "1": 118, "2": 119, "3": 120}, + "Germany": {"0": 121, "1": 122, "2": 123, "3": 124}, + "Italy": {"0": 125, "1": 126, "2": 127, "3": 128}, + }, + } + for scenario_type, table in table_form.items(): + ruleset.set_table_form(table, scenario_type) + actual_rules = ruleset.get_rules() + expected = { + "bc,main,0": 85, + "bc,main,1": 86, + "bc,main,2": 87, + "bc,main,3": 88, + "bc,secondary,0": 89, + "bc,secondary,1": 90, + "bc,secondary,2": 91, + "bc,secondary,3": 92, + "h,france,0": 5, + "h,france,1": 6, + "h,france,3": 8, + "h,germany,0": 9, + "h,germany,1": 10, + "h,germany,2": 11, + "h,germany,3": 12, + "h,italy,0": 13, + "h,italy,2": 15, + "h,italy,3": 16, + "hfl,france,0": 1.05, + "hfl,france,1": 1.06, + "hfl,france,2": 1.07, + "hfl,france,3": 1.08, + "hfl,germany,0": 1.09, + "hfl,germany,1": 1.1, + "hfl,germany,2": 1.11, + "hfl,germany,3": 1.12, + "hfl,italy,0": 1.13, + "hfl,italy,1": 1.14, + "hfl,italy,2": 1.15, + "hfl,italy,3": 1.16, + "hgp,france,0": 117, + "hgp,france,1": 118, + "hgp,france,2": 119, + "hgp,france,3": 120, + "hgp,germany,0": 121, + "hgp,germany,1": 122, + "hgp,germany,2": 123, + "hgp,germany,3": 124, + "hgp,italy,0": 125, + "hgp,italy,1": 126, + "hgp,italy,2": 127, + "hgp,italy,3": 128, + "hl,france,0": 0.93, + "hl,france,1": 0.94, + "hl,france,2": 0.95, + "hl,france,3": 0.96, + "hl,germany,0": 0.97, + "hl,germany,1": 0.98, + "hl,germany,2": 0.99, + "hl,germany,3": 1.0, + "hl,italy,0": 1.01, + "hl,italy,1": 1.02, + "hl,italy,2": 1.03, + "hl,italy,3": 1.04, + "l,france,0": 1, + "l,france,1": 2, + "l,france,2": 3, + "l,france,3": 4, + "l,germany,0": 5, + "l,germany,1": 6, + "l,germany,2": 7, + "l,germany,3": 8, + "l,italy,0": 9, + "ntc,germany,france,0": 77, + "ntc,germany,france,1": 78, + "ntc,germany,france,2": 79, + "ntc,germany,france,3": 80, + "ntc,italy,france,0": 81, + "ntc,italy,france,1": 82, + "ntc,italy,france,2": 83, + "ntc,italy,france,3": 84, + "r,france,0,wind offshore": 65, + "r,france,0,wind onshore": 69, + "r,france,1,wind offshore": 66, + "r,france,1,wind onshore": 70, + "r,france,2,wind offshore": 67, + "r,france,2,wind onshore": 71, + "r,france,3,wind offshore": 68, + "r,france,3,wind onshore": 72, + "r,germany,0,wind onshore": 73, + "r,germany,1,wind onshore": 74, + "r,germany,2,wind onshore": 75, + "r,germany,3,wind onshore": 76, + "s,france,0": 29, + "s,france,1": 30, + "s,france,2": 31, + "s,france,3": 32, + "s,germany,0": 33, + "s,germany,1": 34, + "s,germany,2": 35, + "s,germany,3": 36, + "s,italy,0": 37, + "s,italy,1": 38, + "s,italy,2": 39, + "s,italy,3": 40, + "t,france,0,coal": 45, + "t,france,0,nuclear": 41, + "t,france,1,coal": 46, + "t,france,1,nuclear": 42, + "t,france,2,coal": 47, + "t,france,2,nuclear": 43, + "t,france,3,coal": 48, + "t,france,3,nuclear": 44, + "t,germany,0,fuel": 53, + "t,germany,0,gaz": 49, + "t,germany,1,fuel": 54, + "t,germany,1,gaz": 50, + "t,germany,2,fuel": 55, + "t,germany,2,gaz": 51, + "t,germany,3,fuel": 56, + "t,germany,3,gaz": 52, + "t,italy,0,fuel": 61, + "t,italy,0,nuclear": 57, + "t,italy,1,fuel": 62, + "t,italy,1,nuclear": 58, + "t,italy,2,fuel": 63, + "t,italy,2,nuclear": 59, + "t,italy,3,fuel": 64, + "t,italy,3,nuclear": 60, + "w,france,0": 17, + "w,france,1": 18, + "w,france,2": 19, + "w,france,3": 20, + "w,germany,0": 21, + "w,germany,1": 22, + "w,germany,2": 23, + "w,germany,3": 24, + "w,italy,0": 25, + "w,italy,1": 26, + "w,italy,2": 27, + "w,italy,3": 28, + } + assert actual_rules == expected + # fmt: off + assert ruleset.get_table_form("load") == table_form["load"] + assert ruleset.get_table_form("hydro") == table_form["hydro"] + assert ruleset.get_table_form("wind") == table_form["wind"] + assert ruleset.get_table_form("solar") == table_form["solar"] + assert ruleset.get_table_form("thermal") == table_form["thermal"] + assert ruleset.get_table_form("renewable") == table_form["renewable"] + assert ruleset.get_table_form("link") == table_form["link"] + assert ruleset.get_table_form("bindingConstraints") == table_form["bindingConstraints"] + assert ruleset.get_table_form("hydroInitialLevels") == table_form["hydroInitialLevels"] + assert ruleset.get_table_form("hydroFinalLevels") == table_form["hydroFinalLevels"] + assert ruleset.get_table_form("hydroGenerationPower") == table_form["hydroGenerationPower"] + # fmt: on + + with pytest.raises(KeyError): + ruleset.get_table_form("invalid") From 76818356aa9162b39876ffb856bac8443a12b7df Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Wed, 5 Jun 2024 18:12:45 +0200 Subject: [PATCH 11/47] feat(sb): added symbol filtering in `ScenarioBuilder` node to read INI files --- .../root/settings/scenariobuilder.py | 36 ++++ .../filesystem/test_scenariobuilder.py | 188 ++++++++++++------ 2 files changed, 159 insertions(+), 65 deletions(-) diff --git a/antarest/study/storage/rawstudy/model/filesystem/root/settings/scenariobuilder.py b/antarest/study/storage/rawstudy/model/filesystem/root/settings/scenariobuilder.py index 17aa0f5e74..8fde47960e 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/root/settings/scenariobuilder.py +++ b/antarest/study/storage/rawstudy/model/filesystem/root/settings/scenariobuilder.py @@ -1,3 +1,4 @@ +import re import typing as t import typing_extensions as te @@ -103,3 +104,38 @@ def _populate_hydro_final_level_rules(self, rules: _Rules) -> None: def _populate_hydro_generation_power_rules(self, rules: _Rules) -> None: for area_id in self.config.areas: rules[f"hgp,{area_id},0"] = _TSNumber + + def _get_filtering_kwargs(self, url: t.List[str]) -> t.Dict[str, str]: + # If the URL contains 2 elements, we can filter the options based on the generator type. + if len(url) == 2: + section, symbol = url + if re.fullmatch(r"\w+", symbol): + # Mutate the URL to get all values matching the generator type. + url[:] = [section] + return {"section": section, "option_regex": f"{symbol},.*"} + + # If the URL contains 3 elements, we can filter on the generator type and area (or group for BC). + elif len(url) == 3: + section, symbol, area = url + if re.fullmatch(r"\w+", symbol): + # Mutate the URL to get all values matching the generator type. + url[:] = [section] + area_re = re.escape(area) + return {"section": section, "option_regex": f"{symbol},{area_re},.*"} + + # If the URL contains 4 elements, we can filter on the generator type, area, and cluster. + elif len(url) == 4: + section, symbol, area, cluster = url + if re.fullmatch(r"\w+", symbol): + # Mutate the URL to get all values matching the generator type. + url[:] = [section] + if symbol in ("t", "r"): + area_re = re.escape(area) + cluster_re = re.escape(cluster) + return {"section": section, "option_regex": rf"{symbol},{area_re},\d+,{cluster_re}"} + elif symbol == "ntc": + area1_re = re.escape(area) + area2_re = re.escape(cluster) + return {"section": section, "option_regex": f"{symbol},{area1_re},{area2_re},.*"} + + return super()._get_filtering_kwargs(url) diff --git a/tests/storage/repository/filesystem/test_scenariobuilder.py b/tests/storage/repository/filesystem/test_scenariobuilder.py index 3abd3847c6..91b964e621 100644 --- a/tests/storage/repository/filesystem/test_scenariobuilder.py +++ b/tests/storage/repository/filesystem/test_scenariobuilder.py @@ -5,71 +5,85 @@ from antarest.study.storage.rawstudy.model.filesystem.config.thermal import ThermalConfig from antarest.study.storage.rawstudy.model.filesystem.root.settings.scenariobuilder import ScenarioBuilder -content = """ -[Default Ruleset] -l,de,0 = 1 -l,es,0 = 1 -l,fr,0 = 1 -l,it,0 = 1 -s,de,0 = 1 -s,es,0 = 1 -s,fr,0 = 1 -s,it,0 = 1 -h,de,0 = 1 -h,es,0 = 1 -h,fr,0 = 1 -h,it,0 = 1 -w,de,0 = 1 -w,es,0 = 1 -w,fr,0 = 1 -w,it,0 = 1 -t,de,0,01_solar = 1 -t,de,0,02_wind_on = 1 -t,de,0,03_wind_off = 1 -t,de,0,04_res = 1 -t,de,0,05_nuclear = 1 -t,de,0,06_coal = 1 -t,de,0,07_gas = 1 -t,de,0,08_non-res = 1 -t,de,0,09_hydro_pump = 1 -t,es,0,01_solar = 1 -t,es,0,02_wind_on = 1 -t,es,0,03_wind_off = 1 -t,es,0,04_res = 1 -t,es,0,05_nuclear = 1 -t,es,0,06_coal = 1 -t,es,0,07_gas = 1 -t,es,0,08_non-res = 1 -t,es,0,09_hydro_pump = 1 -t,fr,0,01_solar = 1 -t,fr,0,02_wind_on = 1 -t,fr,0,03_wind_off = 1 -t,fr,0,04_res = 1 -t,fr,0,05_nuclear = 1 -t,fr,0,06_coal = 1 -t,fr,0,07_gas = 1 -t,fr,0,08_non-res = 1 -t,fr,0,09_hydro_pump = 1 -t,it,0,01_solar = 1 -t,it,0,02_wind_on = 1 -t,it,0,03_wind_off = 1 -t,it,0,04_res = 1 -t,it,0,05_nuclear = 1 -t,it,0,06_coal = 1 -t,it,0,07_gas = 1 -t,it,0,08_non-res = 1 -t,it,0,09_hydro_pump = 1 -# since v8.7 -bc,group a,0 = 1 -bc,group a,1 = 2 -bc,group b,0 = 2 -bc,group b,1 = 1 -""" - - -def test_get(tmp_path: Path): +RULES = { + "h,de,0": 1, + "h,es,0": 1, + "h,fr,0": 1, + "h,it,0": 1, + "l,de,0": 1, + "l,es,0": 1, + "l,fr,0": 1, + "l,it,0": 1, + "s,de,0": 1, + "s,es,0": 1, + "s,fr,0": 1, + "s,it,0": 1, + "ntc,de,fr,0": 34, + "ntc,de,fr,1": 45, + "ntc,de,it,0": 56, + "ntc,de,it,1": 67, + "t,de,0,01_solar": 11, + "t,de,0,02_wind_on": 12, + "t,de,0,03_wind_off": 13, + "t,de,0,04_res": 14, + "t,de,0,05_nuclear": 15, + "t,de,0,06_coal": 16, + "t,de,0,07_gas": 17, + "t,de,0,08_non-res": 18, + "t,de,0,09_hydro_pump": 19, + "t,es,0,01_solar": 21, + "t,es,0,02_wind_on": 22, + "t,es,0,03_wind_off": 23, + "t,es,0,04_res": 24, + "t,es,0,05_nuclear": 25, + "t,es,0,06_coal": 26, + "t,es,0,07_gas": 27, + "t,es,0,08_non-res": 28, + "t,es,0,09_hydro_pump": 29, + "t,fr,0,01_solar": 31, + "t,fr,1,01_solar": 31, + "t,fr,0,02_wind_on": 32, + "t,fr,1,02_wind_on": 32, + "t,fr,0,03_wind_off": 33, + "t,fr,1,03_wind_off": 33, + "t,fr,0,04_res": 34, + "t,fr,1,04_res": 34, + "t,fr,0,05_nuclear": 35, + "t,fr,1,05_nuclear": 35, + "t,fr,0,06_coal": 36, + "t,fr,1,06_coal": 36, + "t,fr,0,07_gas": 37, + "t,fr,1,07_gas": 37, + "t,fr,0,08_non-res": 38, + "t,fr,1,08_non-res": 38, + "t,fr,0,09_hydro_pump": 39, + "t,it,0,01_solar": 41, + "t,it,0,02_wind_on": 42, + "t,it,0,03_wind_off": 43, + "t,it,0,04_res": 44, + "t,it,0,05_nuclear": 45, + "t,it,0,06_coal": 46, + "t,it,0,07_gas": 47, + "t,it,0,08_non-res": 48, + "t,it,0,09_hydro_pump": 49, + "w,de,0": 1, + "w,es,0": 1, + "w,fr,0": 1, + "w,it,0": 1, + # since v8.7 + "bc,group a,0": 1, + "bc,group a,1": 2, + "bc,group b,0": 2, + "bc,group b,1": 1, +} + + +def test_get(tmp_path: Path) -> None: path = tmp_path / "file.ini" - path.write_text(content) + with open(path, mode="w") as f: + print("[Default Ruleset]", file=f) + for key, value in RULES.items(): + print(f"{key} = {value}", file=f) thermals = [ ThermalConfig(id="01_solar", name="01_solar", enabled=True), @@ -107,10 +121,54 @@ def test_get(tmp_path: Path): ), ) - assert node.get(["Default Ruleset", "t,it,0,09_hydro_pump"]) == 1 + actual = node.get() + assert actual == {"Default Ruleset": RULES} + + actual = node.get(["Default Ruleset"]) + assert actual == RULES + + assert node.get(["Default Ruleset", "t,it,0,09_hydro_pump"]) == 49 # since v8.7 assert node.get(["Default Ruleset", "bc,group a,0"]) == 1 assert node.get(["Default Ruleset", "bc,group a,1"]) == 2 assert node.get(["Default Ruleset", "bc,group b,0"]) == 2 assert node.get(["Default Ruleset", "bc,group b,1"]) == 1 + + actual = node.get(["Default Ruleset", "w,de,0"]) + assert actual == 1 + + # We can also filter the data by generator type + actual = node.get(["Default Ruleset", "s"]) + assert actual == {"s,de,0": 1, "s,es,0": 1, "s,fr,0": 1, "s,it,0": 1} + + # We can filter the data by generator type and area (or group for BC) + actual = node.get(["Default Ruleset", "t", "fr"]) + expected = { + "t,fr,0,01_solar": 31, + "t,fr,0,02_wind_on": 32, + "t,fr,0,03_wind_off": 33, + "t,fr,0,04_res": 34, + "t,fr,0,05_nuclear": 35, + "t,fr,0,06_coal": 36, + "t,fr,0,07_gas": 37, + "t,fr,0,08_non-res": 38, + "t,fr,0,09_hydro_pump": 39, + "t,fr,1,01_solar": 31, + "t,fr,1,02_wind_on": 32, + "t,fr,1,03_wind_off": 33, + "t,fr,1,04_res": 34, + "t,fr,1,05_nuclear": 35, + "t,fr,1,06_coal": 36, + "t,fr,1,07_gas": 37, + "t,fr,1,08_non-res": 38, + } + assert actual == expected + + # We can filter the data by generator type, area and cluster + actual = node.get(["Default Ruleset", "t", "fr", "01_solar"]) + assert actual == {"t,fr,0,01_solar": 31, "t,fr,1,01_solar": 31} + + # We can filter the data by link type + actual = node.get(["Default Ruleset", "ntc", "de", "fr"]) + assert actual == {"ntc,de,fr,0": 34, "ntc,de,fr,1": 45} From a62f546e51db70f9929f555804c9692e6c6405f5 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Sun, 9 Jun 2024 09:15:34 +0200 Subject: [PATCH 12/47] feat(sb): add methods in the manager to get/update rulesets by scenario type --- .../business/scenario_builder_management.py | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/antarest/study/business/scenario_builder_management.py b/antarest/study/business/scenario_builder_management.py index 1c417eb586..33950b5f91 100644 --- a/antarest/study/business/scenario_builder_management.py +++ b/antarest/study/business/scenario_builder_management.py @@ -1,9 +1,12 @@ +import enum import typing as t import typing_extensions as te from antarest.study.business.utils import execute_or_add_commands from antarest.study.model import Study +from antarest.study.storage.rawstudy.model.filesystem.config.ruleset_matrices import RulesetMatrices, TableForm +from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy from antarest.study.storage.storage_service import StudyStorageService from antarest.study.storage.variantstudy.model.command.update_scenario_builder import UpdateScenarioBuilder @@ -22,6 +25,127 @@ Rulesets: te.TypeAlias = t.MutableMapping[str, Ruleset] +class ScenarioType(str, enum.Enum): + """ + Scenario type + + - LOAD: Load scenario + - THERMAL: Thermal cluster scenario + - HYDRO: Hydraulic scenario + - WIND: Wind scenario + - SOLAR: Solar scenario + - NTC: NTC scenario (link) + - RENEWABLE: Renewable scenario + - BINDING_CONSTRAINTS: Binding constraints scenario + - HYDRO_INITIAL_LEVEL: hydraulic Initial level scenario + - HYDRO_FINAL_LEVEL: hydraulic Final level scenario + - HYDRO_GENERATION_POWER: hydraulic Generation power scenario + """ + + LOAD = "load" + THERMAL = "thermal" + HYDRO = "hydro" + WIND = "wind" + SOLAR = "solar" + LINK = "ntc" + RENEWABLE = "renewable" + BINDING_CONSTRAINTS = "bindingConstraints" + HYDRO_INITIAL_LEVEL = "hydroInitialLevels" + HYDRO_FINAL_LEVEL = "hydroFinalLevels" + HYDRO_GENERATION_POWER = "hydroGenerationPower" + + def __str__(self) -> str: + """Return the string representation of the enum value.""" + return self.value + + +SYMBOLS_BY_SCENARIO_TYPES = { + ScenarioType.LOAD: "l", + ScenarioType.HYDRO: "h", + ScenarioType.WIND: "w", + ScenarioType.SOLAR: "s", + ScenarioType.THERMAL: "t", + ScenarioType.RENEWABLE: "r", + ScenarioType.LINK: "ntc", + ScenarioType.BINDING_CONSTRAINTS: "bc", + ScenarioType.HYDRO_INITIAL_LEVEL: "hl", + ScenarioType.HYDRO_FINAL_LEVEL: "hfl", + ScenarioType.HYDRO_GENERATION_POWER: "hgp", +} + + +def _get_ruleset_config( + file_study: FileStudy, + ruleset_name: str, + symbol: str = "", +) -> t.Dict[str, t.Union[int, float]]: + try: + suffix = f"/{symbol}" if symbol else "" + url = f"settings/scenariobuilder/{ruleset_name}{suffix}".split("/") + ruleset_cfg = t.cast(t.Dict[str, t.Union[int, float]], file_study.tree.get(url)) + except KeyError: + ruleset_cfg = {} + return ruleset_cfg + + +def _get_nb_years(file_study: FileStudy) -> int: + try: + # noinspection SpellCheckingInspection + url = "settings/generaldata/general/nbyears".split("/") + nb_years = t.cast(int, file_study.tree.get(url)) + except KeyError: + nb_years = 1 + return nb_years + + +def _get_active_ruleset_name(file_study: FileStudy, default_ruleset: str = "Default Ruleset") -> str: + """ + Get the active ruleset name stored in the configuration at the following path: + ``settings/generaldata.ini``, in the section "general", key "active-rules-scenario". + + This ruleset name must match a section name in the scenario builder configuration + at the following path: ``settings/scenariobuilder``. + + Args: + file_study: Object representing the study file + default_ruleset: Name of the default ruleset + + Returns: + The active ruleset name if found in the configuration, or the default ruleset name if missing. + """ + try: + url = "settings/generaldata/general/active-rules-scenario".split("/") + active_ruleset = t.cast(str, file_study.tree.get(url)) + except KeyError: + active_ruleset = default_ruleset + else: + # In some old studies, the active ruleset is stored in lowercase. + if not active_ruleset or active_ruleset.lower() == "default ruleset": + active_ruleset = default_ruleset + return active_ruleset + + +def _build_ruleset(file_study: FileStudy, symbol: str = "") -> RulesetMatrices: + ruleset_name = _get_active_ruleset_name(file_study) + nb_years = _get_nb_years(file_study) + ruleset_config = _get_ruleset_config(file_study, ruleset_name, symbol) + + # Create and populate the RulesetMatrices + areas = file_study.config.areas + scenario_types = {s: str(st) for st, s in SYMBOLS_BY_SCENARIO_TYPES.items()} + ruleset = RulesetMatrices( + nb_years=nb_years, + areas=areas, + links=((a1, a2) for a1 in areas for a2 in file_study.config.get_links(a1)), + thermals={a: file_study.config.get_thermal_ids(a) for a in areas}, + renewables={a: file_study.config.get_renewable_ids(a) for a in areas}, + groups=file_study.config.get_binding_constraint_groups(), + scenario_types=scenario_types, + ) + ruleset.update_rules(ruleset_config) + return ruleset + + class ScenarioBuilderManager: def __init__(self, storage_service: StudyStorageService) -> None: self.storage_service = storage_service @@ -79,6 +203,33 @@ def update_config(self, study: Study, rulesets: Rulesets) -> None: self.storage_service, ) + def get_scenario_by_type(self, study: Study, scenario_type: ScenarioType) -> TableForm: + symbol = SYMBOLS_BY_SCENARIO_TYPES[scenario_type] + file_study = self.storage_service.get_storage(study).get_raw(study) + ruleset = _build_ruleset(file_study, symbol) + ruleset.sort_scenarios() + + # Extract the table form for the given scenario type + table_form = ruleset.get_table_form(str(scenario_type), nan_value="") + return table_form + + def update_scenario_by_type(self, study: Study, table_form: TableForm, scenario_type: ScenarioType) -> TableForm: + file_study = self.storage_service.get_storage(study).get_raw(study) + ruleset = _build_ruleset(file_study) + ruleset.update_table_form(table_form, str(scenario_type), nan_value="") + ruleset.sort_scenarios() + + # Create the UpdateScenarioBuilder command + ruleset_name = _get_active_ruleset_name(file_study) + data = {ruleset_name: ruleset.get_rules()} + command_context = self.storage_service.variant_study_service.command_factory.command_context + update_scenario = UpdateScenarioBuilder(data=data, command_context=command_context) + execute_or_add_commands(study, file_study, [update_scenario], self.storage_service) + + # Extract the updated table form for the given scenario type + table_form = ruleset.get_table_form(str(scenario_type), nan_value="") + return table_form + def _populate_common(section: _Section, symbol: str, data: t.Mapping[str, t.Mapping[str, t.Any]]) -> None: for area, scenario_area in data.items(): From 60f9372e069ecaa09f2ab4c01ddaef8058d15f29 Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Mon, 15 Apr 2024 16:26:10 +0200 Subject: [PATCH 13/47] feat(ui): add scenario builder types handling --- webapp/.eslintrc.cjs | 5 +- webapp/public/locales/en/main.json | 1 + webapp/public/locales/fr/main.json | 1 + .../ScenarioBuilderDialog/ConfigContext.ts | 15 -- .../ScenarioBuilderDialog/Rulesets.tsx | 60 +++--- .../ScenarioBuilderContext.ts | 24 +++ .../dialogs/ScenarioBuilderDialog/Table.tsx | 82 ++++++++ .../dialogs/ScenarioBuilderDialog/index.tsx | 152 ++++++++------- .../ScenarioBuilderDialog/tabs/Table.tsx | 142 -------------- .../ScenarioBuilderDialog/tabs/Thermal.tsx | 75 ------- .../dialogs/ScenarioBuilderDialog/utils.ts | 184 ++++++++++++++++-- .../ScenarioBuilderDialog/withAreas.tsx | 131 +++++++++++++ 12 files changed, 522 insertions(+), 350 deletions(-) delete mode 100644 webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/ConfigContext.ts create mode 100644 webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/ScenarioBuilderContext.ts create mode 100644 webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Table.tsx delete mode 100644 webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/tabs/Table.tsx delete mode 100644 webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/tabs/Thermal.tsx create mode 100644 webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/withAreas.tsx diff --git a/webapp/.eslintrc.cjs b/webapp/.eslintrc.cjs index c39a0165bc..80b5c04c13 100644 --- a/webapp/.eslintrc.cjs +++ b/webapp/.eslintrc.cjs @@ -45,10 +45,9 @@ module.exports = { ], curly: "error", "jsdoc/no-defaults": "off", - "jsdoc/require-hyphen-before-param-description": "warn", "jsdoc/require-jsdoc": "off", - "jsdoc/tag-lines": ["warn", "any", { "startLines": 1 }], // Expected 1 line after block description - "no-console": "error", + "jsdoc/tag-lines": ["warn", "any", { startLines: 1 }], // Expected 1 line after block description + "no-console": "warn", "no-param-reassign": [ "error", { diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index aa1b90a044..cf41d0173a 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -323,6 +323,7 @@ "study.configuration.general.mcScenarioBuilder.tab.solar": "Solar", "study.configuration.general.mcScenarioBuilder.tab.ntc": "NTC", "study.configuration.general.mcScenarioBuilder.tab.hydroLevels": "Hydro Levels", + "study.configuration.general.mcScenarioBuilder.tab.bindingConstraints": "Binding Constraints", "study.configuration.general.mcScenarioBuilder.dialog.delete.text": "Are you sure you want to delete '{{0}}' ruleset?", "study.configuration.general.mcScenarioBuilder.error.table": "'{{0}}' table not updated", "study.configuration.general.mcScenarioBuilder.error.ruleset.rename": "'{{0}}' ruleset not renamed", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 61a701a773..091cbe547e 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -323,6 +323,7 @@ "study.configuration.general.mcScenarioBuilder.tab.solar": "Solaire", "study.configuration.general.mcScenarioBuilder.tab.ntc": "NTC", "study.configuration.general.mcScenarioBuilder.tab.hydroLevels": "Hydro Levels", + "study.configuration.general.mcScenarioBuilder.tab.bindingConstraints": "Contraintes Couplantes", "study.configuration.general.mcScenarioBuilder.dialog.delete.text": "Êtes-vous sûr de vouloir supprimer la ruleset '{{0}}' ?", "study.configuration.general.mcScenarioBuilder.error.table": "La table '{{0}}' n'a pas été mise à jour", "study.configuration.general.mcScenarioBuilder.error.ruleset.rename": "La ruleset '{{0}}' n'a pas été renommée", diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/ConfigContext.ts b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/ConfigContext.ts deleted file mode 100644 index a516ceafd5..0000000000 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/ConfigContext.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createContext } from "react"; -import { StudyMetadata } from "../../../../../../../../common/types"; -import { ScenarioBuilderConfig } from "./utils"; - -interface CxType { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - config: Record; - setConfig: React.Dispatch>; - reloadConfig: VoidFunction; - activeRuleset: string; - setActiveRuleset: (ruleset: string) => void; - studyId: StudyMetadata["id"]; -} - -export default createContext({} as CxType); diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Rulesets.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Rulesets.tsx index 6eefad1392..c08945b6f7 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Rulesets.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Rulesets.tsx @@ -8,7 +8,7 @@ import { LoadingButton } from "@mui/lab"; import FileCopyIcon from "@mui/icons-material/FileCopy"; import AddIcon from "@mui/icons-material/Add"; import SelectFE from "../../../../../../../common/fieldEditors/SelectFE"; -import ConfigContext from "./ConfigContext"; +import { ScenarioBuilderContext } from "./ScenarioBuilderContext"; import StringFE from "../../../../../../../common/fieldEditors/StringFE"; import Form from "../../../../../../../common/Form"; import { SubmitHandlerPlus } from "../../../../../../../common/Form/types"; @@ -22,11 +22,11 @@ function Rulesets() { const { config, setConfig, - reloadConfig, + refreshConfig, activeRuleset, - setActiveRuleset, + updateRuleset, studyId, - } = useContext(ConfigContext); + } = useContext(ScenarioBuilderContext); const { t } = useTranslation(); const [openForm, setOpenForm] = useState<"add" | "rename" | "">(""); const [confirmDelete, setConfirmDelete] = useState(false); @@ -37,58 +37,63 @@ function Rulesets() { // Event Handlers //////////////////////////////////////////////////////////////// - const handleRename = ({ values: { name } }: SubmitHandlerType) => { + const handleRename = async ({ values: { name } }: SubmitHandlerType) => { setOpenForm(""); setConfig( (prev) => RA.renameKeys({ [activeRuleset]: name }, prev) as typeof prev, ); - setActiveRuleset(name); + updateRuleset(name); - return updateScenarioBuilderConfig(studyId, { - [activeRuleset]: "", - [name]: activeRuleset, - }).catch((err) => { - reloadConfig(); + try { + await updateScenarioBuilderConfig(studyId, { + [activeRuleset]: "", + [name]: activeRuleset, + }); + } catch (err) { + refreshConfig(); throw new Error( t( "study.configuration.general.mcScenarioBuilder.error.ruleset.rename", - { 0: activeRuleset }, + { + 0: activeRuleset, + }, ), { cause: err }, ); - }); + } }; - const handleAdd = ({ values: { name } }: SubmitHandlerType) => { + const handleAdd = async ({ values: { name } }: SubmitHandlerType) => { setOpenForm(""); setConfig((prev) => ({ [name]: {}, ...prev })); - setActiveRuleset(name); - - return updateScenarioBuilderConfig(studyId, { - [name]: {}, - }).catch((err) => { - reloadConfig(); - + updateRuleset(name); + + try { + await updateScenarioBuilderConfig(studyId, { + [name]: {}, + }); + } catch (err) { + refreshConfig(); throw new Error( t("study.configuration.general.mcScenarioBuilder.error.ruleset.add", { 0: name, }), { cause: err }, ); - }); + } }; const handleDelete = () => { const { [activeRuleset]: ignore, ...newConfig } = config; setConfig(newConfig); - setActiveRuleset(Object.keys(newConfig)[0] || ""); + updateRuleset(Object.keys(newConfig)[0] || ""); setConfirmDelete(false); updateScenarioBuilderConfig(studyId, { [activeRuleset]: "", }).catch((err) => { - reloadConfig(); + refreshConfig(); enqueueErrorSnackbar( t( @@ -103,12 +108,12 @@ function Rulesets() { const handleDuplicate = () => { const newRulesetName = `${activeRuleset} Copy`; setConfig((prev) => ({ [newRulesetName]: prev[activeRuleset], ...prev })); - setActiveRuleset(newRulesetName); + updateRuleset(newRulesetName); updateScenarioBuilderConfig(studyId, { [newRulesetName]: activeRuleset, }).catch((err) => { - reloadConfig(); + refreshConfig(); enqueueErrorSnackbar( t( @@ -131,6 +136,7 @@ function Rulesets() { display: "flex", alignItems: "center", gap: 1, + px: 2, }} > @@ -187,7 +193,7 @@ function Rulesets() { variant="outlined" startCaseLabel={false} onChange={(event) => { - setActiveRuleset(event.target.value as string); + updateRuleset(String(event.target.value)); }} /> setOpenForm("add")}> diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/ScenarioBuilderContext.ts b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/ScenarioBuilderContext.ts new file mode 100644 index 0000000000..85cac7c2a5 --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/ScenarioBuilderContext.ts @@ -0,0 +1,24 @@ +import { Dispatch, SetStateAction, createContext } from "react"; +import type { StudyMetadata } from "../../../../../../../../common/types"; +import type { ScenarioBuilderConfig } from "./utils"; + +interface ScenarioBuilderContextType { + config: ScenarioBuilderConfig; + setConfig: Dispatch>; + refreshConfig: VoidFunction; + activeRuleset: string; + updateRuleset: (ruleset: string) => void; + studyId: StudyMetadata["id"]; +} + +const defaultValues = { + config: {}, + setConfig: () => undefined, + refreshConfig: () => undefined, + activeRuleset: "", + updateRuleset: () => "", + studyId: "", +}; + +export const ScenarioBuilderContext = + createContext(defaultValues); diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Table.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Table.tsx new file mode 100644 index 0000000000..14fc437d08 --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Table.tsx @@ -0,0 +1,82 @@ +import { useContext } from "react"; +import { useTranslation } from "react-i18next"; +import * as R from "ramda"; +import TableForm from "../../../../../../../common/TableForm"; +import { ScenarioBuilderContext } from "./ScenarioBuilderContext"; +import { + GenericScenarioConfig, + ScenarioSymbol, + ThermalHandlerReturn, + updateScenarioBuilderConfig, +} from "./utils"; +import { SubmitHandlerPlus } from "../../../../../../../common/Form/types"; + +interface Props { + config: GenericScenarioConfig | ThermalHandlerReturn; + symbol: ScenarioSymbol; + areaId?: string; +} + +function Table({ config, symbol, areaId }: Props) { + const { t } = useTranslation(); + + const { activeRuleset, setConfig, refreshConfig, studyId } = useContext( + ScenarioBuilderContext, + ); + + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// + + const handleSubmit = async ({ dirtyValues }: SubmitHandlerPlus) => { + const newData = { + [activeRuleset]: { + [symbol]: + symbol === "t" && areaId ? { [areaId]: dirtyValues } : dirtyValues, + }, + }; + + setConfig(R.mergeDeepLeft(newData)); + + try { + await updateScenarioBuilderConfig(studyId, newData); + } catch (err) { + refreshConfig(); + + throw new Error( // TODO snackbar + t("study.configuration.general.mcScenarioBuilder.error.table", { + 0: `${activeRuleset}.${symbol}`, + }), + { cause: err }, + ); + } + }; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + if (!config) { + return
No configuration available
; + } + + return ( + + `${t("study.configuration.general.mcScenarioBuilder.year")} ${ + index + 1 + }`, + className: "htCenter", + }} + onSubmit={handleSubmit} + /> + ); +} + +export default Table; diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/index.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/index.tsx index 83abfb6a17..d7317f0bb9 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/index.tsx @@ -1,26 +1,27 @@ import { TabContext, TabList, TabListProps, TabPanel } from "@mui/lab"; import { Box, Button, Tab } from "@mui/material"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { StudyMetadata } from "../../../../../../../../common/types"; import usePromise from "../../../../../../../../hooks/usePromise"; import BasicDialog from "../../../../../../../common/dialogs/BasicDialog"; import Rulesets from "./Rulesets"; -import Table from "./tabs/Table"; +import Table from "./Table"; import { - ACTIVE_SCENARIO_PATH, + RULESET_PATH, getScenarioBuilderConfig, ScenarioBuilderConfig, - TABS_DATA, + SCENARIOS, } from "./utils"; -import ConfigContext from "./ConfigContext"; -import Thermal from "./tabs/Thermal"; +import { ScenarioBuilderContext } from "./ScenarioBuilderContext"; import UsePromiseCond from "../../../../../../../common/utils/UsePromiseCond"; import { editStudy, getStudyData, } from "../../../../../../../../services/api/study"; import useEnqueueErrorSnackbar from "../../../../../../../../hooks/useEnqueueErrorSnackbar"; +import { AxiosError } from "axios"; +import withAreas from "./withAreas"; interface Props { study: StudyMetadata; @@ -29,66 +30,76 @@ interface Props { nbYears: number; } -function ScenarioBuilderDialog(props: Props) { - const { study, open, onClose, nbYears } = props; - const [currentTab, setCurrentTab] = useState(TABS_DATA[0][0]); - const [activeRuleset, setActiveRuleset] = useState(""); - const [config, setConfig] = useState({}); - const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); +// HOC that provides areas menu, for particular cases. (e.g thermals) +const EnhancedTable = withAreas(Table); + +function ScenarioBuilderDialog({ study, open, onClose, nbYears }: Props) { const { t } = useTranslation(); + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); + const [config, setConfig] = useState({}); + const [activeRuleset, setActiveRuleset] = useState(""); + const [selectedScenario, setSelectedScenario] = useState(SCENARIOS[0].type); const res = usePromise(async () => { - const config = await getScenarioBuilderConfig(study.id); - setConfig(config); - try { - const activeRuleset = await getStudyData( - study.id, - ACTIVE_SCENARIO_PATH, + const [config, rulesetId] = await Promise.all([ + // TODO use nbYears and a query param to get the splitted content + getScenarioBuilderConfig(study.id), + getStudyData(study.id, RULESET_PATH), // Active ruleset. + ]); + + setConfig(config); + setActiveRuleset(rulesetId); + } catch (error) { + // TODO test + setActiveRuleset(activeRuleset); + + enqueueErrorSnackbar( + "There is no active ruleset or valid configuration available.", + error as AxiosError, ); + } + }, [study.id, t, enqueueErrorSnackbar]); + + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// - if (!config[activeRuleset]) { - throw new Error(); + const handleActiveRulesetChange = useCallback( + async (ruleset: string) => { + setActiveRuleset(ruleset); + try { + await editStudy(ruleset, study.id, RULESET_PATH); + } catch (error) { + enqueueErrorSnackbar( + t("study.configuration.error.changeActiveRuleset"), + error as AxiosError, + ); } + }, + [study.id, t, enqueueErrorSnackbar, setActiveRuleset], + ); - setActiveRuleset(activeRuleset); - } catch { - setActiveRuleset(""); - } - }, [study.id]); + const handleScenarioChange: TabListProps["onChange"] = (_, type) => { + setSelectedScenario(type); + }; - const cxValue = useMemo( + //////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////// + + const scenarioBuilderContext = useMemo( () => ({ config, setConfig, - reloadConfig: res.reload, + refreshConfig: res.reload, activeRuleset, - setActiveRuleset: (ruleset: string) => { - setActiveRuleset(ruleset); - - editStudy(ruleset, study.id, ACTIVE_SCENARIO_PATH).catch((err) => { - setActiveRuleset(""); - enqueueErrorSnackbar( - t( - "study.configuration.general.mcScenarioBuilder.error.ruleset.changeActive", - ), - err, - ); - }); - }, + updateRuleset: handleActiveRulesetChange, studyId: study.id, }), - [activeRuleset, config, enqueueErrorSnackbar, res.reload, study.id, t], + [config, res.reload, activeRuleset, handleActiveRulesetChange, study.id], ); - //////////////////////////////////////////////////////////////// - // Event Handlers - //////////////////////////////////////////////////////////////// - - const handleTabChange: TabListProps["onChange"] = (_, newValue) => { - setCurrentTab(newValue); - }; - //////////////////////////////////////////////////////////////// // JSX //////////////////////////////////////////////////////////////// @@ -98,54 +109,45 @@ function ScenarioBuilderDialog(props: Props) { title={t("study.configuration.general.mcScenarioBuilder")} open={open} onClose={onClose} - actions={} - maxWidth="md" + actions={} + maxWidth="xl" fullWidth - PaperProps={{ - // TODO: add `maxHeight` and `fullHeight` in BasicDialog` - sx: { height: "calc(100% - 64px)", maxHeight: "900px" }, + contentProps={{ + sx: { p: 1, height: "95vh", width: 1 }, }} > ( - + {activeRuleset && ( - + - - {TABS_DATA.map(([name]) => ( + + {SCENARIOS.map(({ type }) => ( ))} - {TABS_DATA.map(([name, sym]) => ( + {SCENARIOS.map(({ type, symbol }) => ( - {name === "thermal" ? ( - - ) : ( - - )} + ))} )} - + )} /> diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/tabs/Table.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/tabs/Table.tsx deleted file mode 100644 index 61fc35ffe2..0000000000 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/tabs/Table.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { useContext, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import * as R from "ramda"; -import { Path } from "ramda"; -import { LinkElement } from "../../../../../../../../../common/types"; -import useStudySynthesis, { - UseStudySynthesisProps, -} from "../../../../../../../../../redux/hooks/useStudySynthesis"; -import { - getArea, - getAreas, - getLinks, -} from "../../../../../../../../../redux/selectors"; -import TableForm from "../../../../../../../../common/TableForm"; -import ConfigContext from "../ConfigContext"; -import { updateScenarioBuilderConfig } from "../utils"; -import { SubmitHandlerPlus } from "../../../../../../../../common/Form/types"; - -type ElementList = Array<{ - id: string; - name: string; - // In link - label?: LinkElement["label"]; -}>; - -type RowValues = Record; - -type TableData = Record; - -type RowType = "area" | "thermal" | "link"; - -interface Props { - nbYears: number; - symbol: string; - rowType: RowType; - areaId?: string; -} - -function Table(props: Props) { - const { nbYears, symbol, rowType, areaId } = props; - const { config, setConfig, reloadConfig, activeRuleset, studyId } = - useContext(ConfigContext); - const { t } = useTranslation(); - - const valuesFromConfig = R.path( - [activeRuleset, symbol, rowType === "thermal" && areaId].filter( - Boolean, - ) as Path, - config, - ) as TableData; - - const { data: areasOrLinksOrThermals = [] } = useStudySynthesis({ - studyId, - selector: R.cond< - [string], - UseStudySynthesisProps["selector"] - >([ - [R.equals("area"), () => getAreas], - [R.equals("link"), () => getLinks], - [ - R.equals("thermal"), - () => (state, studyId) => - areaId ? getArea(state, studyId, areaId)?.thermals : undefined, - ], - ])(rowType), - }); - - const defaultValues = useMemo(() => { - const emptyCols = Array.from({ length: nbYears }).reduce( - (acc: RowValues, _, index) => { - acc[String(index)] = ""; - return acc; - }, - {}, - ); - - return areasOrLinksOrThermals.reduce((acc: TableData, { id }) => { - acc[id] = { - ...emptyCols, - ...valuesFromConfig?.[id], - }; - return acc; - }, {}); - }, [areasOrLinksOrThermals, nbYears, valuesFromConfig]); - - //////////////////////////////////////////////////////////////// - // Event Handlers - //////////////////////////////////////////////////////////////// - - const handleSubmit = ({ dirtyValues }: SubmitHandlerPlus) => { - const newData = { - [activeRuleset]: { - [symbol]: - rowType === "thermal" && areaId - ? { [areaId]: dirtyValues } - : dirtyValues, - }, - }; - - setConfig(R.mergeDeepLeft(newData)); - - return updateScenarioBuilderConfig(studyId, newData).catch((err) => { - reloadConfig(); - - throw new Error( - t("study.configuration.general.mcScenarioBuilder.error.table", { - 0: `${activeRuleset}.${symbol}`, - }), - { cause: err }, - ); - }); - }; - - //////////////////////////////////////////////////////////////// - // JSX - //////////////////////////////////////////////////////////////// - - return ( - { - const item = areasOrLinksOrThermals.find(({ id }) => row.id === id); - return item ? item.label || item.name : String(row.id); - }, - colHeaders: (index) => - `${t("study.configuration.general.mcScenarioBuilder.year")} ${ - index + 1 - }`, - stretchH: "all", - className: "htCenter", - }} - onSubmit={handleSubmit} - /> - ); -} - -export default Table; diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/tabs/Thermal.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/tabs/Thermal.tsx deleted file mode 100644 index f84cf81a78..0000000000 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/tabs/Thermal.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { useContext, useEffect, useMemo, useState } from "react"; -import useStudySynthesis from "../../../../../../../../../redux/hooks/useStudySynthesis"; -import { getAreas } from "../../../../../../../../../redux/selectors"; -import { isSearchMatching } from "../../../../../../../../../utils/stringUtils"; -import PropertiesView from "../../../../../../../../common/PropertiesView"; -import SplitLayoutView from "../../../../../../../../common/SplitLayoutView"; -import UsePromiseCond from "../../../../../../../../common/utils/UsePromiseCond"; -import ListElement from "../../../../../common/ListElement"; -import ConfigContext from "../ConfigContext"; -import Table from "./Table"; - -interface Props { - nbYears: number; -} - -function Thermal(props: Props) { - const { nbYears } = props; - const { studyId } = useContext(ConfigContext); - const res = useStudySynthesis({ studyId, selector: getAreas }); - const [selectedAreaId, setSelectedAreaId] = useState(""); - const [searchValue, setSearchValue] = useState(""); - - const filteredAreas = useMemo( - () => - res.data?.filter(({ name }) => isSearchMatching(searchValue, name)) || [], - [res.data, searchValue], - ); - - useEffect(() => { - setSelectedAreaId(filteredAreas.length > 0 ? filteredAreas[0].id : ""); - }, [filteredAreas]); - - //////////////////////////////////////////////////////////////// - // JSX - //////////////////////////////////////////////////////////////// - - return ( - ( - setSelectedAreaId(id)} - /> - } - onSearchFilterChange={setSearchValue} - /> - } - right={ -
- } - /> - )} - /> - ); -} - -export default Thermal; diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/utils.ts b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/utils.ts index bf1de4b145..f50989e9ac 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/utils.ts @@ -2,20 +2,178 @@ import { AxiosResponse } from "axios"; import { StudyMetadata } from "../../../../../../../../common/types"; import client from "../../../../../../../../services/api/client"; -export const TABS_DATA: Array<[string, string]> = [ - ["load", "l"], - ["thermal", "t"], - ["hydro", "h"], - ["wind", "w"], - ["solar", "s"], - ["ntc", "ntc"], - ["hydroLevels", "hl"], -]; - -export const ACTIVE_SCENARIO_PATH = +//////////////////////////////////////////////////////////////// +// Constants +//////////////////////////////////////////////////////////////// + +// Defines each scenario type and its corresponding symbol. +export const SCENARIOS = [ + { type: "load", symbol: "l" }, + { type: "thermal", symbol: "t" }, + { type: "hydro", symbol: "h" }, + { type: "wind", symbol: "w" }, + { type: "solar", symbol: "s" }, + { type: "ntc", symbol: "ntc" }, + { type: "hydroLevels", symbol: "r" }, + { type: "bindingConstraints", symbol: "bc" }, +] as const; + +// Maps scenario types to the row types used in user interfaces. +export const ROW_TYPE_BY_SCENARIO = { + load: "area", + thermal: "area", + hydro: "area", + wind: "area", + solar: "area", + ntc: "link", + hydroLevels: "area", + bindingConstraints: "constraintGroup", +} as const; + +export const RULESET_PATH = "settings/generaldata/general/active-rules-scenario"; -export type ScenarioBuilderConfig = Record; +//////////////////////////////////////////////////////////////// +// Types +//////////////////////////////////////////////////////////////// + +export type RowType = keyof typeof ROW_TYPE_BY_SCENARIO; +export type ScenarioType = (typeof SCENARIOS)[number]["type"]; +export type ScenarioSymbol = (typeof SCENARIOS)[number]["symbol"]; + +// Represents values that can be either a number or an uninitialized string. +export type YearlyValues = number | ""; + +// Maps element IDs to their configurations, consisting of yearly data. +export type ElementConfig = Record; +// General configuration format for scenarios using areas as element IDs. +export type GenericScenarioConfig = Record; + +// Configuration format for clusters within thermal scenarios. +export type ClusterConfig = Record; +// Maps area IDs to configurations of clusters within those areas. +export type AreaClustersConfig = Record; // TODO make name more generic +// Full configuration for thermal scenarios involving multiple clusters per area. +export type ThermalConfig = Record; + +// Return structure for thermal scenario configuration handling. +export interface ThermalHandlerReturn { + areas: string[]; + clusters: Record; +} + +// General structure for ruleset configurations covering all scenarios. +export interface RulesetConfig { + l?: GenericScenarioConfig; + h?: GenericScenarioConfig; + s?: GenericScenarioConfig; + w?: GenericScenarioConfig; + t?: ThermalConfig; + ntc?: GenericScenarioConfig; + r?: ThermalConfig; + bc?: GenericScenarioConfig; +} + +// Enforces that all configurations within a ruleset are non-nullable. +type NonNullableRulesetConfig = { + [K in keyof RulesetConfig]-?: NonNullable; +}; + +// Maps ruleset names to their corresponding configurations. +export type ScenarioBuilderConfig = Record; + +// Function type for configuration handlers, capable of transforming config types. +type ConfigHandler = (config: T) => U; + +// Specific return types for each scenario handler. +export interface HandlerReturnTypes { + l: GenericScenarioConfig; + h: GenericScenarioConfig; + s: GenericScenarioConfig; + w: GenericScenarioConfig; + r: ThermalHandlerReturn; + ntc: GenericScenarioConfig; + t: ThermalHandlerReturn; + bc: GenericScenarioConfig; +} + +// Configuration handlers mapped by scenario type. +const handlers: { + [K in keyof NonNullableRulesetConfig]: ConfigHandler< + NonNullableRulesetConfig[K], + HandlerReturnTypes[K] + >; +} = { + l: handleGenericConfig, + h: handleGenericConfig, + s: handleGenericConfig, + w: handleGenericConfig, + r: handleThermalConfig, + ntc: handleGenericConfig, + bc: handleGenericConfig, + t: handleThermalConfig, +}; + +/** + * Handles generic scenario configurations by reducing key-value pairs into a single object. + * + * @param config The initial scenario configuration object. + * @returns The processed configuration object. + */ +function handleGenericConfig( + config: GenericScenarioConfig, +): GenericScenarioConfig { + return Object.entries(config).reduce( + (acc, [areaId, yearlyValue]) => { + acc[areaId] = yearlyValue; + return acc; + }, + {}, + ); +} + +/** + * Processes thermal configurations to separate areas and clusters. + * + * @param config The initial thermal scenario configuration. + * @returns Object containing separated areas and cluster configurations. + */ +function handleThermalConfig(config: ThermalConfig): ThermalHandlerReturn { + return Object.entries(config).reduce( + (acc, [areaId, clusterConfig]) => { + acc.areas.push(areaId); + acc.clusters[areaId] = clusterConfig; + return acc; + }, + { areas: [], clusters: {} }, + ); +} + +/** + * Retrieves and processes the configuration for a specific scenario within a ruleset. + * + * @param config Full configuration mapping by ruleset. + * @param ruleset The name of the ruleset to query. + * @param scenario The specific scenario type to retrieve. + * @returns The processed configuration or undefined if not found. + */ +export function getConfigByScenario( + config: ScenarioBuilderConfig, + ruleset: string, + scenario: K, +): HandlerReturnTypes[K] | undefined { + const scenarioConfig = config[ruleset]?.[scenario]; + + if (!scenarioConfig) { + return undefined; + } + + return handlers[scenario](scenarioConfig); +} + +//////////////////////////////////////////////////////////////// +// API +//////////////////////////////////////////////////////////////// function makeRequestURL(studyId: StudyMetadata["id"]): string { return `v1/studies/${studyId}/config/scenariobuilder`; @@ -28,7 +186,7 @@ export async function getScenarioBuilderConfig( return res.data; } -export async function updateScenarioBuilderConfig( +export function updateScenarioBuilderConfig( studyId: StudyMetadata["id"], data: Partial, ): Promise> { diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/withAreas.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/withAreas.tsx new file mode 100644 index 0000000000..8968e5d9e1 --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/withAreas.tsx @@ -0,0 +1,131 @@ +import { + ComponentType, + ReactElement, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { Box } from "@mui/material"; +import SplitView from "../../../../../../../common/SplitView"; +import PropertiesView from "../../../../../../../common/PropertiesView"; +import ListElement from "../../../../common/ListElement"; +import { + GenericScenarioConfig, + HandlerReturnTypes, + ScenarioSymbol, + ThermalHandlerReturn, + getConfigByScenario, +} from "./utils"; +import { ScenarioBuilderContext } from "./ScenarioBuilderContext"; + +interface ScenarioTableProps { + symbol: ScenarioSymbol; + areaId?: string; +} + +function hasAreas( + config: HandlerReturnTypes[keyof HandlerReturnTypes], +): config is ThermalHandlerReturn { + // TODO make a generic type for areas configurations return + return ( + "areas" in config && + Array.isArray(config.areas) && + config.areas.every((area) => typeof area === "string") + ); +} + +function withAreas( + Component: ComponentType< + ScenarioTableProps & { + config: GenericScenarioConfig | ThermalHandlerReturn; + } + >, +) { + return function TableWithAreas({ + symbol, + ...props + }: ScenarioTableProps): ReactElement { + const { config, activeRuleset } = useContext(ScenarioBuilderContext); + const [selectedAreaId, setSelectedAreaId] = useState(""); + const [areas, setAreas] = useState([]); + const [configByArea, setConfigByArea] = useState< + GenericScenarioConfig | ThermalHandlerReturn + >({}); + + const scenarioConfig = useMemo( + () => getConfigByScenario(config, activeRuleset, symbol), + [config, activeRuleset, symbol], + ); + + console.log("scenarioConfig", scenarioConfig); + + useEffect(() => { + if (scenarioConfig && hasAreas(scenarioConfig)) { + setAreas(scenarioConfig.areas); + + // Set selected area ID only if it hasn't been selected yet or current selection is not valid anymore. + if (!selectedAreaId || !scenarioConfig.areas.includes(selectedAreaId)) { + setSelectedAreaId(scenarioConfig.areas[0]); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scenarioConfig]); + + useEffect(() => { + if (scenarioConfig && hasAreas(scenarioConfig) && selectedAreaId) { + setConfigByArea(scenarioConfig.clusters[selectedAreaId]); + } + }, [selectedAreaId, scenarioConfig]); + + console.log("selectedAreaId", selectedAreaId); + console.log("symbol", symbol); + console.log("configByArea", configByArea); + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + // The regular case where no nested data depending on areas. + if (!areas.length && scenarioConfig) { + return ( + + ); + } + + return ( + + ({ + id: areaId, + name: `${areaId}`, + }))} + currentElement={selectedAreaId} + currentElementKeyToTest="id" + setSelectedItem={({ id }) => setSelectedAreaId(id)} + /> + } + /> + + + + + ); + }; +} + +export default withAreas; From b0c3bf39ec39bb4acb9695b543ad9ac0b09d7fed Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Mon, 27 May 2024 11:28:25 +0200 Subject: [PATCH 14/47] refactor(ui): minor improvements --- webapp/public/locales/en/main.json | 15 +- webapp/public/locales/fr/main.json | 15 +- .../ScenarioBuilderDialog/Rulesets.tsx | 238 ------------------ .../ScenarioBuilderContext.ts | 10 +- .../dialogs/ScenarioBuilderDialog/Table.tsx | 47 ++-- .../dialogs/ScenarioBuilderDialog/index.tsx | 124 ++++----- .../dialogs/ScenarioBuilderDialog/utils.ts | 140 +++++------ .../ScenarioBuilderDialog/withAreas.tsx | 46 ++-- .../explore/Configuration/General/index.tsx | 1 - 9 files changed, 155 insertions(+), 481 deletions(-) delete mode 100644 webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Rulesets.tsx diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index cf41d0173a..ff15d4bc95 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -314,23 +314,18 @@ "study.configuration.general.yearByYear": "Year-by-year", "study.configuration.general.mcScenario": "MC Scenario", "study.configuration.general.mcScenarioBuilder": "MC Scenario builder", - "study.configuration.general.mcScenarioBuilder.activeRuleset": "Active ruleset:", "study.configuration.general.mcScenarioBuilder.year": "Year", "study.configuration.general.mcScenarioBuilder.tab.load": "Load", "study.configuration.general.mcScenarioBuilder.tab.thermal": "Thermal", "study.configuration.general.mcScenarioBuilder.tab.hydro": "Hydro", "study.configuration.general.mcScenarioBuilder.tab.wind": "Wind", "study.configuration.general.mcScenarioBuilder.tab.solar": "Solar", - "study.configuration.general.mcScenarioBuilder.tab.ntc": "NTC", - "study.configuration.general.mcScenarioBuilder.tab.hydroLevels": "Hydro Levels", + "study.configuration.general.mcScenarioBuilder.tab.renewable": "Renewables", + "study.configuration.general.mcScenarioBuilder.tab.ntc": "NTC. Links", "study.configuration.general.mcScenarioBuilder.tab.bindingConstraints": "Binding Constraints", - "study.configuration.general.mcScenarioBuilder.dialog.delete.text": "Are you sure you want to delete '{{0}}' ruleset?", - "study.configuration.general.mcScenarioBuilder.error.table": "'{{0}}' table not updated", - "study.configuration.general.mcScenarioBuilder.error.ruleset.rename": "'{{0}}' ruleset not renamed", - "study.configuration.general.mcScenarioBuilder.error.ruleset.add": "'{{0}}' ruleset not added", - "study.configuration.general.mcScenarioBuilder.error.ruleset.delete": "'{{0}}' ruleset not deleted", - "study.configuration.general.mcScenarioBuilder.error.ruleset.duplicate": "'{{0}}' ruleset not duplicated", - "study.configuration.general.mcScenarioBuilder.error.ruleset.changeActive": "Active ruleset has not been changed", + "study.configuration.general.mcScenarioBuilder.noConfig.error": "There is no valid configuration available.", + "study.configuration.general.mcScenarioBuilder.update.error": "Failed to update scenario '{{type}}'.", + "study.configuration.general.mcScenarioBuilder.get.error":"Failed to fetch configuration for the selected scenario: '{{type}}'.", "study.configuration.general.mcScenarioPlaylist": "MC Scenario playlist", "study.configuration.general.mcScenarioPlaylist.action.enableAll": "Enable all", "study.configuration.general.mcScenarioPlaylist.action.disableAll": "Disable all", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 091cbe547e..e9fddc763c 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -314,23 +314,18 @@ "study.configuration.general.yearByYear": "Year-by-year", "study.configuration.general.mcScenario": "MC Scenario", "study.configuration.general.mcScenarioBuilder": "MC Scenario builder", - "study.configuration.general.mcScenarioBuilder.activeRuleset": "Ruleset actif :", "study.configuration.general.mcScenarioBuilder.year": "Année", "study.configuration.general.mcScenarioBuilder.tab.load": "Conso", "study.configuration.general.mcScenarioBuilder.tab.thermal": "Clus. Thermiques", "study.configuration.general.mcScenarioBuilder.tab.hydro": "Hydro", "study.configuration.general.mcScenarioBuilder.tab.wind": "Éolien", "study.configuration.general.mcScenarioBuilder.tab.solar": "Solaire", - "study.configuration.general.mcScenarioBuilder.tab.ntc": "NTC", - "study.configuration.general.mcScenarioBuilder.tab.hydroLevels": "Hydro Levels", + "study.configuration.general.mcScenarioBuilder.tab.renewable": "Clus. Renouvelables", + "study.configuration.general.mcScenarioBuilder.tab.ntc": "NTC. Liens", "study.configuration.general.mcScenarioBuilder.tab.bindingConstraints": "Contraintes Couplantes", - "study.configuration.general.mcScenarioBuilder.dialog.delete.text": "Êtes-vous sûr de vouloir supprimer la ruleset '{{0}}' ?", - "study.configuration.general.mcScenarioBuilder.error.table": "La table '{{0}}' n'a pas été mise à jour", - "study.configuration.general.mcScenarioBuilder.error.ruleset.rename": "La ruleset '{{0}}' n'a pas été renommée", - "study.configuration.general.mcScenarioBuilder.error.ruleset.add": "La ruleset '{{0}}' n'a pas été ajoutée", - "study.configuration.general.mcScenarioBuilder.error.ruleset.delete": "La ruleset '{{0}}' n'a pas été supprimée", - "study.configuration.general.mcScenarioBuilder.error.ruleset.duplicate": "La ruleset '{{0}}' n'a pas été dupliquée", - "study.configuration.general.mcScenarioBuilder.error.ruleset.changeActive": "La ruleset active n'a pas été changée", + "study.configuration.general.mcScenarioBuilder.noConfig.error": "Il n'y a aucune configuration valide disponible.", + "study.configuration.general.mcScenarioBuilder.update.error": "Échec de la mise à jour du scénario '{{type}}'.", + "study.configuration.general.mcScenarioBuilder.get.error": "Échec de la récupération de la configuration pour le scénario sélectionné : '{{type}}'.", "study.configuration.general.mcScenarioPlaylist": "MC Scenario playlist", "study.configuration.general.mcScenarioPlaylist.action.enableAll": "Activer tous", "study.configuration.general.mcScenarioPlaylist.action.disableAll": "Désactiver tous", diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Rulesets.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Rulesets.tsx deleted file mode 100644 index c08945b6f7..0000000000 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Rulesets.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { InputLabel, IconButton, Box, Button } from "@mui/material"; -import EditIcon from "@mui/icons-material/Edit"; -import DeleteIcon from "@mui/icons-material/Delete"; -import { useTranslation } from "react-i18next"; -import { useContext, useState } from "react"; -import * as RA from "ramda-adjunct"; -import { LoadingButton } from "@mui/lab"; -import FileCopyIcon from "@mui/icons-material/FileCopy"; -import AddIcon from "@mui/icons-material/Add"; -import SelectFE from "../../../../../../../common/fieldEditors/SelectFE"; -import { ScenarioBuilderContext } from "./ScenarioBuilderContext"; -import StringFE from "../../../../../../../common/fieldEditors/StringFE"; -import Form from "../../../../../../../common/Form"; -import { SubmitHandlerPlus } from "../../../../../../../common/Form/types"; -import { updateScenarioBuilderConfig } from "./utils"; -import ConfirmationDialog from "../../../../../../../common/dialogs/ConfirmationDialog"; -import useEnqueueErrorSnackbar from "../../../../../../../../hooks/useEnqueueErrorSnackbar"; - -type SubmitHandlerType = SubmitHandlerPlus<{ name: string }>; - -function Rulesets() { - const { - config, - setConfig, - refreshConfig, - activeRuleset, - updateRuleset, - studyId, - } = useContext(ScenarioBuilderContext); - const { t } = useTranslation(); - const [openForm, setOpenForm] = useState<"add" | "rename" | "">(""); - const [confirmDelete, setConfirmDelete] = useState(false); - const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); - const allowDelete = activeRuleset && Object.keys(config).length > 1; - - //////////////////////////////////////////////////////////////// - // Event Handlers - //////////////////////////////////////////////////////////////// - - const handleRename = async ({ values: { name } }: SubmitHandlerType) => { - setOpenForm(""); - setConfig( - (prev) => RA.renameKeys({ [activeRuleset]: name }, prev) as typeof prev, - ); - updateRuleset(name); - - try { - await updateScenarioBuilderConfig(studyId, { - [activeRuleset]: "", - [name]: activeRuleset, - }); - } catch (err) { - refreshConfig(); - - throw new Error( - t( - "study.configuration.general.mcScenarioBuilder.error.ruleset.rename", - { - 0: activeRuleset, - }, - ), - { cause: err }, - ); - } - }; - - const handleAdd = async ({ values: { name } }: SubmitHandlerType) => { - setOpenForm(""); - setConfig((prev) => ({ [name]: {}, ...prev })); - updateRuleset(name); - - try { - await updateScenarioBuilderConfig(studyId, { - [name]: {}, - }); - } catch (err) { - refreshConfig(); - throw new Error( - t("study.configuration.general.mcScenarioBuilder.error.ruleset.add", { - 0: name, - }), - { cause: err }, - ); - } - }; - - const handleDelete = () => { - const { [activeRuleset]: ignore, ...newConfig } = config; - setConfig(newConfig); - updateRuleset(Object.keys(newConfig)[0] || ""); - setConfirmDelete(false); - - updateScenarioBuilderConfig(studyId, { - [activeRuleset]: "", - }).catch((err) => { - refreshConfig(); - - enqueueErrorSnackbar( - t( - "study.configuration.general.mcScenarioBuilder.error.ruleset.delete", - { 0: activeRuleset }, - ), - err, - ); - }); - }; - - const handleDuplicate = () => { - const newRulesetName = `${activeRuleset} Copy`; - setConfig((prev) => ({ [newRulesetName]: prev[activeRuleset], ...prev })); - updateRuleset(newRulesetName); - - updateScenarioBuilderConfig(studyId, { - [newRulesetName]: activeRuleset, - }).catch((err) => { - refreshConfig(); - - enqueueErrorSnackbar( - t( - "study.configuration.general.mcScenarioBuilder.error.ruleset.duplicate", - { 0: activeRuleset }, - ), - err, - ); - }); - }; - - //////////////////////////////////////////////////////////////// - // JSX - //////////////////////////////////////////////////////////////// - - return ( - <> - - - {t("study.configuration.general.mcScenarioBuilder.activeRuleset")} - - {openForm ? ( -
- {({ control, formState: { isDirty, isSubmitting } }) => ( - <> - { - return !Object.keys(config).find( - (ruleset) => - v === ruleset && - (openForm === "add" || v !== activeRuleset), - ); - }, - }} - /> - - {t(`button.${openForm}`)} - - - - )} - - ) : ( - <> - { - updateRuleset(String(event.target.value)); - }} - /> - setOpenForm("add")}> - - - setOpenForm("rename")} - disabled={!activeRuleset} - > - - - - - - setConfirmDelete(true)} - disabled={!allowDelete} - > - - - - )} -
- {confirmDelete && ( - setConfirmDelete(false)} - alert="warning" - > - {t( - "study.configuration.general.mcScenarioBuilder.dialog.delete.text", - { 0: activeRuleset }, - )} - - )} - - ); -} - -export default Rulesets; diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/ScenarioBuilderContext.ts b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/ScenarioBuilderContext.ts index 85cac7c2a5..447e271dee 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/ScenarioBuilderContext.ts +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/ScenarioBuilderContext.ts @@ -1,22 +1,14 @@ -import { Dispatch, SetStateAction, createContext } from "react"; +import { createContext } from "react"; import type { StudyMetadata } from "../../../../../../../../common/types"; import type { ScenarioBuilderConfig } from "./utils"; interface ScenarioBuilderContextType { config: ScenarioBuilderConfig; - setConfig: Dispatch>; - refreshConfig: VoidFunction; - activeRuleset: string; - updateRuleset: (ruleset: string) => void; studyId: StudyMetadata["id"]; } const defaultValues = { config: {}, - setConfig: () => undefined, - refreshConfig: () => undefined, - activeRuleset: "", - updateRuleset: () => "", studyId: "", }; diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Table.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Table.tsx index 14fc437d08..1e6adeff30 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Table.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Table.tsx @@ -1,28 +1,28 @@ import { useContext } from "react"; import { useTranslation } from "react-i18next"; -import * as R from "ramda"; import TableForm from "../../../../../../../common/TableForm"; import { ScenarioBuilderContext } from "./ScenarioBuilderContext"; import { GenericScenarioConfig, - ScenarioSymbol, - ThermalHandlerReturn, + ScenarioType, + ClustersHandlerReturn, updateScenarioBuilderConfig, } from "./utils"; import { SubmitHandlerPlus } from "../../../../../../../common/Form/types"; +import SimpleContent from "../../../../../../../common/page/SimpleContent"; +import useEnqueueErrorSnackbar from "../../../../../../../../hooks/useEnqueueErrorSnackbar"; +import { AxiosError } from "axios"; interface Props { - config: GenericScenarioConfig | ThermalHandlerReturn; - symbol: ScenarioSymbol; + config: GenericScenarioConfig | ClustersHandlerReturn; + type: ScenarioType; areaId?: string; } -function Table({ config, symbol, areaId }: Props) { +function Table({ config, type, areaId }: Props) { const { t } = useTranslation(); - - const { activeRuleset, setConfig, refreshConfig, studyId } = useContext( - ScenarioBuilderContext, - ); + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); + const { studyId } = useContext(ScenarioBuilderContext); //////////////////////////////////////////////////////////////// // Event Handlers @@ -30,24 +30,20 @@ function Table({ config, symbol, areaId }: Props) { const handleSubmit = async ({ dirtyValues }: SubmitHandlerPlus) => { const newData = { - [activeRuleset]: { - [symbol]: - symbol === "t" && areaId ? { [areaId]: dirtyValues } : dirtyValues, - }, + [type]: + (type === "thermal" || type === "renewable") && areaId + ? { [areaId]: dirtyValues } + : dirtyValues, }; - setConfig(R.mergeDeepLeft(newData)); - try { await updateScenarioBuilderConfig(studyId, newData); - } catch (err) { - refreshConfig(); - - throw new Error( // TODO snackbar - t("study.configuration.general.mcScenarioBuilder.error.table", { - 0: `${activeRuleset}.${symbol}`, + } catch (error) { + enqueueErrorSnackbar( + t("study.configuration.general.mcScenarioBuilder.update.error", { + type, }), - { cause: err }, + error as AxiosError, ); } }; @@ -56,13 +52,14 @@ function Table({ config, symbol, areaId }: Props) { // JSX //////////////////////////////////////////////////////////////// - if (!config) { - return
No configuration available
; + if (Object.keys(config).length === 0) { + return ; } return ( ({}); - const [activeRuleset, setActiveRuleset] = useState(""); - const [selectedScenario, setSelectedScenario] = useState(SCENARIOS[0].type); + const [selectedScenario, setSelectedScenario] = useState( + SCENARIOS[0], + ); - const res = usePromise(async () => { + const scenarioConfig = usePromise(async () => { try { - const [config, rulesetId] = await Promise.all([ - // TODO use nbYears and a query param to get the splitted content - getScenarioBuilderConfig(study.id), - getStudyData(study.id, RULESET_PATH), // Active ruleset. - ]); - + const config = await getScenarioConfigByType(study.id, selectedScenario); setConfig(config); - setActiveRuleset(rulesetId); } catch (error) { - // TODO test - setActiveRuleset(activeRuleset); - enqueueErrorSnackbar( - "There is no active ruleset or valid configuration available.", + "study.configuration.general.mcScenarioBuilder.noConfig.error", error as AxiosError, ); } @@ -65,23 +51,9 @@ function ScenarioBuilderDialog({ study, open, onClose, nbYears }: Props) { // Event Handlers //////////////////////////////////////////////////////////////// - const handleActiveRulesetChange = useCallback( - async (ruleset: string) => { - setActiveRuleset(ruleset); - try { - await editStudy(ruleset, study.id, RULESET_PATH); - } catch (error) { - enqueueErrorSnackbar( - t("study.configuration.error.changeActiveRuleset"), - error as AxiosError, - ); - } - }, - [study.id, t, enqueueErrorSnackbar, setActiveRuleset], - ); - const handleScenarioChange: TabListProps["onChange"] = (_, type) => { setSelectedScenario(type); + scenarioConfig.reload(); }; //////////////////////////////////////////////////////////////// @@ -91,13 +63,9 @@ function ScenarioBuilderDialog({ study, open, onClose, nbYears }: Props) { const scenarioBuilderContext = useMemo( () => ({ config, - setConfig, - refreshConfig: res.reload, - activeRuleset, - updateRuleset: handleActiveRulesetChange, studyId: study.id, }), - [config, res.reload, activeRuleset, handleActiveRulesetChange, study.id], + [config, study.id], ); //////////////////////////////////////////////////////////////// @@ -116,40 +84,38 @@ function ScenarioBuilderDialog({ study, open, onClose, nbYears }: Props) { sx: { p: 1, height: "95vh", width: 1 }, }} > - ( - - - {activeRuleset && ( - - - - {SCENARIOS.map(({ type }) => ( - - ))} - - - {SCENARIOS.map(({ type, symbol }) => ( - - - - ))} - - )} - - )} - /> + + + + + {SCENARIOS.map((type) => ( + + ))} + + + {SCENARIOS.map((type) => ( + + } + ifPending={() => ( + + )} + /> + + ))} + + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/utils.ts b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/utils.ts index f50989e9ac..2a72582e68 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/utils.ts @@ -6,112 +6,84 @@ import client from "../../../../../../../../services/api/client"; // Constants //////////////////////////////////////////////////////////////// -// Defines each scenario type and its corresponding symbol. export const SCENARIOS = [ - { type: "load", symbol: "l" }, - { type: "thermal", symbol: "t" }, - { type: "hydro", symbol: "h" }, - { type: "wind", symbol: "w" }, - { type: "solar", symbol: "s" }, - { type: "ntc", symbol: "ntc" }, - { type: "hydroLevels", symbol: "r" }, - { type: "bindingConstraints", symbol: "bc" }, + "load", + "thermal", + "hydro", + "wind", + "solar", + "ntc", + "renewable", + "bindingConstraints", ] as const; -// Maps scenario types to the row types used in user interfaces. -export const ROW_TYPE_BY_SCENARIO = { - load: "area", - thermal: "area", - hydro: "area", - wind: "area", - solar: "area", - ntc: "link", - hydroLevels: "area", - bindingConstraints: "constraintGroup", -} as const; - -export const RULESET_PATH = - "settings/generaldata/general/active-rules-scenario"; +export type ScenarioType = (typeof SCENARIOS)[number]; //////////////////////////////////////////////////////////////// // Types //////////////////////////////////////////////////////////////// -export type RowType = keyof typeof ROW_TYPE_BY_SCENARIO; -export type ScenarioType = (typeof SCENARIOS)[number]["type"]; -export type ScenarioSymbol = (typeof SCENARIOS)[number]["symbol"]; - -// Represents values that can be either a number or an uninitialized string. +// Represents values that can be either a number or an uninitialized string (rand). export type YearlyValues = number | ""; - -// Maps element IDs to their configurations, consisting of yearly data. export type ElementConfig = Record; // General configuration format for scenarios using areas as element IDs. export type GenericScenarioConfig = Record; -// Configuration format for clusters within thermal scenarios. export type ClusterConfig = Record; -// Maps area IDs to configurations of clusters within those areas. -export type AreaClustersConfig = Record; // TODO make name more generic -// Full configuration for thermal scenarios involving multiple clusters per area. -export type ThermalConfig = Record; +export type AreaClustersConfig = Record; +// Full configuration for scenarios involving multiple clusters per area. +export type ClustersScenarioConfig = Record; -// Return structure for thermal scenario configuration handling. -export interface ThermalHandlerReturn { +export interface ClustersHandlerReturn { areas: string[]; clusters: Record; } // General structure for ruleset configurations covering all scenarios. export interface RulesetConfig { - l?: GenericScenarioConfig; - h?: GenericScenarioConfig; - s?: GenericScenarioConfig; - w?: GenericScenarioConfig; - t?: ThermalConfig; + load?: GenericScenarioConfig; + thermal?: ClustersScenarioConfig; + hydro?: GenericScenarioConfig; + wind?: GenericScenarioConfig; + solar?: GenericScenarioConfig; ntc?: GenericScenarioConfig; - r?: ThermalConfig; - bc?: GenericScenarioConfig; + renewable?: ClustersScenarioConfig; + bindingConstraints?: GenericScenarioConfig; } -// Enforces that all configurations within a ruleset are non-nullable. type NonNullableRulesetConfig = { [K in keyof RulesetConfig]-?: NonNullable; }; -// Maps ruleset names to their corresponding configurations. export type ScenarioBuilderConfig = Record; -// Function type for configuration handlers, capable of transforming config types. type ConfigHandler = (config: T) => U; -// Specific return types for each scenario handler. export interface HandlerReturnTypes { - l: GenericScenarioConfig; - h: GenericScenarioConfig; - s: GenericScenarioConfig; - w: GenericScenarioConfig; - r: ThermalHandlerReturn; + load: GenericScenarioConfig; + thermal: ClustersHandlerReturn; + hydro: GenericScenarioConfig; + wind: GenericScenarioConfig; + solar: GenericScenarioConfig; ntc: GenericScenarioConfig; - t: ThermalHandlerReturn; - bc: GenericScenarioConfig; + renewable: ClustersHandlerReturn; + bindingConstraints: GenericScenarioConfig; } -// Configuration handlers mapped by scenario type. const handlers: { [K in keyof NonNullableRulesetConfig]: ConfigHandler< NonNullableRulesetConfig[K], HandlerReturnTypes[K] >; } = { - l: handleGenericConfig, - h: handleGenericConfig, - s: handleGenericConfig, - w: handleGenericConfig, - r: handleThermalConfig, + load: handleGenericConfig, + thermal: handleClustersConfig, + hydro: handleGenericConfig, + wind: handleGenericConfig, + solar: handleGenericConfig, ntc: handleGenericConfig, - bc: handleGenericConfig, - t: handleThermalConfig, + renewable: handleClustersConfig, + bindingConstraints: handleGenericConfig, }; /** @@ -133,13 +105,15 @@ function handleGenericConfig( } /** - * Processes thermal configurations to separate areas and clusters. + * Processes clusters based configurations to separate areas and clusters. * - * @param config The initial thermal scenario configuration. + * @param config The initial clusters based scenario configuration. * @returns Object containing separated areas and cluster configurations. */ -function handleThermalConfig(config: ThermalConfig): ThermalHandlerReturn { - return Object.entries(config).reduce( +function handleClustersConfig( + config: ClustersScenarioConfig, +): ClustersHandlerReturn { + return Object.entries(config).reduce( (acc, [areaId, clusterConfig]) => { acc.areas.push(areaId); acc.clusters[areaId] = clusterConfig; @@ -153,16 +127,14 @@ function handleThermalConfig(config: ThermalConfig): ThermalHandlerReturn { * Retrieves and processes the configuration for a specific scenario within a ruleset. * * @param config Full configuration mapping by ruleset. - * @param ruleset The name of the ruleset to query. * @param scenario The specific scenario type to retrieve. * @returns The processed configuration or undefined if not found. */ export function getConfigByScenario( - config: ScenarioBuilderConfig, - ruleset: string, + config: RulesetConfig, scenario: K, ): HandlerReturnTypes[K] | undefined { - const scenarioConfig = config[ruleset]?.[scenario]; + const scenarioConfig = config[scenario]; if (!scenarioConfig) { return undefined; @@ -175,20 +147,32 @@ export function getConfigByScenario( // API //////////////////////////////////////////////////////////////// -function makeRequestURL(studyId: StudyMetadata["id"]): string { - return `v1/studies/${studyId}/config/scenariobuilder`; +export async function getScenarioBuilderConfig(studyId: StudyMetadata["id"]) { + const res = await client.get( + `v1/studies/${studyId}/config/scenariobuilder`, + ); + return res.data; } -export async function getScenarioBuilderConfig( +export async function getScenarioConfigByType( studyId: StudyMetadata["id"], -): Promise { - const res = await client.get(makeRequestURL(studyId)); + scenarioType: ScenarioType, +) { + const res = await client.get( + `v1/studies/${studyId}/config/scenariobuilder`, + { + params: { scenarioType }, + }, + ); return res.data; } export function updateScenarioBuilderConfig( studyId: StudyMetadata["id"], data: Partial, -): Promise> { - return client.put(makeRequestURL(studyId), data); +) { + return client.put>( + `v1/studies/${studyId}/config/scenariobuilder`, + data, + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/withAreas.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/withAreas.tsx index 8968e5d9e1..1326d61083 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/withAreas.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/withAreas.tsx @@ -1,11 +1,4 @@ -import { - ComponentType, - ReactElement, - useContext, - useEffect, - useMemo, - useState, -} from "react"; +import { ComponentType, useContext, useEffect, useMemo, useState } from "react"; import { Box } from "@mui/material"; import SplitView from "../../../../../../../common/SplitView"; import PropertiesView from "../../../../../../../common/PropertiesView"; @@ -13,21 +6,21 @@ import ListElement from "../../../../common/ListElement"; import { GenericScenarioConfig, HandlerReturnTypes, - ScenarioSymbol, - ThermalHandlerReturn, + ScenarioType, + ClustersHandlerReturn, getConfigByScenario, } from "./utils"; import { ScenarioBuilderContext } from "./ScenarioBuilderContext"; interface ScenarioTableProps { - symbol: ScenarioSymbol; + type: ScenarioType; areaId?: string; } +// If the configuration contains areas/clusters. function hasAreas( config: HandlerReturnTypes[keyof HandlerReturnTypes], -): config is ThermalHandlerReturn { - // TODO make a generic type for areas configurations return +): config is ClustersHandlerReturn { return ( "areas" in config && Array.isArray(config.areas) && @@ -38,28 +31,23 @@ function hasAreas( function withAreas( Component: ComponentType< ScenarioTableProps & { - config: GenericScenarioConfig | ThermalHandlerReturn; + config: GenericScenarioConfig | ClustersHandlerReturn; } >, ) { - return function TableWithAreas({ - symbol, - ...props - }: ScenarioTableProps): ReactElement { - const { config, activeRuleset } = useContext(ScenarioBuilderContext); + return function TableWithAreas({ type, ...props }: ScenarioTableProps) { + const { config } = useContext(ScenarioBuilderContext); const [selectedAreaId, setSelectedAreaId] = useState(""); const [areas, setAreas] = useState([]); const [configByArea, setConfigByArea] = useState< - GenericScenarioConfig | ThermalHandlerReturn + GenericScenarioConfig | ClustersHandlerReturn >({}); const scenarioConfig = useMemo( - () => getConfigByScenario(config, activeRuleset, symbol), - [config, activeRuleset, symbol], + () => getConfigByScenario(config, type), + [config, type], ); - console.log("scenarioConfig", scenarioConfig); - useEffect(() => { if (scenarioConfig && hasAreas(scenarioConfig)) { setAreas(scenarioConfig.areas); @@ -78,21 +66,17 @@ function withAreas( } }, [selectedAreaId, scenarioConfig]); - console.log("selectedAreaId", selectedAreaId); - console.log("symbol", symbol); - console.log("configByArea", configByArea); - //////////////////////////////////////////////////////////////// // JSX //////////////////////////////////////////////////////////////// - // The regular case where no nested data depending on areas. + // The regular case where no clusters nested data. if (!areas.length && scenarioConfig) { return ( ); @@ -119,7 +103,7 @@ function withAreas( diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx index e942d31134..8c1fcaf7ef 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx @@ -77,7 +77,6 @@ function GeneralParameters() { open study={study} onClose={handleCloseDialog} - nbYears={apiRef?.current?.getValues("nbYears") || 0} /> ), ], From 6a7e86ecb48afd566658a14f09ddba23d1ece2b3 Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Tue, 28 May 2024 11:35:04 +0200 Subject: [PATCH 15/47] feat(ui): configure esbuild to eliminate logs in production using pure option --- webapp/vite.config.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/webapp/vite.config.ts b/webapp/vite.config.ts index 9187a26b51..614e70c546 100644 --- a/webapp/vite.config.ts +++ b/webapp/vite.config.ts @@ -10,6 +10,11 @@ export default defineConfig(({ mode }) => { return { // Serve the web app at the `/static` entry point on Desktop mode base: isDesktopMode ? "/static/" : "/", + esbuild: { + // Remove logs safely when building production bundle + // https://esbuild.github.io/api/#pure + pure: mode === "production" ? ["console"] : [], + }, plugins: [react({ devTarget: "es2022" })], server: { port: 3000, From f60cf51da176d533f541158ee5cff082585e04d2 Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Tue, 28 May 2024 11:36:59 +0200 Subject: [PATCH 16/47] refactor(ui): improve types documentation and refine naming --- webapp/.eslintrc.cjs | 1 + webapp/public/locales/en/main.json | 4 +- webapp/public/locales/fr/main.json | 6 +- .../ScenarioBuilderContext.ts | 16 --- .../dialogs/ScenarioBuilderDialog/Table.tsx | 14 +-- .../dialogs/ScenarioBuilderDialog/index.tsx | 107 +++++++---------- .../dialogs/ScenarioBuilderDialog/utils.ts | 108 ++++++++++++------ .../ScenarioBuilderDialog/withAreas.tsx | 12 +- .../explore/Configuration/General/index.tsx | 9 +- 9 files changed, 139 insertions(+), 138 deletions(-) delete mode 100644 webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/ScenarioBuilderContext.ts diff --git a/webapp/.eslintrc.cjs b/webapp/.eslintrc.cjs index 80b5c04c13..8a4e508acb 100644 --- a/webapp/.eslintrc.cjs +++ b/webapp/.eslintrc.cjs @@ -46,6 +46,7 @@ module.exports = { curly: "error", "jsdoc/no-defaults": "off", "jsdoc/require-jsdoc": "off", + "jsdoc/require-hyphen-before-param-description": "warn", "jsdoc/tag-lines": ["warn", "any", { startLines: 1 }], // Expected 1 line after block description "no-console": "warn", "no-param-reassign": [ diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index ff15d4bc95..2b62fbecf7 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -316,12 +316,12 @@ "study.configuration.general.mcScenarioBuilder": "MC Scenario builder", "study.configuration.general.mcScenarioBuilder.year": "Year", "study.configuration.general.mcScenarioBuilder.tab.load": "Load", - "study.configuration.general.mcScenarioBuilder.tab.thermal": "Thermal", + "study.configuration.general.mcScenarioBuilder.tab.thermal": "Thermals", "study.configuration.general.mcScenarioBuilder.tab.hydro": "Hydro", "study.configuration.general.mcScenarioBuilder.tab.wind": "Wind", "study.configuration.general.mcScenarioBuilder.tab.solar": "Solar", "study.configuration.general.mcScenarioBuilder.tab.renewable": "Renewables", - "study.configuration.general.mcScenarioBuilder.tab.ntc": "NTC. Links", + "study.configuration.general.mcScenarioBuilder.tab.ntc": "Links", "study.configuration.general.mcScenarioBuilder.tab.bindingConstraints": "Binding Constraints", "study.configuration.general.mcScenarioBuilder.noConfig.error": "There is no valid configuration available.", "study.configuration.general.mcScenarioBuilder.update.error": "Failed to update scenario '{{type}}'.", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index e9fddc763c..61af6a0d01 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -316,12 +316,12 @@ "study.configuration.general.mcScenarioBuilder": "MC Scenario builder", "study.configuration.general.mcScenarioBuilder.year": "Année", "study.configuration.general.mcScenarioBuilder.tab.load": "Conso", - "study.configuration.general.mcScenarioBuilder.tab.thermal": "Clus. Thermiques", + "study.configuration.general.mcScenarioBuilder.tab.thermal": "Thermiques", "study.configuration.general.mcScenarioBuilder.tab.hydro": "Hydro", "study.configuration.general.mcScenarioBuilder.tab.wind": "Éolien", "study.configuration.general.mcScenarioBuilder.tab.solar": "Solaire", - "study.configuration.general.mcScenarioBuilder.tab.renewable": "Clus. Renouvelables", - "study.configuration.general.mcScenarioBuilder.tab.ntc": "NTC. Liens", + "study.configuration.general.mcScenarioBuilder.tab.renewable": "Renouvelables", + "study.configuration.general.mcScenarioBuilder.tab.ntc": "Liens", "study.configuration.general.mcScenarioBuilder.tab.bindingConstraints": "Contraintes Couplantes", "study.configuration.general.mcScenarioBuilder.noConfig.error": "Il n'y a aucune configuration valide disponible.", "study.configuration.general.mcScenarioBuilder.update.error": "Échec de la mise à jour du scénario '{{type}}'.", diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/ScenarioBuilderContext.ts b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/ScenarioBuilderContext.ts deleted file mode 100644 index 447e271dee..0000000000 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/ScenarioBuilderContext.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { createContext } from "react"; -import type { StudyMetadata } from "../../../../../../../../common/types"; -import type { ScenarioBuilderConfig } from "./utils"; - -interface ScenarioBuilderContextType { - config: ScenarioBuilderConfig; - studyId: StudyMetadata["id"]; -} - -const defaultValues = { - config: {}, - studyId: "", -}; - -export const ScenarioBuilderContext = - createContext(defaultValues); diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Table.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Table.tsx index 1e6adeff30..6ad04bdcfd 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Table.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Table.tsx @@ -1,7 +1,5 @@ -import { useContext } from "react"; import { useTranslation } from "react-i18next"; import TableForm from "../../../../../../../common/TableForm"; -import { ScenarioBuilderContext } from "./ScenarioBuilderContext"; import { GenericScenarioConfig, ScenarioType, @@ -11,7 +9,9 @@ import { import { SubmitHandlerPlus } from "../../../../../../../common/Form/types"; import SimpleContent from "../../../../../../../common/page/SimpleContent"; import useEnqueueErrorSnackbar from "../../../../../../../../hooks/useEnqueueErrorSnackbar"; -import { AxiosError } from "axios"; +import { toError } from "../../../../../../../../utils/fnUtils"; +import { useOutletContext } from "react-router"; +import { StudyMetadata } from "../../../../../../../../common/types"; interface Props { config: GenericScenarioConfig | ClustersHandlerReturn; @@ -22,14 +22,14 @@ interface Props { function Table({ config, type, areaId }: Props) { const { t } = useTranslation(); const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); - const { studyId } = useContext(ScenarioBuilderContext); + const { study } = useOutletContext<{ study: StudyMetadata }>(); //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// const handleSubmit = async ({ dirtyValues }: SubmitHandlerPlus) => { - const newData = { + const updatedScenario = { [type]: (type === "thermal" || type === "renewable") && areaId ? { [areaId]: dirtyValues } @@ -37,13 +37,13 @@ function Table({ config, type, areaId }: Props) { }; try { - await updateScenarioBuilderConfig(studyId, newData); + await updateScenarioBuilderConfig(study.id, updatedScenario, type); } catch (error) { enqueueErrorSnackbar( t("study.configuration.general.mcScenarioBuilder.update.error", { type, }), - error as AxiosError, + toError(error), ); } }; diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/index.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/index.tsx index a35cfd1eb8..e6499f591a 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/index.tsx @@ -1,22 +1,14 @@ import { TabContext, TabList, TabListProps, TabPanel } from "@mui/lab"; import { Box, Button, Tab, Skeleton } from "@mui/material"; -import { useMemo, useState } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { StudyMetadata } from "../../../../../../../../common/types"; -import usePromise from "../../../../../../../../hooks/usePromise"; import BasicDialog from "../../../../../../../common/dialogs/BasicDialog"; import Table from "./Table"; -import { - getScenarioConfigByType, - ScenarioBuilderConfig, - SCENARIOS, - ScenarioType, -} from "./utils"; -import { ScenarioBuilderContext } from "./ScenarioBuilderContext"; +import { getScenarioConfigByType, SCENARIOS, ScenarioType } from "./utils"; import UsePromiseCond from "../../../../../../../common/utils/UsePromiseCond"; -import useEnqueueErrorSnackbar from "../../../../../../../../hooks/useEnqueueErrorSnackbar"; -import { AxiosError } from "axios"; import withAreas from "./withAreas"; +import usePromiseWithSnackbarError from "../../../../../../../../hooks/usePromiseWithSnackbarError"; interface Props { study: StudyMetadata; @@ -29,23 +21,18 @@ const EnhancedTable = withAreas(Table); function ScenarioBuilderDialog({ study, open, onClose }: Props) { const { t } = useTranslation(); - const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); - const [config, setConfig] = useState({}); const [selectedScenario, setSelectedScenario] = useState( SCENARIOS[0], ); - const scenarioConfig = usePromise(async () => { - try { - const config = await getScenarioConfigByType(study.id, selectedScenario); - setConfig(config); - } catch (error) { - enqueueErrorSnackbar( + const config = usePromiseWithSnackbarError( + () => getScenarioConfigByType(study.id, selectedScenario), + { + errorMessage: t( "study.configuration.general.mcScenarioBuilder.noConfig.error", - error as AxiosError, - ); - } - }, [study.id, t, enqueueErrorSnackbar]); + ), + }, + ); //////////////////////////////////////////////////////////////// // Event Handlers @@ -53,21 +40,9 @@ function ScenarioBuilderDialog({ study, open, onClose }: Props) { const handleScenarioChange: TabListProps["onChange"] = (_, type) => { setSelectedScenario(type); - scenarioConfig.reload(); + config.reload(); }; - //////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////// - - const scenarioBuilderContext = useMemo( - () => ({ - config, - studyId: study.id, - }), - [config, study.id], - ); - //////////////////////////////////////////////////////////////// // JSX //////////////////////////////////////////////////////////////// @@ -77,45 +52,43 @@ function ScenarioBuilderDialog({ study, open, onClose }: Props) { title={t("study.configuration.general.mcScenarioBuilder")} open={open} onClose={onClose} - actions={} + actions={} maxWidth="xl" fullWidth contentProps={{ sx: { p: 1, height: "95vh", width: 1 }, }} > - - - - - {SCENARIOS.map((type) => ( - - ))} - - - {SCENARIOS.map((type) => ( - - } - ifPending={() => ( - + + + + {SCENARIOS.map((type) => ( + - - ))} - - + ))} + + + {SCENARIOS.map((type) => ( + + } + ifPending={() => ( + + )} + /> + + ))} + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/utils.ts b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/utils.ts index 2a72582e68..9a1a1d4f17 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/utils.ts @@ -23,24 +23,79 @@ export type ScenarioType = (typeof SCENARIOS)[number]; // Types //////////////////////////////////////////////////////////////// -// Represents values that can be either a number or an uninitialized string (rand). +/** + * Represents yearly configuration values, which can be either a numerical value or an uninitialized (rand) value represented as an empty string. + * + * @example + * { "0": 120, "1": "", "2": 150 } + */ export type YearlyValues = number | ""; -export type ElementConfig = Record; -// General configuration format for scenarios using areas as element IDs. -export type GenericScenarioConfig = Record; +/** + * Maps area identifiers to their configuration, each configuration being a series of values or uninitialized (rand) values. + * + * @example + * { "Area1": { "0": 10, "1": 20, "2": 15, "3": "", "4": 50 } } + */ +export type AreaConfig = Record; + +/** + * Maps cluster identifiers to their configurations within an area, similar to AreaConfig but used at the cluster level. + * + * @example + * { "Cluster1": { "0": 5, "1": "", "2": 20, "3": 30, "4": "" } } + */ export type ClusterConfig = Record; -export type AreaClustersConfig = Record; -// Full configuration for scenarios involving multiple clusters per area. -export type ClustersScenarioConfig = Record; + +/** + * Represents configuration for multiple clusters within each area. + * + * @example + * { + * "Area1": { + * "Cluster1": { "0": 10, "1": "", "2": 30 }, + * "Cluster2": { "0": 5, "1": 25, "2": "" } + * } + * } + */ +export type ClustersConfig = Record; + +/** + * General configuration format for scenarios using single areas as elements. + * Each scenario type maps to its specific areas configuration. + * + * @example + * { + * "load": { + * "Area1": { "0": 15, "1": 255, "2": "", "3": "", "4": "", "5": "" }, + * "Area2": { "0": 15, "1": 255, "2": "", "3": "", "4": "", "5": "" } + * } + * } + */ +export type GenericScenarioConfig = Record; + +/** + * Full configuration format for scenarios involving multiple clusters per area. + * + * @example + * { + * "thermal": { + * "Area1": { + * "Cluster1": { "0": 10, "1": "", "2": 30 }, + * "Cluster2": { "0": 5, "1": 25, "2": "" } + * } + * } + * } + */ +export type ClustersScenarioConfig = Record; export interface ClustersHandlerReturn { areas: string[]; - clusters: Record; + clusters: Record; } // General structure for ruleset configurations covering all scenarios. -export interface RulesetConfig { +export interface ScenarioConfig { load?: GenericScenarioConfig; thermal?: ClustersScenarioConfig; hydro?: GenericScenarioConfig; @@ -52,11 +107,9 @@ export interface RulesetConfig { } type NonNullableRulesetConfig = { - [K in keyof RulesetConfig]-?: NonNullable; + [K in keyof ScenarioConfig]-?: NonNullable; }; -export type ScenarioBuilderConfig = Record; - type ConfigHandler = (config: T) => U; export interface HandlerReturnTypes { @@ -89,7 +142,7 @@ const handlers: { /** * Handles generic scenario configurations by reducing key-value pairs into a single object. * - * @param config The initial scenario configuration object. + * @param config - The initial scenario configuration object. * @returns The processed configuration object. */ function handleGenericConfig( @@ -107,7 +160,7 @@ function handleGenericConfig( /** * Processes clusters based configurations to separate areas and clusters. * - * @param config The initial clusters based scenario configuration. + * @param config - The initial clusters based scenario configuration. * @returns Object containing separated areas and cluster configurations. */ function handleClustersConfig( @@ -126,12 +179,12 @@ function handleClustersConfig( /** * Retrieves and processes the configuration for a specific scenario within a ruleset. * - * @param config Full configuration mapping by ruleset. - * @param scenario The specific scenario type to retrieve. + * @param config - Full configuration mapping by ruleset. + * @param scenario - The specific scenario type to retrieve. * @returns The processed configuration or undefined if not found. */ -export function getConfigByScenario( - config: RulesetConfig, +export function getConfigByScenario( + config: ScenarioConfig, scenario: K, ): HandlerReturnTypes[K] | undefined { const scenarioConfig = config[scenario]; @@ -147,32 +200,23 @@ export function getConfigByScenario( // API //////////////////////////////////////////////////////////////// -export async function getScenarioBuilderConfig(studyId: StudyMetadata["id"]) { - const res = await client.get( - `v1/studies/${studyId}/config/scenariobuilder`, - ); - return res.data; -} - export async function getScenarioConfigByType( studyId: StudyMetadata["id"], scenarioType: ScenarioType, ) { - const res = await client.get( - `v1/studies/${studyId}/config/scenariobuilder`, - { - params: { scenarioType }, - }, + const res = await client.get( + `v1/studies/${studyId}/config/scenariobuilder/${scenarioType}`, ); return res.data; } export function updateScenarioBuilderConfig( studyId: StudyMetadata["id"], - data: Partial, + data: Partial, + scenarioType: ScenarioType, ) { return client.put>( - `v1/studies/${studyId}/config/scenariobuilder`, + `v1/studies/${studyId}/config/scenariobuilder/${scenarioType}`, data, ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/withAreas.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/withAreas.tsx index 1326d61083..192dab9375 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/withAreas.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/withAreas.tsx @@ -1,4 +1,4 @@ -import { ComponentType, useContext, useEffect, useMemo, useState } from "react"; +import { ComponentType, useEffect, useMemo, useState } from "react"; import { Box } from "@mui/material"; import SplitView from "../../../../../../../common/SplitView"; import PropertiesView from "../../../../../../../common/PropertiesView"; @@ -9,11 +9,12 @@ import { ScenarioType, ClustersHandlerReturn, getConfigByScenario, + ScenarioConfig, } from "./utils"; -import { ScenarioBuilderContext } from "./ScenarioBuilderContext"; interface ScenarioTableProps { type: ScenarioType; + config: ScenarioConfig; areaId?: string; } @@ -35,8 +36,11 @@ function withAreas( } >, ) { - return function TableWithAreas({ type, ...props }: ScenarioTableProps) { - const { config } = useContext(ScenarioBuilderContext); + return function TableWithAreas({ + type, + config, + ...props + }: ScenarioTableProps) { const [selectedAreaId, setSelectedAreaId] = useState(""); const [areas, setAreas] = useState([]); const [configByArea, setConfigByArea] = useState< diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx index 8c1fcaf7ef..dca52bc9ce 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx @@ -1,6 +1,6 @@ import { useOutletContext } from "react-router"; import * as R from "ramda"; -import { useRef, useState } from "react"; +import { useState } from "react"; import { StudyMetadata } from "../../../../../../common/types"; import Form from "../../../../../common/Form"; import Fields from "./Fields"; @@ -14,16 +14,12 @@ import { SetDialogStateType, setGeneralFormFields, } from "./utils"; -import { - SubmitHandlerPlus, - UseFormReturnPlus, -} from "../../../../../common/Form/types"; +import { SubmitHandlerPlus } from "../../../../../common/Form/types"; import ScenarioBuilderDialog from "./dialogs/ScenarioBuilderDialog"; function GeneralParameters() { const { study } = useOutletContext<{ study: StudyMetadata }>(); const [dialog, setDialog] = useState(""); - const apiRef = useRef>(); //////////////////////////////////////////////////////////////// // Event Handlers @@ -54,7 +50,6 @@ function GeneralParameters() { key={study.id} config={{ defaultValues: () => getGeneralFormFields(study.id) }} onSubmit={handleSubmit} - apiRef={apiRef} enableUndoRedo > From 3a7d96cef795a625b82b24f88a85e24fb2359f35 Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Mon, 15 Apr 2024 16:26:10 +0200 Subject: [PATCH 17/47] refactor(api): use URL param instead of query strings --- antarest/study/web/study_data_blueprint.py | 42 +++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index 613c8c3d44..39d2a732f0 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -54,7 +54,7 @@ from antarest.study.business.link_management import LinkInfoDTO from antarest.study.business.optimization_management import OptimizationFormFields from antarest.study.business.playlist_management import PlaylistColumns -from antarest.study.business.scenario_builder_management import Rulesets +from antarest.study.business.scenario_builder_management import Rulesets, ScenarioType from antarest.study.business.table_mode_management import TableDataDTO, TableModeType from antarest.study.business.thematic_trimming_field_infos import ThematicTrimmingFormFields from antarest.study.business.timeseries_config_management import TSFormFields @@ -65,6 +65,7 @@ BindingConstraintOperator, ) from antarest.study.storage.rawstudy.model.filesystem.config.model import transform_name_to_id +from antarest.study.storage.rawstudy.model.filesystem.config.ruleset_matrices import TableForm as SBTableForm logger = logging.getLogger(__name__) @@ -666,6 +667,25 @@ def get_scenario_builder_config( return study_service.scenario_builder_manager.get_config(study) + @bp.get( + path="/studies/{uuid}/config/scenariobuilder/{scenario_type}", + tags=[APITag.study_data], + summary="Get MC Scenario builder config", + ) + def get_scenario_builder_config_by_type( + uuid: str, + scenario_type: ScenarioType, + current_user: JWTUser = Depends(auth.get_current_user), + ) -> SBTableForm: + logger.info( + f"Getting MC Scenario builder config for study {uuid} with scenario type filter: {scenario_type}", + extra={"user": current_user.id}, + ) + params = RequestParameters(user=current_user) + study = study_service.check_study_access(uuid, StudyPermissionType.READ, params) + table_form = study_service.scenario_builder_manager.get_scenario_by_type(study, scenario_type) + return table_form + @bp.put( path="/studies/{uuid}/config/scenariobuilder", tags=[APITag.study_data], @@ -684,6 +704,26 @@ def update_scenario_builder_config( study = study_service.check_study_access(uuid, StudyPermissionType.WRITE, params) study_service.scenario_builder_manager.update_config(study, data) + @bp.put( + path="/studies/{uuid}/config/scenariobuilder/{scenario_type}", + tags=[APITag.study_data], + summary="Set MC Scenario builder config", + ) + def update_scenario_builder_config_by_type( + uuid: str, + data: SBTableForm, + scenario_type: ScenarioType, + current_user: JWTUser = Depends(auth.get_current_user), + ) -> SBTableForm: + logger.info( + f"Updating MC Scenario builder config for study {uuid} with scenario type filter: {scenario_type}", + extra={"user": current_user.id}, + ) + params = RequestParameters(user=current_user) + study = study_service.check_study_access(uuid, StudyPermissionType.WRITE, params) + table_form = study_service.scenario_builder_manager.update_scenario_by_type(study, data, scenario_type) + return table_form + @bp.get( path="/studies/{uuid}/config/general/form", tags=[APITag.study_data], From 5bc437c04c5c2deca443b21a1a2e6baec011790d Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Sun, 9 Jun 2024 09:29:51 +0200 Subject: [PATCH 18/47] feat: add hydro initial levels --- webapp/public/locales/en/main.json | 1 + webapp/public/locales/fr/main.json | 1 + .../General/dialogs/ScenarioBuilderDialog/utils.ts | 6 ++++++ 3 files changed, 8 insertions(+) diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 2b62fbecf7..0393800e1e 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -322,6 +322,7 @@ "study.configuration.general.mcScenarioBuilder.tab.solar": "Solar", "study.configuration.general.mcScenarioBuilder.tab.renewable": "Renewables", "study.configuration.general.mcScenarioBuilder.tab.ntc": "Links", + "study.configuration.general.mcScenarioBuilder.tab.hydroInitialLevels": "Hydro Initial Levels", "study.configuration.general.mcScenarioBuilder.tab.bindingConstraints": "Binding Constraints", "study.configuration.general.mcScenarioBuilder.noConfig.error": "There is no valid configuration available.", "study.configuration.general.mcScenarioBuilder.update.error": "Failed to update scenario '{{type}}'.", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 61af6a0d01..faa7ebd89f 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -322,6 +322,7 @@ "study.configuration.general.mcScenarioBuilder.tab.solar": "Solaire", "study.configuration.general.mcScenarioBuilder.tab.renewable": "Renouvelables", "study.configuration.general.mcScenarioBuilder.tab.ntc": "Liens", + "study.configuration.general.mcScenarioBuilder.tab.hydroInitialLevels": "Niveaux Hydro Initial", "study.configuration.general.mcScenarioBuilder.tab.bindingConstraints": "Contraintes Couplantes", "study.configuration.general.mcScenarioBuilder.noConfig.error": "Il n'y a aucune configuration valide disponible.", "study.configuration.general.mcScenarioBuilder.update.error": "Échec de la mise à jour du scénario '{{type}}'.", diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/utils.ts b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/utils.ts index 9a1a1d4f17..d59661fe60 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/utils.ts @@ -14,6 +14,9 @@ export const SCENARIOS = [ "solar", "ntc", "renewable", + "hydroInitialLevels", + // "hydroFinalLevels", since v9.2 + // "hydroGenerationPower", since v9.1 "bindingConstraints", ] as const; @@ -103,6 +106,7 @@ export interface ScenarioConfig { solar?: GenericScenarioConfig; ntc?: GenericScenarioConfig; renewable?: ClustersScenarioConfig; + hydroInitialLevels?: GenericScenarioConfig; bindingConstraints?: GenericScenarioConfig; } @@ -120,6 +124,7 @@ export interface HandlerReturnTypes { solar: GenericScenarioConfig; ntc: GenericScenarioConfig; renewable: ClustersHandlerReturn; + hydroInitialLevels?: GenericScenarioConfig; bindingConstraints: GenericScenarioConfig; } @@ -136,6 +141,7 @@ const handlers: { solar: handleGenericConfig, ntc: handleGenericConfig, renewable: handleClustersConfig, + hydroInitialLevels: handleGenericConfig, bindingConstraints: handleGenericConfig, }; From 69dc8b83b12673df02cb99b0351f5e7b3e466bc8 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Sun, 9 Jun 2024 21:00:38 +0200 Subject: [PATCH 19/47] fix(ui): correct typescript issues --- .../General/dialogs/ScenarioBuilderDialog/withAreas.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/withAreas.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/withAreas.tsx index 192dab9375..2862d14c13 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/withAreas.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/withAreas.tsx @@ -23,6 +23,7 @@ function hasAreas( config: HandlerReturnTypes[keyof HandlerReturnTypes], ): config is ClustersHandlerReturn { return ( + config !== undefined && "areas" in config && Array.isArray(config.areas) && config.areas.every((area) => typeof area === "string") @@ -87,7 +88,7 @@ function withAreas( } return ( - + Date: Mon, 10 Jun 2024 11:25:37 +0200 Subject: [PATCH 20/47] feat(sb): change the API endpoints to handle TableForm values indexed by scenario type --- antarest/study/web/study_data_blueprint.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index 39d2a732f0..4d8d76766d 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -676,7 +676,7 @@ def get_scenario_builder_config_by_type( uuid: str, scenario_type: ScenarioType, current_user: JWTUser = Depends(auth.get_current_user), - ) -> SBTableForm: + ) -> t.Dict[str, SBTableForm]: logger.info( f"Getting MC Scenario builder config for study {uuid} with scenario type filter: {scenario_type}", extra={"user": current_user.id}, @@ -684,7 +684,7 @@ def get_scenario_builder_config_by_type( params = RequestParameters(user=current_user) study = study_service.check_study_access(uuid, StudyPermissionType.READ, params) table_form = study_service.scenario_builder_manager.get_scenario_by_type(study, scenario_type) - return table_form + return {scenario_type: table_form} @bp.put( path="/studies/{uuid}/config/scenariobuilder", @@ -711,18 +711,19 @@ def update_scenario_builder_config( ) def update_scenario_builder_config_by_type( uuid: str, - data: SBTableForm, + data: t.Dict[str, SBTableForm], scenario_type: ScenarioType, current_user: JWTUser = Depends(auth.get_current_user), - ) -> SBTableForm: + ) -> t.Dict[str, SBTableForm]: logger.info( f"Updating MC Scenario builder config for study {uuid} with scenario type filter: {scenario_type}", extra={"user": current_user.id}, ) params = RequestParameters(user=current_user) study = study_service.check_study_access(uuid, StudyPermissionType.WRITE, params) - table_form = study_service.scenario_builder_manager.update_scenario_by_type(study, data, scenario_type) - return table_form + table_form = data[scenario_type] + table_form = study_service.scenario_builder_manager.update_scenario_by_type(study, table_form, scenario_type) + return {scenario_type: table_form} @bp.get( path="/studies/{uuid}/config/general/form", From 864b8d20d565861819a76118ba6c40dd03c1ae54 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Mon, 10 Jun 2024 15:21:06 +0200 Subject: [PATCH 21/47] feat(sb): correct the handling od NaN (or empty) values in SB --- .../business/scenario_builder_management.py | 2 +- .../filesystem/config/ruleset_matrices.py | 37 ++++++++++--------- .../model/command/update_scenario_builder.py | 3 +- .../config/test_ruleset_matrices.py | 32 ++++++++++++++-- 4 files changed, 52 insertions(+), 22 deletions(-) diff --git a/antarest/study/business/scenario_builder_management.py b/antarest/study/business/scenario_builder_management.py index 33950b5f91..e0e56d48b5 100644 --- a/antarest/study/business/scenario_builder_management.py +++ b/antarest/study/business/scenario_builder_management.py @@ -221,7 +221,7 @@ def update_scenario_by_type(self, study: Study, table_form: TableForm, scenario_ # Create the UpdateScenarioBuilder command ruleset_name = _get_active_ruleset_name(file_study) - data = {ruleset_name: ruleset.get_rules()} + data = {ruleset_name: ruleset.get_rules(allow_nan=True)} command_context = self.storage_service.variant_study_service.command_factory.command_context update_scenario = UpdateScenarioBuilder(data=data, command_context=command_context) execute_or_add_commands(study, file_study, [update_scenario], self.storage_service) diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/ruleset_matrices.py b/antarest/study/storage/rawstudy/model/filesystem/config/ruleset_matrices.py index c8663075ea..c23289ba07 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/config/ruleset_matrices.py +++ b/antarest/study/storage/rawstudy/model/filesystem/config/ruleset_matrices.py @@ -1,5 +1,6 @@ import typing as t +import numpy as np import pandas as pd import typing_extensions as te @@ -23,7 +24,7 @@ _Scenario: te.TypeAlias = t.Union[_SimpleScenario, _ClusterScenario] _ScenarioMapping: te.TypeAlias = t.MutableMapping[str, _Scenario] -SimpleTableForm: te.TypeAlias = t.Dict[str, t.Dict[str, t.Union[int, float, str]]] +SimpleTableForm: te.TypeAlias = t.Dict[str, t.Dict[str, t.Union[int, float, str, None]]] ClusterTableForm: te.TypeAlias = t.Dict[str, SimpleTableForm] TableForm: te.TypeAlias = t.Union[SimpleTableForm, ClusterTableForm] @@ -211,10 +212,13 @@ def update_rules(self, rules: t.Mapping[str, _Value]) -> None: else: raise NotImplementedError(f"Unknown symbol {symbol}") - def get_rules(self) -> t.Dict[str, _Value]: + def get_rules(self, *, allow_nan: bool = False) -> t.Dict[str, _Value]: """ Get the rules from the scenario matrices in INI format. + Args: + allow_nan: Allow NaN values if True. + Returns: Dictionary of rules with the following format @@ -230,17 +234,18 @@ def get_rules(self) -> t.Dict[str, _Value]: rules: t.Dict[str, _Value] = {} for symbol, scenario_type in self.scenario_types.items(): scenario = self.scenarios[scenario_type] - scenario_rules = self.get_scenario_rules(scenario, symbol) + scenario_rules = self.get_scenario_rules(scenario, symbol, allow_nan=allow_nan) rules.update(scenario_rules) return rules - def get_scenario_rules(self, scenario: _Scenario, symbol: str) -> t.Dict[str, _Value]: + def get_scenario_rules(self, scenario: _Scenario, symbol: str, *, allow_nan: bool = False) -> t.Dict[str, _Value]: """ Get the rules for a specific scenario matrix and symbol. Args: scenario: Matrix or dictionary of matrices. symbol: Rule symbol. + allow_nan: Allow NaN values if True. Returns: Dictionary of rules. @@ -250,21 +255,21 @@ def get_scenario_rules(self, scenario: _Scenario, symbol: str) -> t.Dict[str, _V f"{symbol},{area_id},{year}": value for area_id, area in self.areas.items() for year, value in scenario.loc[idx_area(area)].items() # type: ignore - if not pd.isna(value) + if allow_nan or not pd.isna(value) } elif symbol in _LINK_RELATED_SYMBOLS: scenario_rules = { f"{symbol},{area1_id},{area2_id},{year}": value for (area1_id, area2_id), (area1, area2) in self.links.items() for year, value in scenario.loc[idx_link(area1, area2)].items() # type: ignore - if not pd.isna(value) + if allow_nan or not pd.isna(value) } elif symbol in _HYDRO_LEVEL_RELATED_SYMBOLS: scenario_rules = { f"{symbol},{area_id},{year}": value / 100 for area_id, area in self.areas.items() for year, value in scenario.loc[idx_area(area)].items() # type: ignore - if not pd.isna(value) + if allow_nan or not pd.isna(value) } elif symbol in _CLUSTER_RELATED_SYMBOLS: clusters_mapping = self.clusters_by_symbols[symbol] @@ -273,14 +278,14 @@ def get_scenario_rules(self, scenario: _Scenario, symbol: str) -> t.Dict[str, _V for area_id, clusters in clusters_mapping.items() for cluster_id, cluster in clusters.items() for year, value in scenario[self.areas[area_id]].loc[idx_cluster(self.areas[area_id], cluster)].items() - if not pd.isna(value) + if allow_nan or not pd.isna(value) } elif symbol in _BINDING_CONSTRAINTS_RELATED_SYMBOLS: scenario_rules = { f"{symbol},{group_id},{year}": value for group_id, group in self.groups.items() for year, value in scenario.loc[idx_group(group)].items() # type: ignore - if not pd.isna(value) + if allow_nan or not pd.isna(value) } else: raise NotImplementedError(f"Unknown symbol {symbol}") @@ -350,11 +355,11 @@ def set_table_form( actual_scenario = self.scenarios[scenario_type] if isinstance(actual_scenario, pd.DataFrame): scenario = pd.DataFrame.from_dict(table_form, orient="index") - scenario = scenario.replace(nan_value, pd.NA) + scenario = scenario.replace([None, nan_value], np.nan) self.scenarios[scenario_type] = scenario else: self.scenarios[scenario_type] = { - area: pd.DataFrame.from_dict(df, orient="index").replace(nan_value, pd.NA) + area: pd.DataFrame.from_dict(df, orient="index").replace([None, nan_value], np.nan) for area, df in table_form.items() } @@ -367,16 +372,14 @@ def update_table_form(self, table_form: TableForm, scenario_type: str, *, nan_va scenario_type: Scenario type. nan_value: Value to replace NaNs. for instance: ``{"& psp x1": {"0": 10}}``. """ - - def to_nan(x: t.Union[int, float, str]) -> _Value: - return t.cast(_Value, pd.NA if x == nan_value else x) - scenario = self.scenarios[scenario_type] if isinstance(scenario, pd.DataFrame): simple_table_form = t.cast(SimpleTableForm, table_form) - scenario.update(pd.DataFrame(simple_table_form).transpose().applymap(to_nan)) + df = pd.DataFrame(simple_table_form).transpose().replace([None, nan_value], np.nan) + scenario.at[df.index, df.columns] = df else: cluster_table_form = t.cast(ClusterTableForm, table_form) for area, simple_table_form in cluster_table_form.items(): scenario = t.cast(pd.DataFrame, self.scenarios[scenario_type][area]) - scenario.update(pd.DataFrame(simple_table_form).transpose().applymap(to_nan)) + df = pd.DataFrame(simple_table_form).transpose().replace([None, nan_value], np.nan) + scenario.at[df.index, df.columns] = df diff --git a/antarest/study/storage/variantstudy/model/command/update_scenario_builder.py b/antarest/study/storage/variantstudy/model/command/update_scenario_builder.py index db6d2dd700..ff8e1311ac 100644 --- a/antarest/study/storage/variantstudy/model/command/update_scenario_builder.py +++ b/antarest/study/storage/variantstudy/model/command/update_scenario_builder.py @@ -1,5 +1,6 @@ import typing as t +import numpy as np from requests.structures import CaseInsensitiveDict from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfig @@ -62,7 +63,7 @@ def _apply(self, study_data: FileStudy) -> CommandOutput: if section: curr_section = curr_cfg.setdefault(section_name, {}) for key, value in section.items(): - if isinstance(value, (int, float)) and value != float("nan"): + if isinstance(value, (int, float)) and not np.isnan(value): curr_section[key] = value else: curr_section.pop(key, None) diff --git a/tests/storage/repository/filesystem/config/test_ruleset_matrices.py b/tests/storage/repository/filesystem/config/test_ruleset_matrices.py index 99a833bdbd..4364af6116 100644 --- a/tests/storage/repository/filesystem/config/test_ruleset_matrices.py +++ b/tests/storage/repository/filesystem/config/test_ruleset_matrices.py @@ -1,6 +1,9 @@ +import typing as t + +import numpy as np import pytest -from antarest.study.storage.rawstudy.model.filesystem.config.ruleset_matrices import RulesetMatrices +from antarest.study.storage.rawstudy.model.filesystem.config.ruleset_matrices import RulesetMatrices, TableForm SCENARIO_TYPES = { "l": "load", @@ -18,7 +21,7 @@ @pytest.fixture(name="ruleset") -def ruleset_fixture(): +def ruleset_fixture() -> RulesetMatrices: return RulesetMatrices( nb_years=4, areas=["France", "Germany", "Italy"], @@ -30,7 +33,7 @@ def ruleset_fixture(): ) -class TestRuleset: +class TestRulesetMatrices: def test_ruleset__init(self, ruleset: RulesetMatrices) -> None: assert ruleset.columns == ["0", "1", "2", "3"] assert ruleset.scenarios["load"].shape == (3, 4) @@ -531,3 +534,26 @@ def test_set_table_form(self, ruleset: RulesetMatrices) -> None: with pytest.raises(KeyError): ruleset.get_table_form("invalid") + + @pytest.mark.parametrize( + "table_form, expected", + [ + ({"France": {"0": 23}}, 23), + ({"France": {"0": None}}, np.nan), + ({"France": {"0": ""}}, np.nan), + ], + ) + @pytest.mark.parametrize("old_value", [12, None, np.nan, ""], ids=["int", "None", "NaN", "empty"]) + def test_update_table_form( + self, + ruleset: RulesetMatrices, + table_form: TableForm, + expected: float, + old_value: t.Union[int, float, str], + ) -> None: + ruleset.scenarios["load"].at["France", "0"] = old_value + ruleset.update_table_form(table_form, "load") + actual = ruleset.scenarios["load"].at["France", "0"] + assert np.isnan(expected) and np.isnan(actual) or expected == actual + actual_table_form = ruleset.get_table_form("load") + assert actual_table_form["France"]["0"] == ("" if np.isnan(expected) else expected) From 15d58192469a32e3046bebf26a7e2c5edced4b85 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Mon, 10 Jun 2024 17:16:17 +0200 Subject: [PATCH 22/47] feat(sb): add API endpoint documentation --- antarest/study/web/study_data_blueprint.py | 89 +++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index 4d8d76766d..e84029ff8f 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -671,12 +671,75 @@ def get_scenario_builder_config( path="/studies/{uuid}/config/scenariobuilder/{scenario_type}", tags=[APITag.study_data], summary="Get MC Scenario builder config", + response_model=t.Dict[str, SBTableForm], ) def get_scenario_builder_config_by_type( uuid: str, scenario_type: ScenarioType, current_user: JWTUser = Depends(auth.get_current_user), ) -> t.Dict[str, SBTableForm]: + """ + Retrieve the scenario matrix corresponding to a specified scenario type. + + The returned scenario matrix is structured as follows: + + ```json + { + "scenario_type": { + "area_id": { + "year": , + ... + }, + ... + }, + } + ``` + + For thermal and renewable scenarios, the format is: + + ```json + { + "scenario_type": { + "area_id": { + "cluster_id": { + "year": , + ... + }, + ... + }, + ... + }, + } + ``` + + For hydraulic levels scenarios, the format is: + + ```json + { + "scenario_type": { + "area_id": { + "year": , + ... + }, + ... + }, + } + ``` + + For binding constraints scenarios, the format is: + + ```json + { + "scenario_type": { + "group_name": { + "year": , + ... + }, + ... + }, + } + ``` + """ logger.info( f"Getting MC Scenario builder config for study {uuid} with scenario type filter: {scenario_type}", extra={"user": current_user.id}, @@ -708,13 +771,37 @@ def update_scenario_builder_config( path="/studies/{uuid}/config/scenariobuilder/{scenario_type}", tags=[APITag.study_data], summary="Set MC Scenario builder config", + response_model=t.Dict[str, SBTableForm], ) def update_scenario_builder_config_by_type( uuid: str, - data: t.Dict[str, SBTableForm], scenario_type: ScenarioType, + data: t.Dict[str, SBTableForm], current_user: JWTUser = Depends(auth.get_current_user), ) -> t.Dict[str, SBTableForm]: + """ + Update the scenario matrix corresponding to a specified scenario type. + + Args: + - `data`: partial scenario matrix using the following structure: + + ```json + { + "scenario_type": { + "area_id": { + "year": , + ... + }, + ... + }, + } + ``` + + > See the GET endpoint for the structure of the scenario matrix. + + Returns: + - The updated scenario matrix. + """ logger.info( f"Updating MC Scenario builder config for study {uuid} with scenario type filter: {scenario_type}", extra={"user": current_user.id}, From efa37ff596b6fdfe015c7957b62e99c871dede4c Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Tue, 11 Jun 2024 12:20:09 +0200 Subject: [PATCH 23/47] feat(sb): convert values to `int` or `float` (for hydro levels) prior to writing to INI file --- .../filesystem/config/ruleset_matrices.py | 28 ++++++--- .../config/test_ruleset_matrices.py | 60 ++++++++++++------- 2 files changed, 57 insertions(+), 31 deletions(-) diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/ruleset_matrices.py b/antarest/study/storage/rawstudy/model/filesystem/config/ruleset_matrices.py index c23289ba07..5e7e380fe0 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/config/ruleset_matrices.py +++ b/antarest/study/storage/rawstudy/model/filesystem/config/ruleset_matrices.py @@ -131,12 +131,13 @@ def _setup(self) -> None: group_index = self.get_group_index() link_index = self.get_link_index() for symbol, scenario_type in self.scenario_types.items(): + # Note: all DataFrames are initialized with NaN values, so the dtype is `float`. if symbol in _AREA_RELATED_SYMBOLS: - self.scenarios[scenario_type] = pd.DataFrame(index=area_index, columns=self.columns, dtype=int) + self.scenarios[scenario_type] = pd.DataFrame(index=area_index, columns=self.columns, dtype=float) elif symbol in _BINDING_CONSTRAINTS_RELATED_SYMBOLS: - self.scenarios[scenario_type] = pd.DataFrame(index=group_index, columns=self.columns, dtype=int) + self.scenarios[scenario_type] = pd.DataFrame(index=group_index, columns=self.columns, dtype=float) elif symbol in _LINK_RELATED_SYMBOLS: - self.scenarios[scenario_type] = pd.DataFrame(index=link_index, columns=self.columns, dtype=int) + self.scenarios[scenario_type] = pd.DataFrame(index=link_index, columns=self.columns, dtype=float) elif symbol in _HYDRO_LEVEL_RELATED_SYMBOLS: self.scenarios[scenario_type] = pd.DataFrame(index=area_index, columns=self.columns, dtype=float) elif symbol in _CLUSTER_RELATED_SYMBOLS: @@ -144,7 +145,7 @@ def _setup(self) -> None: # Keys are the names of the areas (and not the identifiers) self.scenarios[scenario_type] = { self.areas[area_id]: pd.DataFrame( - index=self.get_cluster_index(symbol, self.areas[area_id]), columns=self.columns, dtype=int + index=self.get_cluster_index(symbol, self.areas[area_id]), columns=self.columns, dtype=float ) for area_id, cluster in self.clusters_by_symbols[symbol].items() if cluster @@ -250,23 +251,32 @@ def get_scenario_rules(self, scenario: _Scenario, symbol: str, *, allow_nan: boo Returns: Dictionary of rules. """ + + def to_ts_number(v: t.Any) -> _Value: + """Convert value to TimeSeries number.""" + return np.nan if pd.isna(v) else int(v) + + def to_percent(v: t.Any) -> _Value: + """Convert value to percentage in range [0, 100].""" + return np.nan if pd.isna(v) else float(v) / 100 + if symbol in _AREA_RELATED_SYMBOLS: scenario_rules = { - f"{symbol},{area_id},{year}": value + f"{symbol},{area_id},{year}": to_ts_number(value) for area_id, area in self.areas.items() for year, value in scenario.loc[idx_area(area)].items() # type: ignore if allow_nan or not pd.isna(value) } elif symbol in _LINK_RELATED_SYMBOLS: scenario_rules = { - f"{symbol},{area1_id},{area2_id},{year}": value + f"{symbol},{area1_id},{area2_id},{year}": to_ts_number(value) for (area1_id, area2_id), (area1, area2) in self.links.items() for year, value in scenario.loc[idx_link(area1, area2)].items() # type: ignore if allow_nan or not pd.isna(value) } elif symbol in _HYDRO_LEVEL_RELATED_SYMBOLS: scenario_rules = { - f"{symbol},{area_id},{year}": value / 100 + f"{symbol},{area_id},{year}": to_percent(value) for area_id, area in self.areas.items() for year, value in scenario.loc[idx_area(area)].items() # type: ignore if allow_nan or not pd.isna(value) @@ -274,7 +284,7 @@ def get_scenario_rules(self, scenario: _Scenario, symbol: str, *, allow_nan: boo elif symbol in _CLUSTER_RELATED_SYMBOLS: clusters_mapping = self.clusters_by_symbols[symbol] scenario_rules = { - f"{symbol},{area_id},{year},{cluster_id}": value + f"{symbol},{area_id},{year},{cluster_id}": to_ts_number(value) for area_id, clusters in clusters_mapping.items() for cluster_id, cluster in clusters.items() for year, value in scenario[self.areas[area_id]].loc[idx_cluster(self.areas[area_id], cluster)].items() @@ -282,7 +292,7 @@ def get_scenario_rules(self, scenario: _Scenario, symbol: str, *, allow_nan: boo } elif symbol in _BINDING_CONSTRAINTS_RELATED_SYMBOLS: scenario_rules = { - f"{symbol},{group_id},{year}": value + f"{symbol},{group_id},{year}": to_ts_number(value) for group_id, group in self.groups.items() for year, value in scenario.loc[idx_group(group)].items() # type: ignore if allow_nan or not pd.isna(value) diff --git a/tests/storage/repository/filesystem/config/test_ruleset_matrices.py b/tests/storage/repository/filesystem/config/test_ruleset_matrices.py index 4364af6116..40651bce59 100644 --- a/tests/storage/repository/filesystem/config/test_ruleset_matrices.py +++ b/tests/storage/repository/filesystem/config/test_ruleset_matrices.py @@ -91,14 +91,16 @@ def test_update_rules__load(self, ruleset: RulesetMatrices, symbol: str, scenari actual = ruleset.scenarios[scenario_type] actual = actual.fillna("NaN").to_dict(orient="index") expected = { - "France": {"0": 1.0, "1": 4.0, "2": 7.0, "3": "NaN"}, - "Germany": {"0": 2.0, "1": 5.0, "2": 8.0, "3": "NaN"}, - "Italy": {"0": 3.0, "1": 6.0, "2": "NaN", "3": "NaN"}, + "France": {"0": 1, "1": 4, "2": 7, "3": "NaN"}, + "Germany": {"0": 2, "1": 5, "2": 8, "3": "NaN"}, + "Italy": {"0": 3, "1": 6, "2": "NaN", "3": "NaN"}, } assert actual == expected actual_rules = ruleset.get_rules() assert actual_rules == rules + for rule_id, ts_number in actual_rules.items(): + assert isinstance(ts_number, int) def test_update_rules__link(self, ruleset: RulesetMatrices) -> None: rules = { @@ -112,13 +114,15 @@ def test_update_rules__link(self, ruleset: RulesetMatrices) -> None: actual = ruleset.scenarios["link"] actual = actual.fillna("NaN").to_dict(orient="index") expected = { - "Germany / France": {"0": 1.0, "1": 3.0, "2": 5.0, "3": "NaN"}, - "Italy / France": {"0": 2.0, "1": 4.0, "2": "NaN", "3": "NaN"}, + "Germany / France": {"0": 1, "1": 3, "2": 5, "3": "NaN"}, + "Italy / France": {"0": 2, "1": 4, "2": "NaN", "3": "NaN"}, } assert actual == expected actual_rules = ruleset.get_rules() assert actual_rules == rules + for rule_id, ts_number in actual_rules.items(): + assert isinstance(ts_number, int) def test_update_rules__thermal(self, ruleset: RulesetMatrices) -> None: rules = { @@ -137,16 +141,16 @@ def test_update_rules__thermal(self, ruleset: RulesetMatrices) -> None: actual = actual_map["France"] actual = actual.fillna("NaN").to_dict(orient="index") expected = { - "coal": {"0": 2.0, "1": 6.0, "2": "NaN", "3": "NaN"}, - "nuclear": {"0": 1.0, "1": 5.0, "2": "NaN", "3": "NaN"}, + "coal": {"0": 2, "1": 6, "2": "NaN", "3": "NaN"}, + "nuclear": {"0": 1, "1": 5, "2": "NaN", "3": "NaN"}, } assert actual == expected actual = actual_map["Italy"] actual = actual.fillna("NaN").to_dict(orient="index") expected = { - "fuel": {"0": 4.0, "1": 8.0, "2": "NaN", "3": "NaN"}, - "nuclear": {"0": 3.0, "1": 7.0, "2": "NaN", "3": "NaN"}, + "fuel": {"0": 4, "1": 8, "2": "NaN", "3": "NaN"}, + "nuclear": {"0": 3, "1": 7, "2": "NaN", "3": "NaN"}, } assert actual == expected @@ -160,6 +164,8 @@ def test_update_rules__thermal(self, ruleset: RulesetMatrices) -> None: actual_rules = ruleset.get_rules() assert actual_rules == rules + for rule_id, ts_number in actual_rules.items(): + assert isinstance(ts_number, int) def test_update_rules__renewable(self, ruleset: RulesetMatrices) -> None: rules = { @@ -176,20 +182,22 @@ def test_update_rules__renewable(self, ruleset: RulesetMatrices) -> None: actual = actual_map["France"] actual = actual.fillna("NaN").to_dict(orient="index") expected = { - "wind offshore": {"0": 1.0, "1": 4.0, "2": "NaN", "3": "NaN"}, - "wind onshore": {"0": 2.0, "1": 5.0, "2": "NaN", "3": "NaN"}, + "wind offshore": {"0": 1, "1": 4, "2": "NaN", "3": "NaN"}, + "wind onshore": {"0": 2, "1": 5, "2": "NaN", "3": "NaN"}, } assert actual == expected actual = actual_map["Germany"] actual = actual.fillna("NaN").to_dict(orient="index") expected = { - "wind onshore": {"0": 3.0, "1": 6.0, "2": "NaN", "3": "NaN"}, + "wind onshore": {"0": 3, "1": 6, "2": "NaN", "3": "NaN"}, } assert actual == expected actual_rules = ruleset.get_rules() assert actual_rules == rules + for rule_id, ts_number in actual_rules.items(): + assert isinstance(ts_number, int) def test_update_rules__hydro(self, ruleset: RulesetMatrices) -> None: rules = { @@ -204,14 +212,16 @@ def test_update_rules__hydro(self, ruleset: RulesetMatrices) -> None: actual = ruleset.scenarios["hydro"] actual = actual.fillna("NaN").to_dict(orient="index") expected = { - "France": {"0": 1.0, "1": 4.0, "2": "NaN", "3": "NaN"}, - "Germany": {"0": 2.0, "1": 5.0, "2": "NaN", "3": "NaN"}, - "Italy": {"0": 3.0, "1": 6.0, "2": "NaN", "3": "NaN"}, + "France": {"0": 1, "1": 4, "2": "NaN", "3": "NaN"}, + "Germany": {"0": 2, "1": 5, "2": "NaN", "3": "NaN"}, + "Italy": {"0": 3, "1": 6, "2": "NaN", "3": "NaN"}, } assert actual == expected actual_rules = ruleset.get_rules() assert actual_rules == rules + for rule_id, ts_number in actual_rules.items(): + assert isinstance(ts_number, int) def test_update_rules__hydro_generation_power(self, ruleset: RulesetMatrices) -> None: rules = { @@ -226,14 +236,16 @@ def test_update_rules__hydro_generation_power(self, ruleset: RulesetMatrices) -> actual = ruleset.scenarios["hydroGenerationPower"] actual = actual.fillna("NaN").to_dict(orient="index") expected = { - "France": {"0": 1.0, "1": 4.0, "2": "NaN", "3": "NaN"}, - "Germany": {"0": 2.0, "1": 5.0, "2": "NaN", "3": "NaN"}, - "Italy": {"0": 3.0, "1": 6.0, "2": "NaN", "3": "NaN"}, + "France": {"0": 1, "1": 4, "2": "NaN", "3": "NaN"}, + "Germany": {"0": 2, "1": 5, "2": "NaN", "3": "NaN"}, + "Italy": {"0": 3, "1": 6, "2": "NaN", "3": "NaN"}, } assert actual == expected actual_rules = ruleset.get_rules() assert actual_rules == rules + for rule_id, ts_number in actual_rules.items(): + assert isinstance(ts_number, int) def test_update_rules__binding_constraints(self, ruleset: RulesetMatrices) -> None: rules = { @@ -247,20 +259,22 @@ def test_update_rules__binding_constraints(self, ruleset: RulesetMatrices) -> No actual = ruleset.scenarios["bindingConstraints"] actual = actual.fillna("NaN").to_dict(orient="index") expected = { - "Main": {"0": 1.0, "1": 3.0, "2": 5.0, "3": "NaN"}, - "Secondary": {"0": 2.0, "1": 4.0, "2": "NaN", "3": "NaN"}, + "Main": {"0": 1, "1": 3, "2": 5, "3": "NaN"}, + "Secondary": {"0": 2, "1": 4, "2": "NaN", "3": "NaN"}, } assert actual == expected actual_rules = ruleset.get_rules() assert actual_rules == rules + for rule_id, ts_number in actual_rules.items(): + assert isinstance(ts_number, int) def test_update_rules__hydro_initial_levels(self, ruleset: RulesetMatrices) -> None: rules = { "hl,france,0": 0.1, "hl,germany,0": 0.2, "hl,italy,0": 0.3, - "hl,france,1": 0.4, + "hl,france,1": 0.4537, "hl,germany,1": 0.5, "hl,italy,1": 0.6, } @@ -268,7 +282,7 @@ def test_update_rules__hydro_initial_levels(self, ruleset: RulesetMatrices) -> N actual = ruleset.scenarios["hydroInitialLevels"] actual = actual.fillna("NaN").to_dict(orient="index") expected = { - "France": {"0": 10, "1": 40, "2": "NaN", "3": "NaN"}, + "France": {"0": 10, "1": 45.37, "2": "NaN", "3": "NaN"}, "Germany": {"0": 20, "1": 50, "2": "NaN", "3": "NaN"}, "Italy": {"0": 30, "1": 60, "2": "NaN", "3": "NaN"}, } @@ -276,6 +290,8 @@ def test_update_rules__hydro_initial_levels(self, ruleset: RulesetMatrices) -> N actual_rules = ruleset.get_rules() assert actual_rules == rules + for rule_id, ts_number in actual_rules.items(): + assert isinstance(ts_number, float) def test_update_rules__hydro_final_levels(self, ruleset: RulesetMatrices) -> None: rules = { From 5a277f54f2611b384d29093c938c8cb268703182 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Tue, 11 Jun 2024 13:31:10 +0200 Subject: [PATCH 24/47] feat(sb): avoid returning Binding Constraints data for study prior to v8.7 --- antarest/study/business/scenario_builder_management.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/antarest/study/business/scenario_builder_management.py b/antarest/study/business/scenario_builder_management.py index e0e56d48b5..0f7c94f7bb 100644 --- a/antarest/study/business/scenario_builder_management.py +++ b/antarest/study/business/scenario_builder_management.py @@ -132,6 +132,7 @@ def _build_ruleset(file_study: FileStudy, symbol: str = "") -> RulesetMatrices: # Create and populate the RulesetMatrices areas = file_study.config.areas + groups = file_study.config.get_binding_constraint_groups() if file_study.config.version >= 870 else [] scenario_types = {s: str(st) for st, s in SYMBOLS_BY_SCENARIO_TYPES.items()} ruleset = RulesetMatrices( nb_years=nb_years, @@ -139,7 +140,7 @@ def _build_ruleset(file_study: FileStudy, symbol: str = "") -> RulesetMatrices: links=((a1, a2) for a1 in areas for a2 in file_study.config.get_links(a1)), thermals={a: file_study.config.get_thermal_ids(a) for a in areas}, renewables={a: file_study.config.get_renewable_ids(a) for a in areas}, - groups=file_study.config.get_binding_constraint_groups(), + groups=groups, scenario_types=scenario_types, ) ruleset.update_rules(ruleset_config) From 8d324a2e6c067217f089d47f11595657166bcad2 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Tue, 11 Jun 2024 13:38:24 +0200 Subject: [PATCH 25/47] chore: correct bad unit test --- tests/integration/study_data_blueprint/test_table_mode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/study_data_blueprint/test_table_mode.py b/tests/integration/study_data_blueprint/test_table_mode.py index d61289041f..3ffbeb2b46 100644 --- a/tests/integration/study_data_blueprint/test_table_mode.py +++ b/tests/integration/study_data_blueprint/test_table_mode.py @@ -45,7 +45,7 @@ def test_lifecycle__nominal( assert task.status == TaskStatus.COMPLETED, task # Create another link to test specific bug. - res = client.post(f"/v1/studies/{study_id}/links", json={"area1": "de", "area2": "it"}, headers=user_headers) + res = client.post(f"/v1/studies/{study_id}/links", json={"area1": "de", "area2": "it"}) assert res.status_code in [200, 201], res.json() # Table Mode - Area From 8b9dd8643d5157a1318276d2ca0d95d5a5f95786 Mon Sep 17 00:00:00 2001 From: belthlemar Date: Mon, 10 Jun 2024 13:58:44 +0200 Subject: [PATCH 26/47] feat(parameters): add MILP option in unit commitment mode --- .../advanced_parameters_management.py | 10 ++ .../test_advanced_parameters.py | 95 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 tests/integration/study_data_blueprint/test_advanced_parameters.py diff --git a/antarest/study/business/advanced_parameters_management.py b/antarest/study/business/advanced_parameters_management.py index f18e47a71f..4801d5487a 100644 --- a/antarest/study/business/advanced_parameters_management.py +++ b/antarest/study/business/advanced_parameters_management.py @@ -4,6 +4,7 @@ from pydantic import validator from pydantic.types import StrictInt, StrictStr +from antarest.core.exceptions import InvalidFieldForVersionError from antarest.study.business.enum_ignore_case import EnumIgnoreCase from antarest.study.business.utils import GENERAL_DATA_PATH, FieldInfo, FormFieldsBaseModel, execute_or_add_commands from antarest.study.model import Study @@ -44,6 +45,7 @@ class ReserveManagement(EnumIgnoreCase): class UnitCommitmentMode(EnumIgnoreCase): FAST = "fast" ACCURATE = "accurate" + MILP = "milp" class SimulationCore(EnumIgnoreCase): @@ -236,6 +238,14 @@ def set_field_values(self, study: Study, field_values: AdvancedParamsFormFields) if value is not None: info = FIELDS_INFO[field_name] + # Handle the specific case of `milp` value that appeared in v8.8 + if ( + field_name == "unit_commitment_mode" + and value == UnitCommitmentMode.MILP + and int(study.version) < 880 + ): + raise InvalidFieldForVersionError("Unit commitment mode `MILP` only exists in v8.8+ studies") + commands.append( UpdateConfig( target=info["path"], diff --git a/tests/integration/study_data_blueprint/test_advanced_parameters.py b/tests/integration/study_data_blueprint/test_advanced_parameters.py new file mode 100644 index 0000000000..b126349335 --- /dev/null +++ b/tests/integration/study_data_blueprint/test_advanced_parameters.py @@ -0,0 +1,95 @@ +from http import HTTPStatus + +import pytest +from starlette.testclient import TestClient + +from antarest.core.tasks.model import TaskStatus +from tests.integration.utils import wait_task_completion + + +class TestAdvancedParametersForm: + """ + Test the end points related to advanced parameters. + + Those tests use the "examples/studies/STA-mini.zip" Study, + which contains the following areas: ["de", "es", "fr", "it"]. + """ + + def test_get_advanced_parameters_values( + self, + client: TestClient, + user_access_token: str, + study_id: str, + ): + """Check `get_advanced_parameters_form_values` end point""" + res = client.get( + f"/v1/studies/{study_id}/config/advancedparameters/form", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == HTTPStatus.OK, res.json() + actual = res.json() + expected = { + "accuracyOnCorrelation": "", + "dayAheadReserveManagement": "global", + "hydroHeuristicPolicy": "accommodate rule curves", + "hydroPricingMode": "fast", + "initialReservoirLevels": "cold start", + "numberOfCoresMode": "maximum", + "powerFluctuations": "free modulations", + "renewableGenerationModelling": "clusters", + "seedHydroCosts": 9005489, + "seedInitialReservoirLevels": 10005489, + "seedSpilledEnergyCosts": 7005489, + "seedThermalCosts": 8005489, + "seedTsgenHydro": 2005489, + "seedTsgenLoad": 1005489, + "seedTsgenSolar": 4005489, + "seedTsgenThermal": 3005489, + "seedTsgenWind": 5489, + "seedTsnumbers": 5005489, + "seedUnsuppliedEnergyCosts": 6005489, + "sheddingPolicy": "shave peaks", + "unitCommitmentMode": "fast", + } + assert actual == expected + + @pytest.mark.parametrize("study_version", [0, 880]) + def test_set_advanced_parameters_values( + self, client: TestClient, user_access_token: str, study_id: str, study_version: int + ): + """Check `set_advanced_parameters_values` end point""" + obj = {"initialReservoirLevels": "hot start"} + res = client.put( + f"/v1/studies/{study_id}/config/advancedparameters/form", + headers={"Authorization": f"Bearer {user_access_token}"}, + json=obj, + ) + assert res.status_code == HTTPStatus.OK, res.json() + actual = res.json() + assert actual is None + + if study_version: + res = client.put( + f"/v1/studies/{study_id}/upgrade", + headers={"Authorization": f"Bearer {user_access_token}"}, + params={"target_version": study_version}, + ) + assert res.status_code == 200, res.json() + + task_id = res.json() + task = wait_task_completion(client, user_access_token, task_id) + assert task.status == TaskStatus.COMPLETED, task + + obj = {"unitCommitmentMode": "milp"} + res = client.put( + f"/v1/studies/{study_id}/config/advancedparameters/form", + headers={"Authorization": f"Bearer {user_access_token}"}, + json=obj, + ) + if study_version: + assert res.status_code == HTTPStatus.OK, res.json() + else: + assert res.status_code == 422 + response = res.json() + assert response["exception"] == "InvalidFieldForVersionError" + assert response["description"] == "Unit commitment mode `MILP` only exists in v8.8+ studies" From 7da2e24f29ed705ed8fbfb930b4efb57bee67b12 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Mon, 17 Jun 2024 16:43:12 +0200 Subject: [PATCH 27/47] refactor(ui-config): enhance submit and API functions for 'Advanced Parameters' --- .../AdvancedParameters/index.tsx | 36 ++++++------- .../Configuration/AdvancedParameters/utils.ts | 50 +++++++++++++------ 2 files changed, 52 insertions(+), 34 deletions(-) diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/index.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/index.tsx index 6b696aec84..3182bb1e56 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/index.tsx @@ -19,28 +19,23 @@ function AdvancedParameters() { // Event Handlers //////////////////////////////////////////////////////////////// - const handleSubmit = async ( - data: SubmitHandlerPlus, - ) => { - const values = { ...data.dirtyValues }; + const handleSubmit = ({ + dirtyValues, + }: SubmitHandlerPlus) => { + return setAdvancedParamsFormFields(study.id, dirtyValues); + }; - // Get a comma separated string from accuracyOnCorrelation array as expected by the api - if (values.accuracyOnCorrelation) { - values.accuracyOnCorrelation = ( - values.accuracyOnCorrelation as unknown as string[] - ).join(", "); + const handleSubmitSuccessful = ({ + dirtyValues: { renewableGenerationModelling }, + }: SubmitHandlerPlus) => { + if (renewableGenerationModelling) { + dispatch( + updateStudySynthesis({ + id: study.id, + changes: { enr_modelling: renewableGenerationModelling }, + }), + ); } - - return setAdvancedParamsFormFields(study.id, values).then(() => { - if (values.renewableGenerationModelling) { - dispatch( - updateStudySynthesis({ - id: study.id, - changes: { enr_modelling: values.renewableGenerationModelling }, - }), - ); - } - }); }; //////////////////////////////////////////////////////////////// @@ -54,6 +49,7 @@ function AdvancedParameters() { defaultValues: () => getAdvancedParamsFormFields(study.id), }} onSubmit={handleSubmit} + onSubmitSuccessful={handleSubmitSuccessful} enableUndoRedo > diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/utils.ts b/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/utils.ts index 551cb4a083..71789b0c7b 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/utils.ts @@ -1,3 +1,4 @@ +import { DeepPartial } from "react-hook-form"; import { StudyMetadata } from "../../../../../../common/types"; import client from "../../../../../../services/api/client"; @@ -82,7 +83,7 @@ export const RENEWABLE_GENERATION_OPTIONS = Object.values( //////////////////////////////////////////////////////////////// export interface AdvancedParamsFormFields { - accuracyOnCorrelation: string; + accuracyOnCorrelation: string[]; dayAheadReserveManagement: string; hydroHeuristicPolicy: string; hydroPricingMode: string; @@ -105,26 +106,47 @@ export interface AdvancedParamsFormFields { unitCommitmentMode: string; } +type AdvancedParamsFormFields_RAW = Omit< + AdvancedParamsFormFields, + "accuracyOnCorrelation" +> & { + accuracyOnCorrelation: string; +}; + +//////////////////////////////////////////////////////////////// +// API +//////////////////////////////////////////////////////////////// + function makeRequestURL(studyId: StudyMetadata["id"]): string { return `v1/studies/${studyId}/config/advancedparameters/form`; } export async function getAdvancedParamsFormFields( studyId: StudyMetadata["id"], -): Promise { - const res = await client.get(makeRequestURL(studyId)); - - // Get array of values from accuracyOnCorrelation string as expected for the SelectFE component - const accuracyOnCorrelation = res.data.accuracyOnCorrelation - .split(/\s*,\s*/) - .filter((v: string) => v.trim()); - - return { ...res.data, accuracyOnCorrelation }; +) { + const { data } = await client.get( + makeRequestURL(studyId), + ); + + return { + ...data, + accuracyOnCorrelation: data.accuracyOnCorrelation + .split(",") + .map((v) => v.trim()) + .filter(Boolean), + } as AdvancedParamsFormFields; } -export function setAdvancedParamsFormFields( +export async function setAdvancedParamsFormFields( studyId: StudyMetadata["id"], - values: Partial, -): Promise { - return client.put(makeRequestURL(studyId), values); + values: DeepPartial, +) { + const { accuracyOnCorrelation, ...rest } = values; + const newValues: Partial = rest; + + if (accuracyOnCorrelation) { + newValues.accuracyOnCorrelation = accuracyOnCorrelation.join(", "); + } + + await client.put(makeRequestURL(studyId), newValues); } From 3c56c0bc39f2d6cc97ffd8a053d9c57931f029ae Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Mon, 17 Jun 2024 17:05:38 +0200 Subject: [PATCH 28/47] refactor(ui-commons): update `options` type in SelectFE --- webapp/src/components/common/fieldEditors/SelectFE.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/components/common/fieldEditors/SelectFE.tsx b/webapp/src/components/common/fieldEditors/SelectFE.tsx index 0eabb24884..938f94f499 100644 --- a/webapp/src/components/common/fieldEditors/SelectFE.tsx +++ b/webapp/src/components/common/fieldEditors/SelectFE.tsx @@ -19,7 +19,7 @@ type OptionObj = { } & T; export interface SelectFEProps extends Omit { - options: string[] | readonly string[] | OptionObj[]; + options: Array | readonly string[]; helperText?: React.ReactNode; emptyValue?: boolean; startCaseLabel?: boolean; From 2e64e56267d3b1804ad88dec63109f1dd1c39626 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Mon, 17 Jun 2024 17:07:24 +0200 Subject: [PATCH 29/47] feat(ui-config): add 'MILP' value option in 'Unit Commitment Mode' field in 'Advanced Parameters' for studies v8.8 or above' --- .../AdvancedParameters/Fields.tsx | 20 +++++++++++-------- .../AdvancedParameters/index.tsx | 2 +- .../Configuration/AdvancedParameters/utils.ts | 4 +++- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/Fields.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/Fields.tsx index 96d313437d..8c2482b7f2 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/Fields.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/Fields.tsx @@ -16,16 +16,16 @@ import { UNIT_COMMITMENT_MODE_OPTIONS, SIMULATION_CORES_OPTIONS, RENEWABLE_GENERATION_OPTIONS, + UnitCommitmentMode, } from "./utils"; +import { useOutletContext } from "react-router"; +import { StudyMetadata } from "../../../../../../common/types"; -interface Props { - version: number; -} - -function Fields(props: Props) { +function Fields() { const [t] = useTranslation(); const { control } = useFormContextPlus(); - const { version } = props; + const { study } = useOutletContext<{ study: StudyMetadata }>(); + const studyVersion = Number(study.version); //////////////////////////////////////////////////////////////// // JSX @@ -178,7 +178,11 @@ function Fields(props: Props) { /> v !== UnitCommitmentMode.MILP || studyVersion >= 880, + ).map((v) => + v === UnitCommitmentMode.MILP ? { label: "MILP", value: v } : v, + )} name="unitCommitmentMode" control={control} /> @@ -188,7 +192,7 @@ function Fields(props: Props) { name="numberOfCoresMode" control={control} /> - {version >= 810 && ( + {studyVersion >= 810 && ( - + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/utils.ts b/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/utils.ts index 71789b0c7b..4a8b69e918 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/utils.ts @@ -42,9 +42,11 @@ enum ReserveManagement { Global = "global", } -enum UnitCommitmentMode { +export enum UnitCommitmentMode { Fast = "fast", Accurate = "accurate", + // Since v8.8 + MILP = "milp", } enum SimulationCore { From b2f59dfb42e1f02896fa31a16c1ff5dc29ca2192 Mon Sep 17 00:00:00 2001 From: MartinBelthle Date: Mon, 17 Jun 2024 23:52:40 +0200 Subject: [PATCH 30/47] ci(worker): deploy AntaresWebWorker on its own (#2066) Fix [ANT-1821] --- .github/workflows/commitlint.yml | 2 +- .github/workflows/compatibility.yml | 6 ++-- .github/workflows/deploy.yml | 7 ++--- .github/workflows/main.yml | 14 ++++----- .github/workflows/worker.yml | 37 ++++++++++++++++++++++++ AntaresWebWorker.spec | 45 +++++++++++++++++++++++++++++ scripts/package_antares_web.sh | 2 +- scripts/package_worker.sh | 17 +++++++++++ 8 files changed, 114 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/worker.yml create mode 100644 AntaresWebWorker.spec create mode 100644 scripts/package_worker.sh diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 8e08ce865c..89b899c5a3 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -9,5 +9,5 @@ jobs: commitlint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: wagoid/commitlint-github-action@v5 diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index 5603c5ffa2..25d2506003 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -15,9 +15,9 @@ jobs: steps: - name: Checkout github repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -37,7 +37,7 @@ jobs: node-version: [18.16.1] steps: - name: Checkout github repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 with: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 50e9abf4fe..a5980f135f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,4 +1,3 @@ -#file: noinspection YAMLSchemaValidation name: deploy on: push: @@ -16,7 +15,7 @@ jobs: steps: - name: 🐙 Checkout GitHub repo (+ download lfs dependencies) - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 @@ -40,7 +39,7 @@ jobs: NODE_OPTIONS: --max-old-space-size=8192 - name: 🐍 Set up Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: python-version: 3.8 @@ -78,7 +77,7 @@ jobs: working-directory: dist/package - name: 🚀 Upload binaries - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: AntaresWeb-${{ matrix.os }}-pkg path: dist/package/AntaresWeb.zip diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fe19702948..90c818eacc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,9 +9,9 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Checkout github repo (+ download lfs dependencies) - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: 3.8 - name: Install dependencies @@ -42,9 +42,9 @@ jobs: steps: - name: Checkout github repo (+ download lfs dependencies) - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: 3.8 - name: Install dependencies @@ -60,7 +60,7 @@ jobs: sed -i 's/\/home\/runner\/work\/AntaREST\/AntaREST/\/github\/workspace/g' coverage.xml - name: Archive code coverage results if: matrix.os == 'ubuntu-20.04' - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: python-code-coverage-report path: coverage.xml @@ -72,7 +72,7 @@ jobs: os: [ ubuntu-20.04 ] steps: - name: Checkout github repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 with: @@ -94,7 +94,7 @@ jobs: runs-on: ubuntu-20.04 needs: [ python-test, npm-test ] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Download python coverage report uses: actions/download-artifact@v3 with: diff --git a/.github/workflows/worker.yml b/.github/workflows/worker.yml new file mode 100644 index 0000000000..4839670d62 --- /dev/null +++ b/.github/workflows/worker.yml @@ -0,0 +1,37 @@ +name: worker +on: + push: + branches: + - "master" + - "worker/**" + +jobs: + binary: + runs-on: windows-latest + + steps: + - name: 🐙 Checkout GitHub repo (+ download lfs dependencies) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 🐍 Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.8 + + - name: 🐍 Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller==5.6.2 + + - name: 📦 Packaging AntaresWebWorker + run: bash ./package_worker.sh + working-directory: scripts + + - name: 🚀 Upload binary + uses: actions/upload-artifact@v4 + with: + name: AntaresWebWorker + path: dist/AntaresWebWorker.exe diff --git a/AntaresWebWorker.spec b/AntaresWebWorker.spec new file mode 100644 index 0000000000..8eeb1c1988 --- /dev/null +++ b/AntaresWebWorker.spec @@ -0,0 +1,45 @@ +# -*- mode: python ; coding: utf-8 -*- + +block_cipher = None + +a = Analysis( + ["antarest/worker/archive_worker_service.py"], + pathex=[], + binaries=[], + datas=[("resources", "resources")], + hiddenimports=["pythonjsonlogger.jsonlogger"], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) +pyz = PYZ( + a.pure, + a.zipped_data, + cipher=block_cipher, +) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name="AntaresWebWorker", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) \ No newline at end of file diff --git a/scripts/package_antares_web.sh b/scripts/package_antares_web.sh index 98d9bc2db7..9a0efb5e15 100755 --- a/scripts/package_antares_web.sh +++ b/scripts/package_antares_web.sh @@ -3,7 +3,7 @@ # Antares Web Packaging -- Desktop Version # # This script is launch by the GitHub Workflow `.github/workflows/deploy.yml`. -# It builds the Desktop version of the Web Application and the Worker Application. +# It builds the Desktop version of the Web Application. # Make sure you run the `npm install` stage before running this script. set -e diff --git a/scripts/package_worker.sh b/scripts/package_worker.sh new file mode 100644 index 0000000000..686d131621 --- /dev/null +++ b/scripts/package_worker.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# AntaresWebWorker packaging +# +# This script is launch by the GitHub Workflow `.github/workflows/worker.yml`. +# It builds the AntaresWebWorker. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P) +PROJECT_DIR=$(dirname -- "${SCRIPT_DIR}") +DIST_DIR="${PROJECT_DIR}/dist" + +echo "INFO: Generating the Worker Application..." +pushd ${PROJECT_DIR} +pyinstaller --distpath ${DIST_DIR} AntaresWebWorker.spec +popd + +chmod +x "${DIST_DIR}/AntaresWebWorker" From 004a3a6a6426290370f668170653a1229ac8adf0 Mon Sep 17 00:00:00 2001 From: MartinBelthle Date: Mon, 17 Jun 2024 23:54:51 +0200 Subject: [PATCH 31/47] feat(outputs): remove useless folder `updated_links` (#2065) --- .../launcher/adapters/slurm_launcher/slurm_launcher.py | 10 +--------- tests/launcher/test_slurm_launcher.py | 8 -------- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/antarest/launcher/adapters/slurm_launcher/slurm_launcher.py b/antarest/launcher/adapters/slurm_launcher/slurm_launcher.py index 714ce6d0e5..92e2755408 100644 --- a/antarest/launcher/adapters/slurm_launcher/slurm_launcher.py +++ b/antarest/launcher/adapters/slurm_launcher/slurm_launcher.py @@ -285,7 +285,7 @@ def _import_study_output( xpansion_mode: t.Optional[str] = None, log_dir: t.Optional[str] = None, ) -> t.Optional[str]: - if xpansion_mode is not None: + if xpansion_mode: self._import_xpansion_result(job_id, xpansion_mode) launcher_logs: t.Dict[str, t.List[Path]] = {} @@ -331,14 +331,6 @@ def _import_xpansion_result(self, job_id: str, xpansion_mode: str) -> None: ) output_path = unzipped_output_path - if (output_path / "updated_links").exists(): - logger.warning("Skipping updated links") - self.callbacks.append_after_log(job_id, "Skipping updated links") - else: - shutil.copytree( - self.local_workspace / STUDIES_OUTPUT_DIR_NAME / job_id / "input" / "links", - output_path / "updated_links", - ) if xpansion_mode == "r": shutil.copytree( self.local_workspace / STUDIES_OUTPUT_DIR_NAME / job_id / "user" / "expansion", diff --git a/tests/launcher/test_slurm_launcher.py b/tests/launcher/test_slurm_launcher.py index 9476f86be8..a509342837 100644 --- a/tests/launcher/test_slurm_launcher.py +++ b/tests/launcher/test_slurm_launcher.py @@ -1,6 +1,5 @@ import os import random -import shutil import textwrap import uuid from argparse import Namespace @@ -400,13 +399,6 @@ def test_import_study_output(launcher_config, tmp_path) -> None: xpansion_test_file.write_text("world") output_dir = launcher_config.launcher.slurm.local_workspace / "OUTPUT" / "1" / "output" / "output_name" output_dir.mkdir(parents=True) - assert not (output_dir / "updated_links" / "something").exists() - assert not (output_dir / "updated_links" / "something").exists() - - slurm_launcher._import_study_output("1", "cpp") - assert (output_dir / "updated_links" / "something").exists() - assert (output_dir / "updated_links" / "something").read_text() == "hello" - shutil.rmtree(output_dir / "updated_links") slurm_launcher._import_study_output("1", "r") assert (output_dir / "results" / "something_else").exists() From 5e52ce5089ea18eb905d3fce000d5f4614c79368 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Tue, 18 Jun 2024 09:19:02 +0200 Subject: [PATCH 32/47] feat(ui-commons): make footer static in Form component --- webapp/src/components/common/Form/index.tsx | 137 ++++++++++++-------- 1 file changed, 81 insertions(+), 56 deletions(-) diff --git a/webapp/src/components/common/Form/index.tsx b/webapp/src/components/common/Form/index.tsx index 6d10b60cce..225063b720 100644 --- a/webapp/src/components/common/Form/index.tsx +++ b/webapp/src/components/common/Form/index.tsx @@ -45,6 +45,7 @@ import { SubmitHandlerPlus, UseFormReturnPlus } from "./types"; import FormContext from "./FormContext"; import useFormApiPlus from "./useFormApiPlus"; import useFormUndoRedo from "./useFormUndoRedo"; +import { mergeSxProp } from "../../../utils/muiUtils"; export interface AutoSubmitConfig { enable: boolean; @@ -76,6 +77,7 @@ export interface FormProps< submitButtonIcon?: LoadingButtonProps["startIcon"]; miniSubmitButton?: boolean; hideSubmitButton?: boolean; + hideFooterDivider?: boolean; onStateChange?: (state: FormState) => void; autoSubmit?: boolean | AutoSubmitConfig; enableUndoRedo?: boolean; @@ -100,6 +102,7 @@ function Form( submitButtonIcon = , miniSubmitButton, hideSubmitButton, + hideFooterDivider, onStateChange, autoSubmit, enableUndoRedo, @@ -154,9 +157,9 @@ function Form( // Don't add `isValid` because we need to trigger fields validation. // In case we have invalid default value for example. const isSubmitAllowed = isDirty && !isSubmitting; - const showSubmitButton = !hideSubmitButton && !autoSubmitConfig.enable; - const showFooter = showSubmitButton || enableUndoRedo; const rootError = errors.root?.[ROOT_ERROR_KEY]; + const showSubmitButton = !hideSubmitButton && !autoSubmitConfig.enable; + const showFooter = showSubmitButton || enableUndoRedo || rootError; const formApiPlus = useFormApiPlus({ formApi, @@ -323,13 +326,22 @@ function Form( return ( {showAutoSubmitLoader && ( ( height: 0, textAlign: "right", }} - className="Form__Loader" > )} - - {RA.isFunction(children) ? ( - children(formApiPlus) - ) : ( - {children} - )} - - {rootError && ( - - {rootError.message || t("form.submit.error")} - - )} - {showFooter && ( - - {showSubmitButton && ( - <> - - {enableUndoRedo && ( - - )} - + + + {RA.isFunction(children) ? ( + children(formApiPlus) + ) : ( + {children} )} - {enableUndoRedo && ( - <> - - - - - - - - - - - - - - - + + + {showFooter && ( + + {!hideFooterDivider && } + {rootError && ( + + {rootError.message || t("form.submit.error")} + )} + + {showSubmitButton && ( + <> + + {enableUndoRedo && ( + + )} + + )} + {enableUndoRedo && ( + <> + + + + + + + + + + + + + + + + )} + )} From ade31ed578d48946431a2af954b707d3c884441a Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Tue, 18 Jun 2024 09:18:35 +0200 Subject: [PATCH 33/47] feat(ui): update LoginWrapper style --- webapp/src/components/wrappers/LoginWrapper.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webapp/src/components/wrappers/LoginWrapper.tsx b/webapp/src/components/wrappers/LoginWrapper.tsx index 9d5dee2ada..de64f4ddcd 100644 --- a/webapp/src/components/wrappers/LoginWrapper.tsx +++ b/webapp/src/components/wrappers/LoginWrapper.tsx @@ -142,8 +142,9 @@ function LoginWrapper(props: Props) { onSubmit={handleSubmit} submitButtonText={t("global.connexion")} submitButtonIcon={} + hideFooterDivider sx={{ - ".Form__Footer": { + ".Form__Footer__Actions": { justifyContent: "center", }, }} From c64cf9ef136da7444dd9d6b6e7e738d190427433 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Tue, 18 Jun 2024 09:18:52 +0200 Subject: [PATCH 34/47] feat(ui-commons): create ViewWrapper component --- .../components/common/page/ViewWrapper.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 webapp/src/components/common/page/ViewWrapper.tsx diff --git a/webapp/src/components/common/page/ViewWrapper.tsx b/webapp/src/components/common/page/ViewWrapper.tsx new file mode 100644 index 0000000000..f0800405e8 --- /dev/null +++ b/webapp/src/components/common/page/ViewWrapper.tsx @@ -0,0 +1,25 @@ +import { Paper } from "@mui/material"; + +export interface ViewWrapperProps { + children: React.ReactNode; +} + +function ViewWrapper({ children }: ViewWrapperProps) { + return ( + + {children} + + ); +} + +export default ViewWrapper; From c4a51b83e5e65b1c2eb70cfd8f3bbe5a4d092d5d Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Mon, 3 Jun 2024 14:35:47 +0200 Subject: [PATCH 35/47] feat(ui-commons): update TabsView component --- webapp/src/components/common/TabsView.tsx | 61 ++++++++++++----------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/webapp/src/components/common/TabsView.tsx b/webapp/src/components/common/TabsView.tsx index 24b3b5496b..5c96248c8d 100644 --- a/webapp/src/components/common/TabsView.tsx +++ b/webapp/src/components/common/TabsView.tsx @@ -1,17 +1,17 @@ import { TabContext, TabList, TabListProps, TabPanel } from "@mui/lab"; -import { Tab } from "@mui/material"; +import { Box, Tab } from "@mui/material"; import { useState } from "react"; -import { mergeSxProp } from "../../utils/muiUtils"; interface TabsViewProps { items: Array<{ label: string; content?: React.ReactNode; }>; - TabListProps?: TabListProps; + onChange?: TabListProps["onChange"]; + divider?: boolean; } -function TabsView({ items, TabListProps }: TabsViewProps) { +function TabsView({ items, onChange, divider }: TabsViewProps) { const [value, setValue] = useState("0"); //////////////////////////////////////////////////////////////// @@ -20,7 +20,7 @@ function TabsView({ items, TabListProps }: TabsViewProps) { const handleChange = (event: React.SyntheticEvent, newValue: string) => { setValue(newValue); - TabListProps?.onChange?.(event, newValue); + onChange?.(event, newValue); }; //////////////////////////////////////////////////////////////// @@ -28,31 +28,34 @@ function TabsView({ items, TabListProps }: TabsViewProps) { //////////////////////////////////////////////////////////////// return ( - - - {items.map(({ label }, index) => ( - + + + {/* Don't set divider to `TabList`, this causes issue with `variant="scrollable"` */} + + + {items.map(({ label }, index) => ( + + ))} + + + {items.map(({ content }, index) => ( + + {content} + ))} - - {items.map(({ content }, index) => ( - - {content} - - ))} - + + ); } From ded87b64a6dbc19f8a7f4a07fd9bc75003f41b95 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Tue, 18 Jun 2024 09:19:10 +0200 Subject: [PATCH 36/47] feat(ui-commons): update TabsWrapper component --- .../Modelization/Areas/Hydro/index.tsx | 2 +- .../App/Singlestudy/explore/TabWrapper.tsx | 56 ++++++++----------- 2 files changed, 25 insertions(+), 33 deletions(-) diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/index.tsx index e8a19a7335..ae0cafa00e 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/index.tsx @@ -39,7 +39,7 @@ function Hydro() { return ( - + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/TabWrapper.tsx b/webapp/src/components/App/Singlestudy/explore/TabWrapper.tsx index d59d128798..3f04932134 100644 --- a/webapp/src/components/App/Singlestudy/explore/TabWrapper.tsx +++ b/webapp/src/components/App/Singlestudy/explore/TabWrapper.tsx @@ -8,7 +8,7 @@ import { Outlet, matchPath, useLocation, useNavigate } from "react-router-dom"; import { StudyMetadata } from "../../../../common/types"; import { mergeSxProp } from "../../../../utils/muiUtils"; -export const StyledTab = styled(Tabs, { +export const StyledTabs = styled(Tabs, { shouldForwardProp: (prop) => prop !== "border" && prop !== "tabStyle", })<{ border?: boolean; tabStyle?: "normal" | "withoutBorder" }>( ({ theme, border, tabStyle }) => ({ @@ -40,17 +40,9 @@ interface Props { border?: boolean; tabStyle?: "normal" | "withoutBorder"; sx?: SxProps; - isScrollable?: boolean; } -function TabWrapper({ - study, - tabList, - border, - tabStyle, - sx, - isScrollable = false, -}: Props) { +function TabWrapper({ study, tabList, border, tabStyle, sx }: Props) { const location = useLocation(); const navigate = useNavigate(); const [selectedTab, setSelectedTab] = useState(0); @@ -78,37 +70,37 @@ function TabWrapper({ return ( - - {tabList.map((tab) => ( - - ))} - + + + {tabList.map((tab) => ( + + ))} + + ); From 948e52574059936f8861e1a759e1be69690a596f Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Tue, 18 Jun 2024 07:54:23 +0200 Subject: [PATCH 37/47] feat(ui-config): add a fieldset in 'Adequacy Patch' --- webapp/public/locales/en/main.json | 1 + webapp/public/locales/fr/main.json | 1 + .../explore/Configuration/AdequacyPatch/Fields.tsx | 7 ++++++- .../explore/Configuration/AdequacyPatch/index.tsx | 1 - 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 0393800e1e..a274efec52 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -365,6 +365,7 @@ "study.configuration.optimization.simplexOptimizationRange": "Simplex optimization range", "study.configuration.adequacyPatch.tab.general": "General", "study.configuration.adequacyPatch.tab.perimeter": "Perimeter", + "study.configuration.adequacyPatch.legend.operatingParameters": "Operating parameters", "study.configuration.adequacyPatch.legend.localMatchingRule": "Local matching rule", "study.configuration.adequacyPatch.legend.curtailmentSharing": "Curtailment sharing", "study.configuration.adequacyPatch.legend.advanced": "Advanced", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index faa7ebd89f..27f92a3551 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -365,6 +365,7 @@ "study.configuration.optimization.simplexOptimizationRange": "Simplex optimization range", "study.configuration.adequacyPatch.tab.general": "Général", "study.configuration.adequacyPatch.tab.perimeter": "Périmètre", + "study.configuration.adequacyPatch.legend.operatingParameters": "Paramètres de fonctionnement", "study.configuration.adequacyPatch.legend.localMatchingRule": "Règle de correspondance locale", "study.configuration.adequacyPatch.legend.curtailmentSharing": "Partage de réduction", "study.configuration.adequacyPatch.legend.advanced": "Avancée", diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/AdequacyPatch/Fields.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/AdequacyPatch/Fields.tsx index ebb63fb13b..a5687c6a14 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/AdequacyPatch/Fields.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/AdequacyPatch/Fields.tsx @@ -17,7 +17,12 @@ function Fields() { return ( -
+
); } From d02b14a5c8df915bb7f298709da9f47f13f00268 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Tue, 18 Jun 2024 07:52:23 +0200 Subject: [PATCH 38/47] refactor(ui-config): rename component name --- .../App/Singlestudy/explore/Configuration/General/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx index dca52bc9ce..312abef7d6 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx @@ -17,7 +17,7 @@ import { import { SubmitHandlerPlus } from "../../../../../common/Form/types"; import ScenarioBuilderDialog from "./dialogs/ScenarioBuilderDialog"; -function GeneralParameters() { +function General() { const { study } = useOutletContext<{ study: StudyMetadata }>(); const [dialog, setDialog] = useState(""); @@ -90,4 +90,4 @@ function GeneralParameters() { ); } -export default GeneralParameters; +export default General; From e72ffb12464ef19c60f2f0987db3f8f5a46f5bc6 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Tue, 18 Jun 2024 09:21:14 +0200 Subject: [PATCH 39/47] fix(ui-config): style issues --- .../Singlestudy/explore/Configuration/General/Fields.tsx | 2 +- .../App/Singlestudy/explore/Configuration/index.tsx | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/Fields.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/Fields.tsx index 0fe6af285b..3bd79f0c94 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/Fields.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/Fields.tsx @@ -186,7 +186,7 @@ function Fields(props: Props) { /> (); @@ -40,6 +40,7 @@ function Configuration() { return ( + {/* Left */} } /> - - + {/* Right */} + {R.cond([ [R.equals(0), () => ], [R.equals(1), () => ], @@ -108,7 +109,7 @@ function Configuration() { ), ], ])(tabList[currentTabIndex].id)} - + ); } From e9992431b2bc39aa17a0a6e8b4c7d5e67e3e4b47 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Tue, 18 Jun 2024 09:31:45 +0200 Subject: [PATCH 40/47] fix(ui-model): style issues --- .../explore/Modelization/Areas/AreasTab.tsx | 19 +------ .../Areas/Hydro/InflowStructure/index.tsx | 3 +- .../Areas/Hydro/ManagementOptions/index.tsx | 1 - .../Modelization/Areas/Hydro/index.tsx | 7 +-- .../Modelization/Areas/Renewables/Form.tsx | 50 +++++++++-------- .../Modelization/Areas/Storages/Form.tsx | 54 ++++++++++--------- .../Modelization/Areas/Thermal/Form.tsx | 46 +++++++++------- .../explore/Modelization/Areas/index.tsx | 20 +++---- .../BindingConstView/index.tsx | 4 +- .../Modelization/BindingConstraints/index.tsx | 16 +++--- .../Modelization/Links/LinkView/index.tsx | 21 +++----- .../explore/Modelization/Links/index.tsx | 16 +++--- webapp/src/components/App/index.tsx | 8 +-- 13 files changed, 127 insertions(+), 138 deletions(-) diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/AreasTab.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/AreasTab.tsx index cb54e2fb30..0bc26081dc 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/AreasTab.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/AreasTab.tsx @@ -1,6 +1,5 @@ import { useEffect, useMemo } from "react"; import { useLocation, useNavigate, useOutletContext } from "react-router-dom"; -import { Paper } from "@mui/material"; import { useTranslation } from "react-i18next"; import { StudyMetadata } from "../../../../../../common/types"; import TabWrapper from "../../TabWrapper"; @@ -83,23 +82,7 @@ function AreasTab({ renewablesClustering }: Props) { })); }, [study.id, areaId, renewablesClustering, t, study.version]); - return ( - - - - ); + return ; } export default AreasTab; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/InflowStructure/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/InflowStructure/index.tsx index d5f38785b8..79bef831a5 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/InflowStructure/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/InflowStructure/index.tsx @@ -38,7 +38,8 @@ function InflowStructure() { onSubmit={handleSubmit} miniSubmitButton enableUndoRedo - sx={{ display: "flex", alignItems: "center", ".Form__Footer": { p: 0 } }} + hideFooterDivider + sx={{ flexDirection: "row", alignItems: "center" }} > {({ control }) => ( getManagementOptionsFormFields(studyId, areaId), }} onSubmit={handleSubmit} - sx={{ pb: 2 }} enableUndoRedo > diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/index.tsx index ae0cafa00e..cca7f9519a 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/index.tsx @@ -2,7 +2,6 @@ import { useMemo } from "react"; import { useOutletContext } from "react-router"; import { StudyMetadata } from "../../../../../../../common/types"; import TabWrapper from "../../../TabWrapper"; -import { Root } from "./style"; import useAppSelector from "../../../../../../../redux/hooks/useAppSelector"; import { getCurrentAreaId } from "../../../../../../../redux/selectors"; @@ -37,11 +36,7 @@ function Hydro() { // JSX //////////////////////////////////////////////////////////////// - return ( - - - - ); + return ; } export default Hydro; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Form.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Form.tsx index 30dc1bc7b7..fe39be2fc8 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Form.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Form.tsx @@ -18,7 +18,7 @@ import { getCurrentAreaId } from "../../../../../../../redux/selectors"; import useNavigateOnCondition from "../../../../../../../hooks/useNavigateOnCondition"; import { nameToId } from "../../../../../../../services/utils"; -function RenewablesForm() { +function Renewables() { const { t } = useTranslation(); const { study } = useOutletContext<{ study: StudyMetadata }>(); const navigate = useNavigate(); @@ -51,37 +51,43 @@ function RenewablesForm() { //////////////////////////////////////////////////////////////// return ( - + -
- - - - + +
+ + + + +
); } -export default RenewablesForm; +export default Renewables; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Form.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Form.tsx index 40aa166664..38a4bb2bc2 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Form.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Form.tsx @@ -15,7 +15,7 @@ import Matrix from "./Matrix"; import useNavigateOnCondition from "../../../../../../../hooks/useNavigateOnCondition"; import { nameToId } from "../../../../../../../services/utils"; -function StorageForm() { +function Storages() { const { t } = useTranslation(); const { study } = useOutletContext<{ study: StudyMetadata }>(); const navigate = useNavigate(); @@ -63,39 +63,45 @@ function StorageForm() { //////////////////////////////////////////////////////////////// return ( - + -
- - - - + +
+ + + + +
); } -export default StorageForm; +export default Storages; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Form.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Form.tsx index 810338b581..e37f4749dd 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Form.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Form.tsx @@ -49,34 +49,40 @@ function ThermalForm() { //////////////////////////////////////////////////////////////// return ( - + -
- - - - + +
+ + + + +
); diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/index.tsx index d5926b6e63..8868838934 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/index.tsx @@ -1,4 +1,3 @@ -import { Box } from "@mui/material"; import { useOutletContext } from "react-router"; import { StudyMetadata } from "../../../../../../common/types"; import SimpleContent from "../../../../../common/page/SimpleContent"; @@ -14,6 +13,7 @@ import { setCurrentArea } from "../../../../../../redux/ducks/studySyntheses"; import useAppSelector from "../../../../../../redux/hooks/useAppSelector"; import UsePromiseCond from "../../../../../common/utils/UsePromiseCond"; import SplitView from "../../../../../common/SplitView"; +import ViewWrapper from "../../../../../common/page/ViewWrapper"; function Areas() { const { study } = useOutletContext<{ study: StudyMetadata }>(); @@ -38,14 +38,14 @@ function Areas() { return ( - - - - + {/* Left */} + + {/* Right */} + @@ -58,7 +58,7 @@ function Areas() { ) } /> - + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/index.tsx index d889ae3173..43dd656e8a 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/index.tsx @@ -113,14 +113,14 @@ function BindingConstView({ constraintId }: Props) { height: 1, display: "flex", flexDirection: "column", - overflow: "hidden", + overflow: "auto", }} > ( <> - +