From f3b5f93d186ec25657915418af82b29b6b1f3fda Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 28 Jun 2024 16:14:53 +0200 Subject: [PATCH] test: add unit test to check that RHS matrices are renamed according to update rules --- tests/integration/prepare_proxy.py | 36 +- .../test_download_matrices.py | 2 +- .../test_binding_constraints.py | 346 +++++++++++------- 3 files changed, 243 insertions(+), 141 deletions(-) diff --git a/tests/integration/prepare_proxy.py b/tests/integration/prepare_proxy.py index 22f9dcee8f..0556df8a87 100644 --- a/tests/integration/prepare_proxy.py +++ b/tests/integration/prepare_proxy.py @@ -164,7 +164,7 @@ def generate_snapshot(self, variant_id: str, denormalize: bool = False, from_scr task = wait_task_completion(self.client, self.user_access_token, task_id, timeout=20) assert task.status == TaskStatus.COMPLETED - def create_area(self, study_id: str, *, name: str, country: str = "FR") -> str: + def create_area(self, study_id: str, *, name: str, country: str = "FR") -> t.Dict[str, t.Any]: """ Create an area in a study. @@ -174,7 +174,7 @@ def create_area(self, study_id: str, *, name: str, country: str = "FR") -> str: country: Country of the area. Returns: - The ID of the created area. + The area properties. """ res = self.client.post( f"/v1/studies/{study_id}/areas", @@ -182,8 +182,8 @@ def create_area(self, study_id: str, *, name: str, country: str = "FR") -> str: json={"name": name, "type": "AREA", "metadata": {"country": country}}, ) res.raise_for_status() - area_id = t.cast(str, res.json()["id"]) - return area_id + properties = t.cast(t.Dict[str, t.Any], res.json()) + return properties def update_general_data(self, study_id: str, **data: t.Any) -> None: """ @@ -200,7 +200,7 @@ def update_general_data(self, study_id: str, **data: t.Any) -> None: ) res.raise_for_status() - def create_link(self, study_id: str, area1_id: str, area2_id: str) -> str: + def create_link(self, study_id: str, area1_id: str, area2_id: str) -> t.Dict[str, t.Any]: """ Create a link between two areas in a study. @@ -210,7 +210,7 @@ def create_link(self, study_id: str, area1_id: str, area2_id: str) -> str: area2_id: The ID of the second area. Returns: - The ID of the created link. + The link properties. """ # Create a link between the two areas res = self.client.post( @@ -219,10 +219,11 @@ def create_link(self, study_id: str, area1_id: str, area2_id: str) -> str: json={"area1": area1_id, "area2": area2_id}, ) assert res.status_code == 200, res.json() - link_id = f"{area1_id}%{area2_id}" - return link_id + properties = t.cast(t.Dict[str, t.Any], res.json()) + properties["id"] = f"{area1_id}%{area2_id}" + return properties - def create_thermal(self, study_id: str, area1_id: str, *, name: str, **kwargs: t.Any) -> str: + def create_thermal(self, study_id: str, area1_id: str, *, name: str, **kwargs: t.Any) -> t.Dict[str, t.Any]: """ Create a thermal cluster in an area. @@ -231,6 +232,9 @@ def create_thermal(self, study_id: str, area1_id: str, *, name: str, **kwargs: t area1_id: The ID of the area. name: The name of the cluster. **kwargs: Additional cluster data. + + Returns: + The cluster properties. """ res = self.client.post( f"/v1/studies/{study_id}/areas/{area1_id}/clusters/thermal", @@ -238,8 +242,8 @@ def create_thermal(self, study_id: str, area1_id: str, *, name: str, **kwargs: t json={"name": name, **kwargs}, ) res.raise_for_status() - cluster_id = t.cast(str, res.json()["id"]) - return cluster_id + properties = t.cast(t.Dict[str, t.Any], res.json()) + return properties def get_thermals(self, study_id: str, area1_id: str) -> t.List[t.Dict[str, t.Any]]: """ @@ -327,3 +331,13 @@ def get_binding_constraints(self, study_id: str) -> t.List[t.Dict[str, t.Any]]: res.raise_for_status() binding_constraints_list = t.cast(t.List[t.Dict[str, t.Any]], res.json()) return binding_constraints_list + + def drop_all_commands(self, variant_id: str) -> None: + """ + Drop all commands of a variant. + + Args: + variant_id: The ID of the variant. + """ + res = self.client.delete(f"/v1/studies/{variant_id}/commands", headers=self.headers) + res.raise_for_status() diff --git a/tests/integration/raw_studies_blueprint/test_download_matrices.py b/tests/integration/raw_studies_blueprint/test_download_matrices.py index 0cf8dd69be..e49b5dc839 100644 --- a/tests/integration/raw_studies_blueprint/test_download_matrices.py +++ b/tests/integration/raw_studies_blueprint/test_download_matrices.py @@ -30,7 +30,7 @@ def test_download_matrices(self, client: TestClient, user_access_token: str, stu variant_id = preparer.create_variant(study_820_id, name="New Variant") # Create a new area to implicitly create normalized matrices - area_id = preparer.create_area(variant_id, name="Mayenne", country="France") + area_id = preparer.create_area(variant_id, name="Mayenne", country="France")["id"] # Change study start_date preparer.update_general_data(variant_id, firstMonth="July") diff --git a/tests/integration/study_data_blueprint/test_binding_constraints.py b/tests/integration/study_data_blueprint/test_binding_constraints.py index 7e3c613e16..df86ae4eb5 100644 --- a/tests/integration/study_data_blueprint/test_binding_constraints.py +++ b/tests/integration/study_data_blueprint/test_binding_constraints.py @@ -1,12 +1,23 @@ -import io import re import numpy as np import pandas as pd import pytest +from requests.exceptions import HTTPError from starlette.testclient import TestClient from antarest.study.business.binding_constraint_management import ClusterTerm, ConstraintTerm, LinkTerm +from tests.integration.prepare_proxy import PreparerProxy + +MATRIX_SIZES = {"hourly": 8784, "daily": 366, "weekly": 366} + + +REQUIRED_MATRICES = { + "less": {"lt"}, + "equal": {"eq"}, + "greater": {"gt"}, + "both": {"lt", "gt"}, +} class TestLinkTerm: @@ -68,14 +79,6 @@ def test_constraint_id__other(self) -> None: assert term.generate_id() == "foo" -def _upload_matrix(client: TestClient, study_id: str, matrix_path: str, df: pd.DataFrame) -> None: - tsv = io.BytesIO() - df.to_csv(tsv, sep="\t", index=False, header=False) - tsv.seek(0) - res = client.put(f"/v1/studies/{study_id}/raw", params={"path": matrix_path}, files={"file": tsv}) - res.raise_for_status() - - @pytest.mark.unit_test class TestBindingConstraints: """ @@ -90,49 +93,22 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st # STUDY PREPARATION # ============================= - # Create a Study - res = client.post("/v1/studies", params={"name": "foo", "version": "860"}) - assert res.status_code == 201, res.json() - study_id = res.json() - - # Create Areas - res = client.post(f"/v1/studies/{study_id}/areas", json={"name": "Area 1", "type": "AREA"}) - assert res.status_code == 200, res.json() - area1_id = res.json()["id"] - assert area1_id == "area 1" - - res = client.post(f"/v1/studies/{study_id}/areas", json={"name": "Area 2", "type": "AREA"}) - assert res.status_code == 200, res.json() - area2_id = res.json()["id"] - assert area2_id == "area 2" - - # Create a link between the two areas - res = client.post(f"/v1/studies/{study_id}/links", json={"area1": area1_id, "area2": area2_id}) - assert res.status_code == 200, res.json() + preparer = PreparerProxy(client, user_access_token) + study_id = preparer.create_study("foo", version=860) + area1_id = preparer.create_area(study_id, name="Area 1")["id"] + area2_id = preparer.create_area(study_id, name="Area 2")["id"] + link_id = preparer.create_link(study_id, area1_id=area1_id, area2_id=area2_id)["id"] # Create a cluster in area1 - res = client.post( - f"/v1/studies/{study_id}/areas/{area1_id}/clusters/thermal", - json={"name": "Cluster 1", "group": "Nuclear"}, - ) - assert res.status_code == 200, res.json() - cluster_id = res.json()["id"] - assert cluster_id == "Cluster 1" - - # Get clusters list to check created cluster in area1 - res = client.get(f"/v1/studies/{study_id}/areas/{area1_id}/clusters/thermal") - clusters_list = res.json() - assert res.status_code == 200, res.json() + cluster_id = preparer.create_thermal(study_id, area1_id, name="Cluster 1", group="Nuclear")["id"] + clusters_list = preparer.get_thermals(study_id, area1_id) assert len(clusters_list) == 1 assert clusters_list[0]["id"] == cluster_id assert clusters_list[0]["name"] == "Cluster 1" assert clusters_list[0]["group"] == "Nuclear" if study_type == "variant": - # Create Variant - res = client.post(f"/v1/studies/{study_id}/variants", params={"name": "Variant 1"}) - assert res.status_code in {200, 201}, res.json() - study_id = res.json() + study_id = preparer.create_variant(study_id, name="Variant 1") # ============================= # CREATION @@ -176,23 +152,18 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st assert res.status_code in {200, 201}, res.json() # Creates a binding constraint with the new API - res = client.post( - f"/v1/studies/{study_id}/bindingconstraints", - json={ - "name": "binding_constraint_3", - "enabled": True, - "timeStep": "hourly", - "operator": "less", - "terms": [], - "comments": "New API", - }, + preparer.create_binding_constraint( + study_id, + name="binding_constraint_3", + enabled=True, + timeStep="hourly", + operator="less", + terms=[], + comments="New API", ) - assert res.status_code in {200, 201}, res.json() # Get Binding Constraint list - res = client.get(f"/v1/studies/{study_id}/bindingconstraints") - binding_constraints_list = res.json() - assert res.status_code == 200, res.json() + binding_constraints_list = preparer.get_binding_constraints(study_id) assert len(binding_constraints_list) == 3 # Group section should not exist as the study version is prior to 8.7 assert "group" not in binding_constraints_list[0] @@ -275,7 +246,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st expected = [ { "data": {"area1": area1_id, "area2": area2_id}, - "id": f"{area1_id}%{area2_id}", + "id": link_id, "offset": 2, "weight": 1.0, }, @@ -303,7 +274,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st expected = [ { "data": {"area1": area1_id, "area2": area2_id}, - "id": f"{area1_id}%{area2_id}", + "id": link_id, "offset": 2, "weight": 1.0, }, @@ -341,7 +312,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st } # Remove Constraint term - res = client.delete(f"/v1/studies/{study_id}/bindingconstraints/{bc_id}/term/{area1_id}%{area2_id}") + res = client.delete(f"/v1/studies/{study_id}/bindingconstraints/{bc_id}/term/{link_id}") assert res.status_code == 200, res.json() # Check updated terms, the deleted term should no longer exist. @@ -550,39 +521,17 @@ def test_for_version_870(self, client: TestClient, user_access_token: str, study # STUDY PREPARATION # ============================= - res = client.post("/v1/studies", params={"name": "foo"}) - assert res.status_code == 201, res.json() - study_id = res.json() + preparer = PreparerProxy(client, user_access_token) + study_id = preparer.create_study("foo", version=870) if study_type == "variant": - # Create Variant - res = client.post(f"/v1/studies/{study_id}/variants", params={"name": "Variant 1"}) - assert res.status_code in {200, 201} - study_id = res.json() + study_id = preparer.create_variant(study_id, name="Variant 1") - # Create Areas - res = client.post(f"/v1/studies/{study_id}/areas", json={"name": "Area 1", "type": "AREA"}) - assert res.status_code == 200, res.json() - area1_id = res.json()["id"] - assert area1_id == "area 1" - - res = client.post(f"/v1/studies/{study_id}/areas", json={"name": "Area 2", "type": "AREA"}) - assert res.status_code == 200, res.json() - area2_id = res.json()["id"] - assert area2_id == "area 2" - - # Create a link between the two areas - res = client.post(f"/v1/studies/{study_id}/links", json={"area1": area1_id, "area2": area2_id}) - assert res.status_code == 200, res.json() - - # Create a cluster in area1 - res = client.post( - f"/v1/studies/{study_id}/areas/{area1_id}/clusters/thermal", - json={"name": "Cluster 1", "group": "Nuclear"}, - ) - assert res.status_code == 200, res.json() - cluster_id = res.json()["id"] - assert cluster_id == "Cluster 1" + # Create Areas, link and cluster + area1_id = preparer.create_area(study_id, name="Area 1")["id"] + area2_id = preparer.create_area(study_id, name="Area 2")["id"] + link_id = preparer.create_link(study_id, area1_id=area1_id, area2_id=area2_id)["id"] + cluster_id = preparer.create_thermal(study_id, area1_id, name="Cluster 1", group="Nuclear")["id"] # ============================= # CREATION @@ -591,27 +540,20 @@ def test_for_version_870(self, client: TestClient, user_access_token: str, study # Creation of a bc without group bc_id_wo_group = "binding_constraint_1" args = {"enabled": True, "timeStep": "hourly", "operator": "less", "terms": [], "comments": "New API"} - res = client.post(f"/v1/studies/{study_id}/bindingconstraints", json={"name": bc_id_wo_group, **args}) - assert res.status_code in {200, 201} - assert res.json()["group"] == "default" + properties = preparer.create_binding_constraint(study_id, name=bc_id_wo_group, **args) + assert properties["group"] == "default" # Creation of bc with a group bc_id_w_group = "binding_constraint_2" - res = client.post( - f"/v1/studies/{study_id}/bindingconstraints", - json={"name": bc_id_w_group, "group": "specific_grp", **args}, - ) - assert res.status_code in {200, 201} - assert res.json()["group"] == "specific_grp" + properties = preparer.create_binding_constraint(study_id, name=bc_id_w_group, group="specific_grp", **args) + assert properties["group"] == "specific_grp" # Creation of bc with a matrix bc_id_w_matrix = "binding_constraint_3" matrix_lt3 = np.ones((8784, 3)) - res = client.post( - f"/v1/studies/{study_id}/bindingconstraints", - json={"name": bc_id_w_matrix, "less_term_matrix": matrix_lt3.tolist(), **args}, + properties = preparer.create_binding_constraint( + study_id, name=bc_id_w_matrix, less_term_matrix=matrix_lt3.tolist(), **args ) - assert res.status_code in {200, 201}, res.json() if study_type == "variant": res = client.get(f"/v1/studies/{study_id}/commands") @@ -671,7 +613,7 @@ def test_for_version_870(self, client: TestClient, user_access_token: str, study description = res.json()["description"] assert exception == "DuplicateConstraintTerm" assert bc_id_w_group in description, "Error message should contain the binding constraint ID" - assert f"{area1_id}%{area2_id}" in description, "Error message should contain the duplicate term ID" + assert link_id in description, "Error message should contain the duplicate term ID" # Get binding constraints list to check added terms res = client.get(f"/v1/studies/{study_id}/bindingconstraints/{bc_id_w_group}") @@ -681,7 +623,7 @@ def test_for_version_870(self, client: TestClient, user_access_token: str, study expected = [ { "data": {"area1": area1_id, "area2": area2_id}, - "id": f"{area1_id}%{area2_id}", + "id": link_id, "offset": 2, "weight": 1.0, }, @@ -699,7 +641,7 @@ def test_for_version_870(self, client: TestClient, user_access_token: str, study f"/v1/studies/{study_id}/bindingconstraints/{bc_id_w_group}/terms", json=[ { - "id": f"{area1_id}%{area2_id}", + "id": link_id, "weight": 4.4, "offset": 1, }, @@ -720,7 +662,7 @@ def test_for_version_870(self, client: TestClient, user_access_token: str, study expected = [ { "data": {"area1": area1_id, "area2": area2_id}, - "id": f"{area1_id}%{area2_id}", + "id": link_id, "offset": 1, "weight": 4.4, }, @@ -805,27 +747,18 @@ def test_for_version_870(self, client: TestClient, user_access_token: str, study assert res.status_code == 200, res.json() # Asserts that the deletion worked - res = client.get(f"/v1/studies/{study_id}/bindingconstraints") - assert len(res.json()) == 2 + binding_constraints_list = preparer.get_binding_constraints(study_id) + assert len(binding_constraints_list) == 2 # ============================= # ERRORS # ============================= # Creation with wrong matrix according to version - res = client.post( - f"/v1/studies/{study_id}/bindingconstraints", - json={ - "name": "binding_constraint_700", - "enabled": True, - "timeStep": "hourly", - "operator": "less", - "terms": [], - "comments": "New API", - "values": [[]], - }, - ) - assert res.status_code == 422, res.json() + with pytest.raises(HTTPError) as ctx: + preparer.create_binding_constraint(study_id, name="binding_constraint_4", less_term_matrix=[], **args) + res = ctx.value.response + assert res.status_code == 422 assert res.json()["description"] == "You cannot fill 'values' as it refers to the matrix before v8.7" # Update with old matrices @@ -879,12 +812,7 @@ def test_for_version_870(self, client: TestClient, user_access_token: str, study generator = np.random.default_rng(11) random_matrix = pd.DataFrame(generator.integers(0, 10, size=(4, 1))) - _upload_matrix( - client, - study_id, - f"input/bindingconstraints/{first_bc_id}_gt", - random_matrix, - ) + preparer.upload_matrix(study_id, f"input/bindingconstraints/{first_bc_id}_gt", random_matrix) # Validation should fail res = client.get(f"/v1/studies/{study_id}/constraint-groups/Group 1/validate") @@ -1031,3 +959,163 @@ def test_for_version_870(self, client: TestClient, user_access_token: str, study assert re.search(r"'Group 1':", description, flags=re.IGNORECASE) assert re.search(r"the most common width in the group is 3", description, flags=re.IGNORECASE) assert re.search(r"'third bc_lt' has 4 columns", description, flags=re.IGNORECASE) + + @pytest.mark.parametrize("study_version", [870]) + @pytest.mark.parametrize("denormalize", [True, False]) + def test_rhs_matrices( + self, client: TestClient, user_access_token: str, study_version: int, denormalize: bool + ) -> None: + """ + The goal of this test is to verify that there are no unnecessary RHS matrices created + in the case of **creation** or **update** of a binding constraint. + This test only concerns studies in **version >= 8.7** for which we have a specific matrix + for each operation: "less", "equal", "greater" or "both". + + To perform this test, we will create a raw study "Base Study" with a "France" area + and a single thermal cluster "Nuclear". + We will then create a variant study "Variant Study" based on the raw study "Base Study" + to apply binding constraint creation or update commands. + + The use of a variant and commands allows to check the behavior for both variant studies + and raw studies by generating the variant snapshot. + + To verify the expected behaviors, we must control the number and naming of the matrices + after generating the snapshot. + In the case of an update and depending on the values of the `operator` and `time_step` parameters, + we must also control the preservation or zeroing of the matrix values. + """ + client.headers = {"Authorization": f"Bearer {user_access_token}"} # type: ignore + + # ======================= + # RAW STUDY PREPARATION + # ======================= + + preparer = PreparerProxy(client, user_access_token) + study_id = preparer.create_study("Base Study", version=study_version) + area_id = preparer.create_area(study_id, name="France")["id"] + cluster_id = preparer.create_thermal(study_id, area_id, name="Nuclear", group="Nuclear")["id"] + + # ============================= + # VARIANT STUDY CREATION + # ============================= + + variant_id = preparer.create_variant(study_id, name="Variant Study") + + # ============================= + # CREATION W/O MATRICES + # ============================= + + all_time_steps = set(MATRIX_SIZES) + all_operators = set(REQUIRED_MATRICES) + + for bc_time_step in all_time_steps: + for bc_operator in all_operators: + bc_name = f"BC_{bc_time_step}_{bc_operator}" + # Creation of a binding constraint without matrices using a command + res = client.post( + f"/v1/studies/{variant_id}/commands", + json=[ + { + "action": "create_binding_constraint", + "args": { + "name": bc_name, + "type": bc_time_step, + "operator": bc_operator, + "coeffs": {f"{area_id}.{cluster_id.lower()}": [1, 2]}, + }, + } + ], + ) + assert res.status_code == 200, res.json() + + preparer.generate_snapshot(variant_id, denormalize=denormalize) + + # Check the matrices size, values and existence + for bc_time_step in all_time_steps: + for bc_operator in all_operators: + bc_name = f"BC_{bc_time_step}_{bc_operator}" + bc_id = bc_name.lower() + + required_matrices = REQUIRED_MATRICES[bc_operator] + for matrix in required_matrices: + df = preparer.download_matrix(variant_id, f"input/bindingconstraints/{bc_id}_{matrix}") + assert df.shape == (MATRIX_SIZES[bc_time_step], 1) + assert (df == 0).all().all() + + superfluous_matrices = {"lt", "gt", "eq"} - required_matrices + for matrix in superfluous_matrices: + try: + preparer.download_matrix(variant_id, f"input/bindingconstraints/{bc_id}_{matrix}") + except HTTPError as e: + assert e.response.status_code == 404 + else: + assert False, "The matrix should not exist" + + # drop all commands to avoid conflicts with the next test + preparer.drop_all_commands(variant_id) + + # ============================= + # CREATION WITH MATRICES + # ============================= + + # random matrices + matrices_by_time_steps = {time_step: np.random.rand(size, 1) for time_step, size in MATRIX_SIZES.items()} + + for bc_time_step in all_time_steps: + for bc_operator in all_operators: + bc_name = f"BC_{bc_time_step}_{bc_operator}" + matrix = matrices_by_time_steps[bc_time_step].tolist() + args = { + "name": bc_name, + "type": bc_time_step, + "operator": bc_operator, + "coeffs": {f"{area_id}.{cluster_id.lower()}": [1, 2]}, + } + if bc_operator == "less": + args["lessTermMatrix"] = matrix + elif bc_operator == "greater": + args["greaterTermMatrix"] = matrix + elif bc_operator == "equal": + args["equalTermMatrix"] = matrix + else: + args["lessTermMatrix"] = args["greaterTermMatrix"] = matrix + res = client.post( + f"/v1/studies/{variant_id}/commands", + json=[{"action": "create_binding_constraint", "args": args}], + ) + assert res.status_code == 200, res.json() + + preparer.generate_snapshot(variant_id, denormalize=denormalize) + + # Check the matrices size, values and existence + for bc_time_step in all_time_steps: + for bc_operator in all_operators: + bc_name = f"BC_{bc_time_step}_{bc_operator}" + bc_id = bc_name.lower() + + required_matrices = REQUIRED_MATRICES[bc_operator] + for matrix in required_matrices: + df = preparer.download_matrix(variant_id, f"input/bindingconstraints/{bc_id}_{matrix}") + assert df.shape == (MATRIX_SIZES[bc_time_step], 1) + assert df.values.tolist() == matrices_by_time_steps[bc_time_step].tolist() + + superfluous_matrices = {"lt", "gt", "eq"} - required_matrices + for matrix in superfluous_matrices: + try: + preparer.download_matrix(variant_id, f"input/bindingconstraints/{bc_id}_{matrix}") + except HTTPError as e: + assert e.response.status_code == 404 + else: + assert False, "The matrix should not exist" + + # ============================= + # UPDATE W/O MATRICES + # ============================= + + # todo: Continue the test here + + # ============================= + # UPDATE WITH MATRICES + # ============================= + + # todo: Continue the test here