From 96fb9c98d5fa90cc761705c6663584914385fc11 Mon Sep 17 00:00:00 2001 From: belthlemar Date: Thu, 8 Feb 2024 14:55:05 +0100 Subject: [PATCH 001/248] feature(raw): add endpoint for matrices download --- antarest/core/filetransfer/service.py | 42 +-- antarest/study/service.py | 92 +++++- antarest/study/storage/utils.py | 124 +++++++- antarest/study/web/raw_studies_blueprint.py | 81 ++++- requirements.txt | 1 + .../test_download_matrices.py | 281 ++++++++++++++++++ .../test_study_matrix_index.py | 10 +- .../business/test_study_service_utils.py | 14 +- tests/storage/test_service.py | 6 +- 9 files changed, 615 insertions(+), 36 deletions(-) create mode 100644 tests/integration/raw_studies_blueprint/test_download_matrices.py diff --git a/antarest/core/filetransfer/service.py b/antarest/core/filetransfer/service.py index 760573a42f..80a81e6927 100644 --- a/antarest/core/filetransfer/service.py +++ b/antarest/core/filetransfer/service.py @@ -43,6 +43,8 @@ def request_download( filename: str, name: Optional[str] = None, owner: Optional[JWTUser] = None, + use_notification: bool = True, + expiration_time_in_minutes: int = 0, ) -> FileDownload: fh, path = tempfile.mkstemp(dir=self.tmp_dir, suffix=filename) os.close(fh) @@ -55,36 +57,40 @@ def request_download( path=str(tmpfile), owner=owner.impersonator if owner is not None else None, expiration_date=datetime.datetime.utcnow() - + datetime.timedelta(minutes=self.download_default_expiration_timeout_minutes), + + datetime.timedelta( + minutes=expiration_time_in_minutes or self.download_default_expiration_timeout_minutes + ), ) self.repository.add(download) - self.event_bus.push( - Event( - type=EventType.DOWNLOAD_CREATED, - payload=download.to_dto(), - permissions=PermissionInfo(owner=owner.impersonator) - if owner - else PermissionInfo(public_mode=PublicMode.READ), + if use_notification: + self.event_bus.push( + Event( + type=EventType.DOWNLOAD_CREATED, + payload=download.to_dto(), + permissions=PermissionInfo(owner=owner.impersonator) + if owner + else PermissionInfo(public_mode=PublicMode.READ), + ) ) - ) return download - def set_ready(self, download_id: str) -> None: + def set_ready(self, download_id: str, use_notification: bool = True) -> None: download = self.repository.get(download_id) if not download: raise FileDownloadNotFound() download.ready = True self.repository.save(download) - self.event_bus.push( - Event( - type=EventType.DOWNLOAD_READY, - payload=download.to_dto(), - permissions=PermissionInfo(owner=download.owner) - if download.owner - else PermissionInfo(public_mode=PublicMode.READ), + if use_notification: + self.event_bus.push( + Event( + type=EventType.DOWNLOAD_READY, + payload=download.to_dto(), + permissions=PermissionInfo(owner=download.owner) + if download.owner + else PermissionInfo(public_mode=PublicMode.READ), + ) ) - ) def fail(self, download_id: str, reason: str = "") -> None: download = self.repository.get(download_id) diff --git a/antarest/study/service.py b/antarest/study/service.py index ae86fe62ae..6c97a16d66 100644 --- a/antarest/study/service.py +++ b/antarest/study/service.py @@ -4,6 +4,7 @@ import json import logging import os +import re import time import typing as t from datetime import datetime, timedelta @@ -12,6 +13,7 @@ from uuid import uuid4 import numpy as np +import pandas as pd from fastapi import HTTPException, UploadFile from markupsafe import escape from starlette.responses import FileResponse, Response @@ -20,6 +22,7 @@ from antarest.core.exceptions import ( BadEditInstructionException, CommandApplicationError, + IncorrectPathError, NotAManagedStudyException, StudyDeletionNotAllowed, StudyNotFoundError, @@ -54,6 +57,7 @@ from antarest.study.business.areas.thermal_management import ThermalManager from antarest.study.business.binding_constraint_management import BindingConstraintManager from antarest.study.business.config_management import ConfigManager +from antarest.study.business.correlation_management import CorrelationManager from antarest.study.business.district_manager import DistrictManager from antarest.study.business.general_management import GeneralManager from antarest.study.business.link_management import LinkInfoDTO, LinkManager @@ -109,7 +113,14 @@ should_study_be_denormalized, upgrade_study, ) -from antarest.study.storage.utils import assert_permission, get_start_date, is_managed, remove_from_cache +from antarest.study.storage.utils import ( + MatrixProfile, + assert_permission, + get_specific_matrices_according_to_version, + get_start_date, + is_managed, + remove_from_cache, +) from antarest.study.storage.variantstudy.model.command.icommand import ICommand from antarest.study.storage.variantstudy.model.command.replace_matrix import ReplaceMatrix from antarest.study.storage.variantstudy.model.command.update_comments import UpdateComments @@ -141,6 +152,40 @@ def get_disk_usage(path: t.Union[str, Path]) -> int: return total_size +def _handle_specific_matrices( + df: pd.DataFrame, + matrix_profile: MatrixProfile, + matrix_path: str, + *, + with_index: bool, + with_columns: bool, +) -> pd.DataFrame: + if with_columns: + if Path(matrix_path).parts[1] == "links": + cols = _handle_links_columns(matrix_path, matrix_profile) + else: + cols = matrix_profile.cols + if cols: + df.columns = pd.Index(cols) + rows = matrix_profile.rows + if with_index and rows: + df.index = rows # type: ignore + return df + + +def _handle_links_columns(matrix_path: str, matrix_profile: MatrixProfile) -> t.List[str]: + path_parts = Path(matrix_path).parts + area_id_1 = path_parts[2] + area_id_2 = path_parts[3] + result = matrix_profile.cols + for k, col in enumerate(result): + if col == "Hurdle costs direct": + result[k] = f"{col} ({area_id_1}->{area_id_2})" + elif col == "Hurdle costs indirect": + result[k] = f"{col} ({area_id_2}->{area_id_1})" + return result + + class StudyUpgraderTask: """ Task to perform a study upgrade. @@ -268,6 +313,7 @@ def __init__( self.xpansion_manager = XpansionManager(self.storage_service) self.matrix_manager = MatrixManager(self.storage_service) self.binding_constraint_manager = BindingConstraintManager(self.storage_service) + self.correlation_manager = CorrelationManager(self.storage_service) self.cache_service = cache_service self.config = config self.on_deletion_callbacks: t.List[t.Callable[[str], None]] = [] @@ -2379,3 +2425,47 @@ def get_disk_usage(self, uuid: str, params: RequestParameters) -> int: study_path = self.storage_service.raw_study_service.get_study_path(study) # If the study is a variant, it's possible that it only exists in DB and not on disk. If so, we return 0. return get_disk_usage(study_path) if study_path.exists() else 0 + + def get_matrix_with_index_and_header( + self, *, study_id: str, path: str, with_index: bool, with_columns: bool, parameters: RequestParameters + ) -> pd.DataFrame: + matrix_path = Path(path) + study = self.get_study(study_id) + for aggregate in ["allocation", "correlation"]: + if matrix_path == Path("input") / "hydro" / aggregate: + all_areas = t.cast( + t.List[AreaInfoDTO], + self.get_all_areas(study_id, area_type=AreaType.AREA, ui=False, params=parameters), + ) + if aggregate == "allocation": + hydro_matrix = self.allocation_manager.get_allocation_matrix(study, all_areas) + else: + hydro_matrix = self.correlation_manager.get_correlation_matrix(all_areas, study, []) # type: ignore + return pd.DataFrame(data=hydro_matrix.data, columns=hydro_matrix.columns, index=hydro_matrix.index) + + json_matrix = self.get(study_id, path, depth=3, formatted=True, params=parameters) + for key in ["data", "index", "columns"]: + if key not in json_matrix: + raise IncorrectPathError(f"The path filled does not correspond to a matrix : {path}") + if not json_matrix["data"]: + return pd.DataFrame() + df_matrix = pd.DataFrame(data=json_matrix["data"], columns=json_matrix["columns"], index=json_matrix["index"]) + + if with_index: + matrix_index = self.get_input_matrix_startdate(study_id, path, parameters) + time_column = pd.date_range( + start=matrix_index.start_date, periods=len(df_matrix), freq=matrix_index.level.value[0] + ) + df_matrix.index = time_column + + specific_matrices = get_specific_matrices_according_to_version(int(study.version)) + for specific_matrix in specific_matrices: + if re.match(specific_matrix, path): + return _handle_specific_matrices( + df_matrix, + specific_matrices[specific_matrix], + path, + with_index=with_index, + with_columns=with_columns, + ) + return df_matrix diff --git a/antarest/study/storage/utils.py b/antarest/study/storage/utils.py index a0fc7a02fe..81e7b5dfca 100644 --- a/antarest/study/storage/utils.py +++ b/antarest/study/storage/utils.py @@ -1,4 +1,5 @@ import calendar +import copy import logging import math import os @@ -269,6 +270,127 @@ def assert_permission( ) +def _generate_columns(column_suffix: str) -> t.List[str]: + return [f"{i}{column_suffix}" for i in range(101)] + + +class MatrixProfile(t.NamedTuple): + """ + Matrix profile for time series or classic tables. + """ + + cols: t.List[str] + rows: t.List[str] + stats: bool + + +SPECIFIC_MATRICES = { + "input/hydro/common/capacity/creditmodulations_*": MatrixProfile( + cols=_generate_columns(""), + rows=["Generating Power", "Pumping Power"], + stats=False, + ), + "input/hydro/common/capacity/maxpower_*": MatrixProfile( + cols=[ + "Generating Max Power (MW)", + "Generating Max Energy (Hours at Pmax)", + "Pumping Max Power (MW)", + "Pumping Max Energy (Hours at Pmax)", + ], + rows=[], + stats=False, + ), + "input/hydro/common/capacity/reservoir_*": MatrixProfile( + cols=["Lev Low (p.u)", "Lev Avg (p.u)", "Lev High (p.u)"], + rows=[], + stats=False, + ), + "input/hydro/common/capacity/waterValues_*": MatrixProfile(cols=_generate_columns("%"), rows=[], stats=False), + "input/hydro/series/*/mod": MatrixProfile(cols=[], rows=[], stats=True), + "input/hydro/series/*/ror": MatrixProfile(cols=[], rows=[], stats=True), + "input/hydro/common/capacity/inflowPattern_*": MatrixProfile(cols=["Inflow Pattern (X)"], rows=[], stats=False), + "input/hydro/prepro/*/energy": MatrixProfile( + cols=["Expectation (MWh)", "Std Deviation (MWh)", "Min. (MWh)", "Max. (MWh)", "ROR Share"], + rows=[ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ], + stats=False, + ), + "input/thermal/prepro/*/*/modulation": MatrixProfile( + cols=["Marginal cost modulation", "Market bid modulation", "Capacity modulation", "Min gen modulation"], + rows=[], + stats=False, + ), + "input/thermal/prepro/*/*/data": MatrixProfile( + cols=["FO Duration", "PO Duration", "FO Rate", "PO Rate", "NPO Min", "NPO Max"], + rows=[], + stats=False, + ), + "input/reserves/*": MatrixProfile( + cols=["Primary Res. (draft)", "Strategic Res. (draft)", "DSM", "Day Ahead"], + rows=[], + stats=False, + ), + "input/misc-gen/miscgen-*": MatrixProfile( + cols=["CHP", "Bio Mass", "Bio Gaz", "Waste", "GeoThermal", "Other", "PSP", "ROW Balance"], + rows=[], + stats=False, + ), + "input/bindingconstraints/*": MatrixProfile(cols=["<", ">", "="], rows=[], stats=False), + "input/links/*/*": MatrixProfile( + cols=[ + "Capacités de transmission directes", + "Capacités de transmission indirectes", + "Hurdle costs direct", + "Hurdle costs indirect", + "Impedances", + "Loop flow", + "P.Shift Min", + "P.Shift Max", + ], + rows=[], + stats=False, + ), +} + + +SPECIFIC_MATRICES_820 = copy.deepcopy(SPECIFIC_MATRICES) +SPECIFIC_MATRICES_820["input/links/*/*"] = MatrixProfile( + cols=[ + "Hurdle costs direct", + "Hurdle costs indirect", + "Impedances", + "Loop flow", + "P.Shift Min", + "P.Shift Max", + ], + rows=[], + stats=False, +) + +SPECIFIC_MATRICES_870 = copy.deepcopy(SPECIFIC_MATRICES_820) +SPECIFIC_MATRICES_870["input/bindingconstraints/*"] = MatrixProfile(cols=[], rows=[], stats=False) + + +def get_specific_matrices_according_to_version(study_version: int) -> t.Dict[str, MatrixProfile]: + if study_version < 820: + return SPECIFIC_MATRICES + elif study_version < 870: + return SPECIFIC_MATRICES_820 + return SPECIFIC_MATRICES_870 + + def get_start_date( file_study: FileStudy, output_id: t.Optional[str] = None, @@ -293,7 +415,7 @@ def get_start_date( starting_month_index = MONTHS.index(starting_month.title()) + 1 starting_day_index = DAY_NAMES.index(starting_day.title()) - target_year = 2000 + target_year = 2018 while True: if leapyear == calendar.isleap(target_year): first_day = datetime(target_year, starting_month_index, 1) diff --git a/antarest/study/web/raw_studies_blueprint.py b/antarest/study/web/raw_studies_blueprint.py index 41e214d1ad..6454ea3175 100644 --- a/antarest/study/web/raw_studies_blueprint.py +++ b/antarest/study/web/raw_studies_blueprint.py @@ -4,12 +4,15 @@ import logging import pathlib import typing as t +from enum import Enum +import pandas as pd from fastapi import APIRouter, Body, Depends, File, HTTPException from fastapi.params import Param, Query -from starlette.responses import JSONResponse, PlainTextResponse, Response, StreamingResponse +from starlette.responses import FileResponse, JSONResponse, PlainTextResponse, Response, StreamingResponse from antarest.core.config import Config +from antarest.core.filetransfer.model import FileDownloadNotFound from antarest.core.jwt import JWTUser from antarest.core.model import SUB_JSON from antarest.core.requests import RequestParameters @@ -49,6 +52,11 @@ } +class ExpectedFormatTypes(Enum): + XLSX = "xlsx" + CSV = "csv" + + def create_raw_study_routes( study_service: StudyService, config: Config, @@ -243,4 +251,75 @@ def validate( ) return study_service.check_errors(uuid) + @bp.get( + "/studies/{uuid}/raw/download", + summary="Download a matrix in a given format", + tags=[APITag.study_raw_data], + response_class=FileResponse, + ) + def get_matrix( + uuid: str, + path: str, + format: ExpectedFormatTypes, + header: bool = True, + index: bool = True, + current_user: JWTUser = Depends(auth.get_current_user), + ) -> FileResponse: + parameters = RequestParameters(user=current_user) + df_matrix = study_service.get_matrix_with_index_and_header( + study_id=uuid, path=path, with_index=index, with_columns=header, parameters=parameters + ) + + export_file_download = study_service.file_transfer_manager.request_download( + f"{pathlib.Path(path).stem}.{format.value}", + f"Exporting matrix {pathlib.Path(path).stem} to format {format.value} for study {uuid}", + current_user, + use_notification=False, + expiration_time_in_minutes=10, + ) + export_path = pathlib.Path(export_file_download.path) + export_id = export_file_download.id + + try: + _create_matrix_files(df_matrix, header, index, format, export_path) + study_service.file_transfer_manager.set_ready(export_id, use_notification=False) + except ValueError as e: + study_service.file_transfer_manager.fail(export_id, str(e)) + raise HTTPException( + status_code=http.HTTPStatus.UNPROCESSABLE_ENTITY, + detail=f"The Excel file {export_path} already exists and cannot be replaced due to Excel policy :{str(e)}", + ) from e + except FileDownloadNotFound as e: + study_service.file_transfer_manager.fail(export_id, str(e)) + raise HTTPException( + status_code=http.HTTPStatus.UNPROCESSABLE_ENTITY, + detail=f"The file download does not exist in database :{str(e)}", + ) from e + + return FileResponse( + export_path, + headers={"Content-Disposition": f'attachment; filename="{export_file_download.filename}"'}, + media_type="application/octet-stream", + ) + return bp + + +def _create_matrix_files( + df_matrix: pd.DataFrame, header: bool, index: bool, format: ExpectedFormatTypes, export_path: pathlib.Path +) -> None: + if format == ExpectedFormatTypes.CSV: + df_matrix.to_csv( + export_path, + sep="\t", + header=header, + index=index, + float_format="%.6f", + ) + else: + df_matrix.to_excel( + export_path, + header=header, + index=index, + float_format="%.6f", + ) diff --git a/requirements.txt b/requirements.txt index 4e12840d32..2d8f4be828 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ Jinja2~=3.0.3 jsonref~=0.2 MarkupSafe~=2.0.1 numpy~=1.22.1 +openpyxl~=3.1.2 pandas~=1.4.0 paramiko~=2.12.0 plyer~=2.0.0 diff --git a/tests/integration/raw_studies_blueprint/test_download_matrices.py b/tests/integration/raw_studies_blueprint/test_download_matrices.py new file mode 100644 index 0000000000..849a0e2675 --- /dev/null +++ b/tests/integration/raw_studies_blueprint/test_download_matrices.py @@ -0,0 +1,281 @@ +import io + +import numpy as np +import pandas as pd +import pytest +from starlette.testclient import TestClient + +from antarest.core.tasks.model import TaskStatus +from tests.integration.utils import wait_task_completion + + +@pytest.mark.integration_test +class TestDownloadMatrices: + """ + Checks the retrieval of matrices with the endpoint GET studies/uuid/raw/download + """ + + def test_download_matrices(self, client: TestClient, admin_access_token: str, study_id: str) -> None: + admin_headers = {"Authorization": f"Bearer {admin_access_token}"} + + # ============================= + # STUDIES PREPARATION + # ============================= + + # Manage parent study and upgrades it to v8.2 + # This is done to test matrix headers according to different versions + copied = client.post( + f"/v1/studies/{study_id}/copy", params={"dest": "copied", "use_task": False}, headers=admin_headers + ) + parent_id = copied.json() + res = client.put(f"/v1/studies/{parent_id}/upgrade", params={"target_version": 820}, headers=admin_headers) + assert res.status_code == 200 + task_id = res.json() + assert task_id + task = wait_task_completion(client, admin_access_token, task_id, timeout=20) + assert task.status == TaskStatus.COMPLETED + + # Create Variant + res = client.post( + f"/v1/studies/{parent_id}/variants", + headers=admin_headers, + params={"name": "variant_1"}, + ) + assert res.status_code == 200 + variant_id = res.json() + + # Create a new area to implicitly create normalized matrices + area_name = "new_area" + res = client.post( + f"/v1/studies/{variant_id}/areas", + headers=admin_headers, + json={"name": area_name, "type": "AREA", "metadata": {"country": "FR"}}, + ) + assert res.status_code in {200, 201} + + # Change study start_date + res = client.put( + f"/v1/studies/{variant_id}/config/general/form", json={"firstMonth": "july"}, headers=admin_headers + ) + assert res.status_code == 200 + + # Really generates the snapshot + client.get(f"/v1/studies/{variant_id}/areas", headers=admin_headers) + assert res.status_code == 200 + + # ============================= + # TESTS NOMINAL CASE ON RAW AND VARIANT STUDY + # ============================= + + raw_matrix_path = r"input/load/series/load_de" + variant_matrix_path = f"input/load/series/load_{area_name}" + + for uuid, path in zip([parent_id, variant_id], [raw_matrix_path, variant_matrix_path]): + # get downloaded bytes + res = client.get( + f"/v1/studies/{uuid}/raw/download", params={"path": path, "format": "xlsx"}, headers=admin_headers + ) + assert res.status_code == 200 + + # load into dataframe + dataframe = pd.read_excel(io.BytesIO(res.content), index_col=0) + + # check time coherence + generated_index = dataframe.index + first_date = generated_index[0].to_pydatetime() + second_date = generated_index[1].to_pydatetime() + assert first_date.month == second_date.month == 1 if uuid == parent_id else 7 + assert first_date.day == second_date.day == 1 + assert first_date.hour == 0 + assert second_date.hour == 1 + + # reformat into a json to help comparison + new_cols = [int(col) for col in dataframe.columns] + dataframe.columns = new_cols + dataframe.index = range(len(dataframe)) + actual_matrix = dataframe.to_dict(orient="split") + + # asserts that the result is the same as the one we get with the classic get /raw endpoint + res = client.get(f"/v1/studies/{uuid}/raw", params={"path": path, "formatted": True}, headers=admin_headers) + expected_matrix = res.json() + assert actual_matrix == expected_matrix + + # ============================= + # TESTS INDEX AND HEADER PARAMETERS + # ============================= + + # test only few possibilities as each API call is quite long + for header in [True, False]: + index = not header + res = client.get( + f"/v1/studies/{parent_id}/raw/download", + params={"path": raw_matrix_path, "format": "csv", "header": header, "index": index}, + headers=admin_headers, + ) + assert res.status_code == 200 + content = io.BytesIO(res.content) + dataframe = pd.read_csv( + content, index_col=0 if index else None, header="infer" if header else None, sep="\t" + ) + first_index = dataframe.index[0] + assert first_index == "2018-01-01 00:00:00" if index else first_index == 0 + assert isinstance(dataframe.columns[0], str) if header else isinstance(dataframe.columns[0], np.int64) + + # ============================= + # TEST SPECIFIC MATRICES + # ============================= + + # tests links headers before v8.2 + res = client.get( + f"/v1/studies/{study_id}/raw/download", + params={"path": "input/links/de/fr", "format": "csv", "index": False}, + headers=admin_headers, + ) + assert res.status_code == 200 + content = io.BytesIO(res.content) + dataframe = pd.read_csv(content, sep="\t") + assert list(dataframe.columns) == [ + "Capacités de transmission directes", + "Capacités de transmission indirectes", + "Hurdle costs direct (de->fr)", + "Hurdle costs indirect (fr->de)", + "Impedances", + "Loop flow", + "P.Shift Min", + "P.Shift Max", + ] + + # tests links headers after v8.2 + res = client.get( + f"/v1/studies/{parent_id}/raw/download", + params={"path": "input/links/de/fr_parameters", "format": "csv"}, + headers=admin_headers, + ) + assert res.status_code == 200 + content = io.BytesIO(res.content) + dataframe = pd.read_csv(content, index_col=0, sep="\t") + assert list(dataframe.columns) == [ + "Hurdle costs direct (de->fr_parameters)", + "Hurdle costs indirect (fr_parameters->de)", + "Impedances", + "Loop flow", + "P.Shift Min", + "P.Shift Max", + ] + + # allocation and correlation matrices + for path in ["input/hydro/allocation", "input/hydro/correlation"]: + res = client.get( + f"/v1/studies/{parent_id}/raw/download", params={"path": path, "format": "csv"}, headers=admin_headers + ) + assert res.status_code == 200 + content = io.BytesIO(res.content) + dataframe = pd.read_csv(content, index_col=0, sep="\t") + assert list(dataframe.index) == list(dataframe.columns) == ["de", "es", "fr", "it"] + for i in range((len(dataframe))): + assert dataframe.iloc[i, i] == 1.0 + + # test for empty matrix + res = client.get( + f"/v1/studies/{study_id}/raw/download", + params={"path": "input/hydro/common/capacity/waterValues_de", "format": "csv"}, + headers=admin_headers, + ) + assert res.status_code == 200 + content = io.BytesIO(res.content) + dataframe = pd.read_csv(content, index_col=0, sep="\t") + assert dataframe.empty + + # modulation matrix + res = client.get( + f"/v1/studies/{parent_id}/raw/download", + params={"path": "input/thermal/prepro/de/01_solar/modulation", "format": "csv"}, + headers=admin_headers, + ) + assert res.status_code == 200 + content = io.BytesIO(res.content) + dataframe = pd.read_csv(content, index_col=0, sep="\t") + assert dataframe.index[0] == "2018-01-01 00:00:00" + dataframe.index = range(len(dataframe)) + liste_transposee = list(zip(*[8760 * [1.0], 8760 * [1.0], 8760 * [1.0], 8760 * [0.0]])) + expected_df = pd.DataFrame(columns=["0", "1", "2", "3"], index=range(8760), data=liste_transposee) + assert dataframe.equals(expected_df) + + # asserts endpoint returns the right columns for output matrix + res = client.get( + f"/v1/studies/{study_id}/raw/download", + params={ + "path": "output/20201014-1422eco-hello/economy/mc-ind/00001/links/de/fr/values-hourly", + "format": "csv", + }, + headers=admin_headers, + ) + assert res.status_code == 200 + content = io.BytesIO(res.content) + dataframe = pd.read_csv(content, index_col=0, sep="\t") + assert list(dataframe.columns) == [ + "('FLOW LIN.', 'MWh', '')", + "('UCAP LIN.', 'MWh', '')", + "('LOOP FLOW', 'MWh', '')", + "('FLOW QUAD.', 'MWh', '')", + "('CONG. FEE (ALG.)', 'Euro', '')", + "('CONG. FEE (ABS.)', 'Euro', '')", + "('MARG. COST', 'Euro/MW', '')", + "('CONG. PROB +', '%', '')", + "('CONG. PROB -', '%', '')", + "('HURDLE COST', 'Euro', '')", + ] + + # test energy matrix to test the regex + res = client.get( + f"/v1/studies/{study_id}/raw/download", + params={"path": "input/hydro/prepro/de/energy", "format": "csv"}, + headers=admin_headers, + ) + assert res.status_code == 200 + content = io.BytesIO(res.content) + dataframe = pd.read_csv(content, index_col=0, sep="\t") + assert dataframe.empty + + # ============================= + # ERRORS + # ============================= + + fake_str = "fake_str" + + # fake study_id + res = client.get( + f"/v1/studies/{fake_str}/raw/download", + params={"path": raw_matrix_path, "format": "csv"}, + headers=admin_headers, + ) + assert res.status_code == 404 + assert res.json()["exception"] == "StudyNotFoundError" + + # fake path + res = client.get( + f"/v1/studies/{parent_id}/raw/download", + params={"path": f"input/links/de/{fake_str}", "format": "csv"}, + headers=admin_headers, + ) + assert res.status_code == 404 + assert res.json()["exception"] == "ChildNotFoundError" + + # path that does not lead to a matrix + res = client.get( + f"/v1/studies/{parent_id}/raw/download", + params={"path": "settings/generaldata", "format": "csv"}, + headers=admin_headers, + ) + assert res.status_code == 404 + assert res.json()["exception"] == "IncorrectPathError" + assert res.json()["description"] == "The path filled does not correspond to a matrix : settings/generaldata" + + # wrong format + res = client.get( + f"/v1/studies/{parent_id}/raw/download", + params={"path": raw_matrix_path, "format": fake_str}, + headers=admin_headers, + ) + assert res.status_code == 422 + assert res.json()["exception"] == "RequestValidationError" diff --git a/tests/integration/studies_blueprint/test_study_matrix_index.py b/tests/integration/studies_blueprint/test_study_matrix_index.py index 69880cb357..4aeacceff4 100644 --- a/tests/integration/studies_blueprint/test_study_matrix_index.py +++ b/tests/integration/studies_blueprint/test_study_matrix_index.py @@ -33,7 +33,7 @@ def test_get_study_matrix_index( expected = { "first_week_size": 7, "level": "hourly", - "start_date": "2001-01-01 00:00:00", + "start_date": "2018-01-01 00:00:00", "steps": 8760, } assert actual == expected @@ -50,7 +50,7 @@ def test_get_study_matrix_index( expected = { "first_week_size": 7, "level": "daily", - "start_date": "2001-01-01 00:00:00", + "start_date": "2018-01-01 00:00:00", "steps": 365, } assert actual == expected @@ -67,7 +67,7 @@ def test_get_study_matrix_index( expected = { "first_week_size": 7, "level": "hourly", - "start_date": "2001-01-01 00:00:00", + "start_date": "2018-01-01 00:00:00", "steps": 8760, } assert actual == expected @@ -80,7 +80,7 @@ def test_get_study_matrix_index( actual = res.json() expected = { "first_week_size": 7, - "start_date": "2001-01-01 00:00:00", + "start_date": "2018-01-01 00:00:00", "steps": 8760, "level": "hourly", } @@ -96,5 +96,5 @@ def test_get_study_matrix_index( ) assert res.status_code == 200 actual = res.json() - expected = {"first_week_size": 7, "start_date": "2001-01-01 00:00:00", "steps": 7, "level": "daily"} + expected = {"first_week_size": 7, "start_date": "2018-01-01 00:00:00", "steps": 7, "level": "daily"} assert actual == expected diff --git a/tests/storage/business/test_study_service_utils.py b/tests/storage/business/test_study_service_utils.py index 623f17a55e..dcd674e0e3 100644 --- a/tests/storage/business/test_study_service_utils.py +++ b/tests/storage/business/test_study_service_utils.py @@ -104,7 +104,7 @@ def test_output_downloads_export(tmp_path: Path): }, StudyDownloadLevelDTO.WEEKLY, MatrixIndex( - start_date=str(datetime.datetime(2001, 1, 1)), + start_date=str(datetime.datetime(2018, 1, 1)), steps=51, first_week_size=7, level=StudyDownloadLevelDTO.WEEKLY, @@ -121,7 +121,7 @@ def test_output_downloads_export(tmp_path: Path): }, StudyDownloadLevelDTO.WEEKLY, MatrixIndex( - start_date=str(datetime.datetime(2002, 7, 5)), + start_date=str(datetime.datetime(2019, 7, 5)), steps=48, first_week_size=5, level=StudyDownloadLevelDTO.WEEKLY, @@ -138,7 +138,7 @@ def test_output_downloads_export(tmp_path: Path): }, StudyDownloadLevelDTO.MONTHLY, MatrixIndex( - start_date=str(datetime.datetime(2002, 7, 1)), + start_date=str(datetime.datetime(2019, 7, 1)), steps=7, first_week_size=7, level=StudyDownloadLevelDTO.MONTHLY, @@ -155,7 +155,7 @@ def test_output_downloads_export(tmp_path: Path): }, StudyDownloadLevelDTO.MONTHLY, MatrixIndex( - start_date=str(datetime.datetime(2002, 7, 1)), + start_date=str(datetime.datetime(2019, 7, 1)), steps=4, first_week_size=7, level=StudyDownloadLevelDTO.MONTHLY, @@ -172,7 +172,7 @@ def test_output_downloads_export(tmp_path: Path): }, StudyDownloadLevelDTO.HOURLY, MatrixIndex( - start_date=str(datetime.datetime(2010, 3, 5)), + start_date=str(datetime.datetime(2021, 3, 5)), steps=2304, first_week_size=3, level=StudyDownloadLevelDTO.HOURLY, @@ -189,7 +189,7 @@ def test_output_downloads_export(tmp_path: Path): }, StudyDownloadLevelDTO.ANNUAL, MatrixIndex( - start_date=str(datetime.datetime(2010, 3, 5)), + start_date=str(datetime.datetime(2021, 3, 5)), steps=1, first_week_size=3, level=StudyDownloadLevelDTO.ANNUAL, @@ -206,7 +206,7 @@ def test_output_downloads_export(tmp_path: Path): }, StudyDownloadLevelDTO.DAILY, MatrixIndex( - start_date=str(datetime.datetime(2009, 3, 3)), + start_date=str(datetime.datetime(2026, 3, 3)), steps=98, first_week_size=3, level=StudyDownloadLevelDTO.DAILY, diff --git a/tests/storage/test_service.py b/tests/storage/test_service.py index e7e8662394..12f61e6489 100644 --- a/tests/storage/test_service.py +++ b/tests/storage/test_service.py @@ -571,7 +571,7 @@ def test_download_output() -> None: # AREA TYPE res_matrix = MatrixAggregationResultDTO( index=MatrixIndex( - start_date="2001-01-01 00:00:00", + start_date="2018-01-01 00:00:00", steps=1, first_week_size=7, level=StudyDownloadLevelDTO.ANNUAL, @@ -631,7 +631,7 @@ def test_download_output() -> None: input_data.filter = ["east>west"] res_matrix = MatrixAggregationResultDTO( index=MatrixIndex( - start_date="2001-01-01 00:00:00", + start_date="2018-01-01 00:00:00", steps=1, first_week_size=7, level=StudyDownloadLevelDTO.ANNUAL, @@ -661,7 +661,7 @@ def test_download_output() -> None: input_data.filterIn = "n" res_matrix = MatrixAggregationResultDTO( index=MatrixIndex( - start_date="2001-01-01 00:00:00", + start_date="2018-01-01 00:00:00", steps=1, first_week_size=7, level=StudyDownloadLevelDTO.ANNUAL, From 34cc269e185f8809d357ef563bdcf5ac8e4b5dd0 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Thu, 8 Feb 2024 23:14:38 +0100 Subject: [PATCH 002/248] refactor(api-download): rename `ExpectedFormatTypes` into `TableExportFormat` --- antarest/study/web/raw_studies_blueprint.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/antarest/study/web/raw_studies_blueprint.py b/antarest/study/web/raw_studies_blueprint.py index 6454ea3175..1707ba8de3 100644 --- a/antarest/study/web/raw_studies_blueprint.py +++ b/antarest/study/web/raw_studies_blueprint.py @@ -1,10 +1,10 @@ +import enum import http import io import json import logging import pathlib import typing as t -from enum import Enum import pandas as pd from fastapi import APIRouter, Body, Depends, File, HTTPException @@ -52,7 +52,7 @@ } -class ExpectedFormatTypes(Enum): +class TableExportFormat(enum.Enum): XLSX = "xlsx" CSV = "csv" @@ -260,7 +260,7 @@ def validate( def get_matrix( uuid: str, path: str, - format: ExpectedFormatTypes, + format: TableExportFormat, header: bool = True, index: bool = True, current_user: JWTUser = Depends(auth.get_current_user), @@ -306,9 +306,9 @@ def get_matrix( def _create_matrix_files( - df_matrix: pd.DataFrame, header: bool, index: bool, format: ExpectedFormatTypes, export_path: pathlib.Path + df_matrix: pd.DataFrame, header: bool, index: bool, format: TableExportFormat, export_path: pathlib.Path ) -> None: - if format == ExpectedFormatTypes.CSV: + if format == TableExportFormat.CSV: df_matrix.to_csv( export_path, sep="\t", From e58c9171e71ac373169c6aac61be66850686f622 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Thu, 8 Feb 2024 23:19:14 +0100 Subject: [PATCH 003/248] refactor(api-download): turn `TableExportFormat` enum into case-insensitive enum --- antarest/study/web/raw_studies_blueprint.py | 6 ++++-- .../raw_studies_blueprint/test_download_matrices.py | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/antarest/study/web/raw_studies_blueprint.py b/antarest/study/web/raw_studies_blueprint.py index 1707ba8de3..fe61cbe6d8 100644 --- a/antarest/study/web/raw_studies_blueprint.py +++ b/antarest/study/web/raw_studies_blueprint.py @@ -1,4 +1,3 @@ -import enum import http import io import json @@ -20,6 +19,7 @@ from antarest.core.utils.utils import sanitize_uuid from antarest.core.utils.web import APITag from antarest.login.auth import Auth +from antarest.study.business.enum_ignore_case import EnumIgnoreCase from antarest.study.service import StudyService logger = logging.getLogger(__name__) @@ -52,7 +52,9 @@ } -class TableExportFormat(enum.Enum): +class TableExportFormat(EnumIgnoreCase): + """Export format for tables.""" + XLSX = "xlsx" CSV = "csv" diff --git a/tests/integration/raw_studies_blueprint/test_download_matrices.py b/tests/integration/raw_studies_blueprint/test_download_matrices.py index 849a0e2675..e1f354f823 100644 --- a/tests/integration/raw_studies_blueprint/test_download_matrices.py +++ b/tests/integration/raw_studies_blueprint/test_download_matrices.py @@ -105,11 +105,12 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st # ============================= # test only few possibilities as each API call is quite long + # (also check that the format is case-insensitive) for header in [True, False]: index = not header res = client.get( f"/v1/studies/{parent_id}/raw/download", - params={"path": raw_matrix_path, "format": "csv", "header": header, "index": index}, + params={"path": raw_matrix_path, "format": "CSV", "header": header, "index": index}, headers=admin_headers, ) assert res.status_code == 200 From 75b3a203e3e0f28975691db416e25210e2659056 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Thu, 8 Feb 2024 23:22:51 +0100 Subject: [PATCH 004/248] refactor(api-download): remplace "csv" by "tsv" in `TableExportFormat` enum --- antarest/study/web/raw_studies_blueprint.py | 6 ++--- .../test_download_matrices.py | 22 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/antarest/study/web/raw_studies_blueprint.py b/antarest/study/web/raw_studies_blueprint.py index fe61cbe6d8..19f3ff4b50 100644 --- a/antarest/study/web/raw_studies_blueprint.py +++ b/antarest/study/web/raw_studies_blueprint.py @@ -56,7 +56,7 @@ class TableExportFormat(EnumIgnoreCase): """Export format for tables.""" XLSX = "xlsx" - CSV = "csv" + TSV = "tsv" def create_raw_study_routes( @@ -98,7 +98,7 @@ def get_study( - `formatted`: A flag specifying whether the data should be returned in a formatted manner. Returns the fetched data: a JSON object (in most cases), a plain text file - or a file attachment (Microsoft Office document, CSV/TSV file...). + or a file attachment (Microsoft Office document, TSV/TSV file...). """ logger.info( f"📘 Fetching data at {path} (depth={depth}) from study {uuid}", @@ -310,7 +310,7 @@ def get_matrix( def _create_matrix_files( df_matrix: pd.DataFrame, header: bool, index: bool, format: TableExportFormat, export_path: pathlib.Path ) -> None: - if format == TableExportFormat.CSV: + if format == TableExportFormat.TSV: df_matrix.to_csv( export_path, sep="\t", diff --git a/tests/integration/raw_studies_blueprint/test_download_matrices.py b/tests/integration/raw_studies_blueprint/test_download_matrices.py index e1f354f823..3fb7af4164 100644 --- a/tests/integration/raw_studies_blueprint/test_download_matrices.py +++ b/tests/integration/raw_studies_blueprint/test_download_matrices.py @@ -110,7 +110,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st index = not header res = client.get( f"/v1/studies/{parent_id}/raw/download", - params={"path": raw_matrix_path, "format": "CSV", "header": header, "index": index}, + params={"path": raw_matrix_path, "format": "TSV", "header": header, "index": index}, headers=admin_headers, ) assert res.status_code == 200 @@ -129,7 +129,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st # tests links headers before v8.2 res = client.get( f"/v1/studies/{study_id}/raw/download", - params={"path": "input/links/de/fr", "format": "csv", "index": False}, + params={"path": "input/links/de/fr", "format": "tsv", "index": False}, headers=admin_headers, ) assert res.status_code == 200 @@ -149,7 +149,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st # tests links headers after v8.2 res = client.get( f"/v1/studies/{parent_id}/raw/download", - params={"path": "input/links/de/fr_parameters", "format": "csv"}, + params={"path": "input/links/de/fr_parameters", "format": "tsv"}, headers=admin_headers, ) assert res.status_code == 200 @@ -167,7 +167,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st # allocation and correlation matrices for path in ["input/hydro/allocation", "input/hydro/correlation"]: res = client.get( - f"/v1/studies/{parent_id}/raw/download", params={"path": path, "format": "csv"}, headers=admin_headers + f"/v1/studies/{parent_id}/raw/download", params={"path": path, "format": "tsv"}, headers=admin_headers ) assert res.status_code == 200 content = io.BytesIO(res.content) @@ -179,7 +179,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st # test for empty matrix res = client.get( f"/v1/studies/{study_id}/raw/download", - params={"path": "input/hydro/common/capacity/waterValues_de", "format": "csv"}, + params={"path": "input/hydro/common/capacity/waterValues_de", "format": "tsv"}, headers=admin_headers, ) assert res.status_code == 200 @@ -190,7 +190,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st # modulation matrix res = client.get( f"/v1/studies/{parent_id}/raw/download", - params={"path": "input/thermal/prepro/de/01_solar/modulation", "format": "csv"}, + params={"path": "input/thermal/prepro/de/01_solar/modulation", "format": "tsv"}, headers=admin_headers, ) assert res.status_code == 200 @@ -207,7 +207,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st f"/v1/studies/{study_id}/raw/download", params={ "path": "output/20201014-1422eco-hello/economy/mc-ind/00001/links/de/fr/values-hourly", - "format": "csv", + "format": "tsv", }, headers=admin_headers, ) @@ -230,7 +230,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st # test energy matrix to test the regex res = client.get( f"/v1/studies/{study_id}/raw/download", - params={"path": "input/hydro/prepro/de/energy", "format": "csv"}, + params={"path": "input/hydro/prepro/de/energy", "format": "tsv"}, headers=admin_headers, ) assert res.status_code == 200 @@ -247,7 +247,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st # fake study_id res = client.get( f"/v1/studies/{fake_str}/raw/download", - params={"path": raw_matrix_path, "format": "csv"}, + params={"path": raw_matrix_path, "format": "tsv"}, headers=admin_headers, ) assert res.status_code == 404 @@ -256,7 +256,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st # fake path res = client.get( f"/v1/studies/{parent_id}/raw/download", - params={"path": f"input/links/de/{fake_str}", "format": "csv"}, + params={"path": f"input/links/de/{fake_str}", "format": "tsv"}, headers=admin_headers, ) assert res.status_code == 404 @@ -265,7 +265,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st # path that does not lead to a matrix res = client.get( f"/v1/studies/{parent_id}/raw/download", - params={"path": "settings/generaldata", "format": "csv"}, + params={"path": "settings/generaldata", "format": "tsv"}, headers=admin_headers, ) assert res.status_code == 404 From 62d24b00cb99c7d0d7512ebd47326fce23bc0a10 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Thu, 8 Feb 2024 23:28:10 +0100 Subject: [PATCH 005/248] refactor(api-download): add `suffix` and `media_type` properties to `TableExportFormat` enum --- antarest/study/web/raw_studies_blueprint.py | 32 +++++++++++++++++-- .../test_download_matrices.py | 10 ++++-- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/antarest/study/web/raw_studies_blueprint.py b/antarest/study/web/raw_studies_blueprint.py index 19f3ff4b50..c296e875d5 100644 --- a/antarest/study/web/raw_studies_blueprint.py +++ b/antarest/study/web/raw_studies_blueprint.py @@ -58,6 +58,31 @@ class TableExportFormat(EnumIgnoreCase): XLSX = "xlsx" TSV = "tsv" + def __str__(self) -> str: + """Return the format as a string for display.""" + return self.value.title() + + @property + def media_type(self) -> str: + """Return the media type used for the HTTP response.""" + if self == TableExportFormat.XLSX: + # noinspection SpellCheckingInspection + return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + elif self == TableExportFormat.TSV: + return "text/tab-separated-values" + else: # pragma: no cover + raise NotImplementedError(f"Export format '{self}' is not implemented") + + @property + def suffix(self) -> str: + """Return the file suffix for the format.""" + if self == TableExportFormat.XLSX: + return ".xlsx" + elif self == TableExportFormat.TSV: + return ".tsv" + else: # pragma: no cover + raise NotImplementedError(f"Export format '{self}' is not implemented") + def create_raw_study_routes( study_service: StudyService, @@ -272,9 +297,10 @@ def get_matrix( study_id=uuid, path=path, with_index=index, with_columns=header, parameters=parameters ) + matrix_name = pathlib.Path(path).stem export_file_download = study_service.file_transfer_manager.request_download( - f"{pathlib.Path(path).stem}.{format.value}", - f"Exporting matrix {pathlib.Path(path).stem} to format {format.value} for study {uuid}", + f"{matrix_name}{format.suffix}", + f"Exporting matrix '{matrix_name}' to {format} format for study '{uuid}'", current_user, use_notification=False, expiration_time_in_minutes=10, @@ -301,7 +327,7 @@ def get_matrix( return FileResponse( export_path, headers={"Content-Disposition": f'attachment; filename="{export_file_download.filename}"'}, - media_type="application/octet-stream", + media_type=format.media_type, ) return bp diff --git a/tests/integration/raw_studies_blueprint/test_download_matrices.py b/tests/integration/raw_studies_blueprint/test_download_matrices.py index 3fb7af4164..2411f30f07 100644 --- a/tests/integration/raw_studies_blueprint/test_download_matrices.py +++ b/tests/integration/raw_studies_blueprint/test_download_matrices.py @@ -70,12 +70,16 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st raw_matrix_path = r"input/load/series/load_de" variant_matrix_path = f"input/load/series/load_{area_name}" - for uuid, path in zip([parent_id, variant_id], [raw_matrix_path, variant_matrix_path]): + for uuid, path in [(parent_id, raw_matrix_path), (variant_id, variant_matrix_path)]: # get downloaded bytes res = client.get( - f"/v1/studies/{uuid}/raw/download", params={"path": path, "format": "xlsx"}, headers=admin_headers + f"/v1/studies/{uuid}/raw/download", + params={"path": path, "format": "xlsx"}, + headers=admin_headers, ) assert res.status_code == 200 + # noinspection SpellCheckingInspection + assert res.headers["content-type"] == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" # load into dataframe dataframe = pd.read_excel(io.BytesIO(res.content), index_col=0) @@ -114,6 +118,8 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st headers=admin_headers, ) assert res.status_code == 200 + assert res.headers["content-type"] == "text/tab-separated-values; charset=utf-8" + content = io.BytesIO(res.content) dataframe = pd.read_csv( content, index_col=0 if index else None, header="infer" if header else None, sep="\t" From 793c17c56cc39ddc398b0a7826b8a5e842c7206c Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Thu, 8 Feb 2024 23:37:50 +0100 Subject: [PATCH 006/248] refactor(api-download): add `export_table` method (to replace `_create_matrix_files`) --- antarest/study/web/raw_studies_blueprint.py | 50 ++++++++++----------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/antarest/study/web/raw_studies_blueprint.py b/antarest/study/web/raw_studies_blueprint.py index c296e875d5..f47691efd0 100644 --- a/antarest/study/web/raw_studies_blueprint.py +++ b/antarest/study/web/raw_studies_blueprint.py @@ -2,8 +2,8 @@ import io import json import logging -import pathlib import typing as t +from pathlib import Path, PurePosixPath import pandas as pd from fastapi import APIRouter, Body, Depends, File, HTTPException @@ -83,6 +83,22 @@ def suffix(self) -> str: else: # pragma: no cover raise NotImplementedError(f"Export format '{self}' is not implemented") + def export_table( + self, + df: pd.DataFrame, + export_path: t.Union[str, Path], + *, + with_index: bool = True, + with_header: bool = True, + ) -> None: + """Export a table to a file in the given format.""" + if self == TableExportFormat.XLSX: + return df.to_excel(export_path, index=with_index, header=with_header, engine="openpyxl") + elif self == TableExportFormat.TSV: + return df.to_csv(export_path, sep="\t", index=with_index, header=with_header, float_format="%.6f") + else: # pragma: no cover + raise NotImplementedError(f"Export format '{self}' is not implemented") + def create_raw_study_routes( study_service: StudyService, @@ -134,10 +150,10 @@ def get_study( if isinstance(output, bytes): # Guess the suffix form the target data - resource_path = pathlib.PurePosixPath(path) + resource_path = PurePosixPath(path) parent_cfg = study_service.get(uuid, str(resource_path.parent), depth=2, formatted=True, params=parameters) child = parent_cfg[resource_path.name] - suffix = pathlib.PurePosixPath(child).suffix + suffix = PurePosixPath(child).suffix content_type, encoding = CONTENT_TYPES.get(suffix, (None, None)) if content_type == "application/json": @@ -297,7 +313,7 @@ def get_matrix( study_id=uuid, path=path, with_index=index, with_columns=header, parameters=parameters ) - matrix_name = pathlib.Path(path).stem + matrix_name = Path(path).stem export_file_download = study_service.file_transfer_manager.request_download( f"{matrix_name}{format.suffix}", f"Exporting matrix '{matrix_name}' to {format} format for study '{uuid}'", @@ -305,17 +321,17 @@ def get_matrix( use_notification=False, expiration_time_in_minutes=10, ) - export_path = pathlib.Path(export_file_download.path) + export_path = Path(export_file_download.path) export_id = export_file_download.id try: - _create_matrix_files(df_matrix, header, index, format, export_path) + format.export_table(df_matrix, export_path, with_index=index, with_header=header) study_service.file_transfer_manager.set_ready(export_id, use_notification=False) except ValueError as e: study_service.file_transfer_manager.fail(export_id, str(e)) raise HTTPException( status_code=http.HTTPStatus.UNPROCESSABLE_ENTITY, - detail=f"The Excel file {export_path} already exists and cannot be replaced due to Excel policy :{str(e)}", + detail=f"Cannot replace '{export_path}' due to Excel policy: {e}", ) from e except FileDownloadNotFound as e: study_service.file_transfer_manager.fail(export_id, str(e)) @@ -331,23 +347,3 @@ def get_matrix( ) return bp - - -def _create_matrix_files( - df_matrix: pd.DataFrame, header: bool, index: bool, format: TableExportFormat, export_path: pathlib.Path -) -> None: - if format == TableExportFormat.TSV: - df_matrix.to_csv( - export_path, - sep="\t", - header=header, - index=index, - float_format="%.6f", - ) - else: - df_matrix.to_excel( - export_path, - header=header, - index=index, - float_format="%.6f", - ) From 9ee16bcf71c758d946ae4de557962370ed92b520 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Thu, 8 Feb 2024 23:51:20 +0100 Subject: [PATCH 007/248] refactor(api-download): rename endpoint parameters and use `alias`, `description` and `title`. NOTE: `description` and `title` are displayed in the Swagger --- antarest/study/web/raw_studies_blueprint.py | 29 ++++++++++++------- .../test_download_matrices.py | 6 ++-- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/antarest/study/web/raw_studies_blueprint.py b/antarest/study/web/raw_studies_blueprint.py index f47691efd0..bd1b254472 100644 --- a/antarest/study/web/raw_studies_blueprint.py +++ b/antarest/study/web/raw_studies_blueprint.py @@ -298,25 +298,32 @@ def validate( "/studies/{uuid}/raw/download", summary="Download a matrix in a given format", tags=[APITag.study_raw_data], - response_class=FileResponse, ) def get_matrix( uuid: str, - path: str, - format: TableExportFormat, - header: bool = True, - index: bool = True, + matrix_path: str = Query( # type: ignore + ..., alias="path", description="Relative path of the matrix to download", title="Matrix Path" + ), + export_format: TableExportFormat = Query( # type: ignore + TableExportFormat.XLSX, alias="format", description="Export format", title="Export Format" + ), + with_header: bool = Query( # type: ignore + True, alias="header", description="Whether to include the header or not", title="With Header" + ), + with_index: bool = Query( # type: ignore + True, alias="index", description="Whether to include the index or not", title="With Index" + ), current_user: JWTUser = Depends(auth.get_current_user), ) -> FileResponse: parameters = RequestParameters(user=current_user) df_matrix = study_service.get_matrix_with_index_and_header( - study_id=uuid, path=path, with_index=index, with_columns=header, parameters=parameters + study_id=uuid, path=matrix_path, with_index=with_index, with_columns=with_header, parameters=parameters ) - matrix_name = Path(path).stem + matrix_name = Path(matrix_path).stem export_file_download = study_service.file_transfer_manager.request_download( - f"{matrix_name}{format.suffix}", - f"Exporting matrix '{matrix_name}' to {format} format for study '{uuid}'", + f"{matrix_name}{export_format.suffix}", + f"Exporting matrix '{matrix_name}' to {export_format} format for study '{uuid}'", current_user, use_notification=False, expiration_time_in_minutes=10, @@ -325,7 +332,7 @@ def get_matrix( export_id = export_file_download.id try: - format.export_table(df_matrix, export_path, with_index=index, with_header=header) + export_format.export_table(df_matrix, export_path, with_index=with_index, with_header=with_header) study_service.file_transfer_manager.set_ready(export_id, use_notification=False) except ValueError as e: study_service.file_transfer_manager.fail(export_id, str(e)) @@ -343,7 +350,7 @@ def get_matrix( return FileResponse( export_path, headers={"Content-Disposition": f'attachment; filename="{export_file_download.filename}"'}, - media_type=format.media_type, + media_type=export_format.media_type, ) return bp diff --git a/tests/integration/raw_studies_blueprint/test_download_matrices.py b/tests/integration/raw_studies_blueprint/test_download_matrices.py index 2411f30f07..23147dbf05 100644 --- a/tests/integration/raw_studies_blueprint/test_download_matrices.py +++ b/tests/integration/raw_studies_blueprint/test_download_matrices.py @@ -71,10 +71,11 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st variant_matrix_path = f"input/load/series/load_{area_name}" for uuid, path in [(parent_id, raw_matrix_path), (variant_id, variant_matrix_path)]: - # get downloaded bytes + # Export the matrix in xlsx format (which is the default format) + # and retrieve it as binary content (a ZIP-like file). res = client.get( f"/v1/studies/{uuid}/raw/download", - params={"path": path, "format": "xlsx"}, + params={"path": path}, headers=admin_headers, ) assert res.status_code == 200 @@ -82,6 +83,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st assert res.headers["content-type"] == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" # load into dataframe + # noinspection PyTypeChecker dataframe = pd.read_excel(io.BytesIO(res.content), index_col=0) # check time coherence From 2e46cbfefe019ac0cca916a3d354172963eece36 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 9 Feb 2024 00:01:32 +0100 Subject: [PATCH 008/248] refactor(api-download): rename the parameters and variables from `with_columns` to `with_header` It is clearer for DataFrames. --- antarest/study/service.py | 8 ++++---- antarest/study/web/raw_studies_blueprint.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/antarest/study/service.py b/antarest/study/service.py index 6c97a16d66..df673a5a0e 100644 --- a/antarest/study/service.py +++ b/antarest/study/service.py @@ -158,9 +158,9 @@ def _handle_specific_matrices( matrix_path: str, *, with_index: bool, - with_columns: bool, + with_header: bool, ) -> pd.DataFrame: - if with_columns: + if with_header: if Path(matrix_path).parts[1] == "links": cols = _handle_links_columns(matrix_path, matrix_profile) else: @@ -2427,7 +2427,7 @@ def get_disk_usage(self, uuid: str, params: RequestParameters) -> int: return get_disk_usage(study_path) if study_path.exists() else 0 def get_matrix_with_index_and_header( - self, *, study_id: str, path: str, with_index: bool, with_columns: bool, parameters: RequestParameters + self, *, study_id: str, path: str, with_index: bool, with_header: bool, parameters: RequestParameters ) -> pd.DataFrame: matrix_path = Path(path) study = self.get_study(study_id) @@ -2466,6 +2466,6 @@ def get_matrix_with_index_and_header( specific_matrices[specific_matrix], path, with_index=with_index, - with_columns=with_columns, + with_header=with_header, ) return df_matrix diff --git a/antarest/study/web/raw_studies_blueprint.py b/antarest/study/web/raw_studies_blueprint.py index bd1b254472..9a401d7135 100644 --- a/antarest/study/web/raw_studies_blueprint.py +++ b/antarest/study/web/raw_studies_blueprint.py @@ -317,7 +317,7 @@ def get_matrix( ) -> FileResponse: parameters = RequestParameters(user=current_user) df_matrix = study_service.get_matrix_with_index_and_header( - study_id=uuid, path=matrix_path, with_index=with_index, with_columns=with_header, parameters=parameters + study_id=uuid, path=matrix_path, with_index=with_index, with_header=with_header, parameters=parameters ) matrix_name = Path(matrix_path).stem From f3925c64f496080cd91f4c590e96c3661239095a Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 9 Feb 2024 00:04:02 +0100 Subject: [PATCH 009/248] refactor(api-download): correct spelling in unit tests --- .../raw_studies_blueprint/test_download_matrices.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/integration/raw_studies_blueprint/test_download_matrices.py b/tests/integration/raw_studies_blueprint/test_download_matrices.py index 23147dbf05..9b9a269702 100644 --- a/tests/integration/raw_studies_blueprint/test_download_matrices.py +++ b/tests/integration/raw_studies_blueprint/test_download_matrices.py @@ -88,7 +88,9 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st # check time coherence generated_index = dataframe.index + # noinspection PyUnresolvedReferences first_date = generated_index[0].to_pydatetime() + # noinspection PyUnresolvedReferences second_date = generated_index[1].to_pydatetime() assert first_date.month == second_date.month == 1 if uuid == parent_id else 7 assert first_date.day == second_date.day == 1 @@ -206,8 +208,8 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st dataframe = pd.read_csv(content, index_col=0, sep="\t") assert dataframe.index[0] == "2018-01-01 00:00:00" dataframe.index = range(len(dataframe)) - liste_transposee = list(zip(*[8760 * [1.0], 8760 * [1.0], 8760 * [1.0], 8760 * [0.0]])) - expected_df = pd.DataFrame(columns=["0", "1", "2", "3"], index=range(8760), data=liste_transposee) + transposed_matrix = list(zip(*[8760 * [1.0], 8760 * [1.0], 8760 * [1.0], 8760 * [0.0]])) + expected_df = pd.DataFrame(columns=["0", "1", "2", "3"], index=range(8760), data=transposed_matrix) assert dataframe.equals(expected_df) # asserts endpoint returns the right columns for output matrix @@ -222,6 +224,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st assert res.status_code == 200 content = io.BytesIO(res.content) dataframe = pd.read_csv(content, index_col=0, sep="\t") + # noinspection SpellCheckingInspection assert list(dataframe.columns) == [ "('FLOW LIN.', 'MWh', '')", "('UCAP LIN.', 'MWh', '')", From 3cf81d7bf2e4e4f7b083faba824b8891662fae62 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 9 Feb 2024 00:42:56 +0100 Subject: [PATCH 010/248] refactor(api-download): correct implementation of `get_matrix_with_index_and_header` --- antarest/study/service.py | 48 +++++++++---------- antarest/study/storage/utils.py | 30 ++---------- antarest/study/web/raw_studies_blueprint.py | 6 ++- .../test_download_matrices.py | 8 +++- 4 files changed, 40 insertions(+), 52 deletions(-) diff --git a/antarest/study/service.py b/antarest/study/service.py index df673a5a0e..2a6113d5bc 100644 --- a/antarest/study/service.py +++ b/antarest/study/service.py @@ -1,10 +1,10 @@ import base64 import contextlib +import fnmatch import io import json import logging import os -import re import time import typing as t from datetime import datetime, timedelta @@ -116,7 +116,7 @@ from antarest.study.storage.utils import ( MatrixProfile, assert_permission, - get_specific_matrices_according_to_version, + get_matrix_profile_by_version, get_start_date, is_managed, remove_from_cache, @@ -2431,26 +2431,25 @@ def get_matrix_with_index_and_header( ) -> pd.DataFrame: matrix_path = Path(path) study = self.get_study(study_id) - for aggregate in ["allocation", "correlation"]: - if matrix_path == Path("input") / "hydro" / aggregate: - all_areas = t.cast( - t.List[AreaInfoDTO], - self.get_all_areas(study_id, area_type=AreaType.AREA, ui=False, params=parameters), - ) - if aggregate == "allocation": - hydro_matrix = self.allocation_manager.get_allocation_matrix(study, all_areas) - else: - hydro_matrix = self.correlation_manager.get_correlation_matrix(all_areas, study, []) # type: ignore - return pd.DataFrame(data=hydro_matrix.data, columns=hydro_matrix.columns, index=hydro_matrix.index) - - json_matrix = self.get(study_id, path, depth=3, formatted=True, params=parameters) - for key in ["data", "index", "columns"]: - if key not in json_matrix: - raise IncorrectPathError(f"The path filled does not correspond to a matrix : {path}") - if not json_matrix["data"]: + + if matrix_path.parts in [("input", "hydro", "allocation"), ("input", "hydro", "correlation")]: + all_areas = t.cast( + t.List[AreaInfoDTO], + self.get_all_areas(study_id, area_type=AreaType.AREA, ui=False, params=parameters), + ) + if matrix_path.parts[-1] == "allocation": + hydro_matrix = self.allocation_manager.get_allocation_matrix(study, all_areas) + else: + hydro_matrix = self.correlation_manager.get_correlation_matrix(all_areas, study, []) # type: ignore + return pd.DataFrame(data=hydro_matrix.data, columns=hydro_matrix.columns, index=hydro_matrix.index) + + matrix_obj = self.get(study_id, path, depth=3, formatted=True, params=parameters) + if set(matrix_obj) != {"data", "index", "columns"}: + raise IncorrectPathError(f"The provided path does not point to a valid matrix: '{path}'") + if not matrix_obj["data"]: return pd.DataFrame() - df_matrix = pd.DataFrame(data=json_matrix["data"], columns=json_matrix["columns"], index=json_matrix["index"]) + df_matrix = pd.DataFrame(**matrix_obj) if with_index: matrix_index = self.get_input_matrix_startdate(study_id, path, parameters) time_column = pd.date_range( @@ -2458,14 +2457,15 @@ def get_matrix_with_index_and_header( ) df_matrix.index = time_column - specific_matrices = get_specific_matrices_according_to_version(int(study.version)) - for specific_matrix in specific_matrices: - if re.match(specific_matrix, path): + matrix_profiles = get_matrix_profile_by_version(int(study.version)) + for pattern, matrix_profile in matrix_profiles.items(): + if fnmatch.fnmatch(path, pattern): return _handle_specific_matrices( df_matrix, - specific_matrices[specific_matrix], + matrix_profile, path, with_index=with_index, with_header=with_header, ) + return df_matrix diff --git a/antarest/study/storage/utils.py b/antarest/study/storage/utils.py index 81e7b5dfca..65a33ac1f0 100644 --- a/antarest/study/storage/utils.py +++ b/antarest/study/storage/utils.py @@ -244,30 +244,9 @@ def assert_permission( MATRIX_INPUT_DAYS_COUNT = 365 -MONTHS = ( - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", -) +MONTHS = calendar.month_name[1:] -DAY_NAMES = ( - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - "Sunday", -) +DAY_NAMES = calendar.day_name[:] def _generate_columns(column_suffix: str) -> t.List[str]: @@ -383,12 +362,13 @@ class MatrixProfile(t.NamedTuple): SPECIFIC_MATRICES_870["input/bindingconstraints/*"] = MatrixProfile(cols=[], rows=[], stats=False) -def get_specific_matrices_according_to_version(study_version: int) -> t.Dict[str, MatrixProfile]: +def get_matrix_profile_by_version(study_version: int) -> t.Dict[str, MatrixProfile]: if study_version < 820: return SPECIFIC_MATRICES elif study_version < 870: return SPECIFIC_MATRICES_820 - return SPECIFIC_MATRICES_870 + else: + return SPECIFIC_MATRICES_870 def get_start_date( diff --git a/antarest/study/web/raw_studies_blueprint.py b/antarest/study/web/raw_studies_blueprint.py index 9a401d7135..d452a53e9e 100644 --- a/antarest/study/web/raw_studies_blueprint.py +++ b/antarest/study/web/raw_studies_blueprint.py @@ -317,7 +317,11 @@ def get_matrix( ) -> FileResponse: parameters = RequestParameters(user=current_user) df_matrix = study_service.get_matrix_with_index_and_header( - study_id=uuid, path=matrix_path, with_index=with_index, with_header=with_header, parameters=parameters + study_id=uuid, + path=matrix_path, + with_index=with_index, + with_header=with_header, + parameters=parameters, ) matrix_name = Path(matrix_path).stem diff --git a/tests/integration/raw_studies_blueprint/test_download_matrices.py b/tests/integration/raw_studies_blueprint/test_download_matrices.py index 9b9a269702..14b8aa9a58 100644 --- a/tests/integration/raw_studies_blueprint/test_download_matrices.py +++ b/tests/integration/raw_studies_blueprint/test_download_matrices.py @@ -209,7 +209,11 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st assert dataframe.index[0] == "2018-01-01 00:00:00" dataframe.index = range(len(dataframe)) transposed_matrix = list(zip(*[8760 * [1.0], 8760 * [1.0], 8760 * [1.0], 8760 * [0.0]])) - expected_df = pd.DataFrame(columns=["0", "1", "2", "3"], index=range(8760), data=transposed_matrix) + expected_df = pd.DataFrame( + columns=["Marginal cost modulation", "Market bid modulation", "Capacity modulation", "Min gen modulation"], + index=range(8760), + data=transposed_matrix, + ) assert dataframe.equals(expected_df) # asserts endpoint returns the right columns for output matrix @@ -281,7 +285,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st ) assert res.status_code == 404 assert res.json()["exception"] == "IncorrectPathError" - assert res.json()["description"] == "The path filled does not correspond to a matrix : settings/generaldata" + assert res.json()["description"] == "The provided path does not point to a valid matrix: 'settings/generaldata'" # wrong format res = client.get( From 05e69f5d31774bad2dbfa48bbe8fdfbd55f8e1bf Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 9 Feb 2024 01:01:36 +0100 Subject: [PATCH 011/248] refactor(api-download): move `MatrixProfile` and specifics matrix in `matrix_profile` module --- antarest/study/service.py | 49 +------ antarest/study/storage/matrix_profile.py | 160 +++++++++++++++++++++++ antarest/study/storage/utils.py | 123 ----------------- 3 files changed, 164 insertions(+), 168 deletions(-) create mode 100644 antarest/study/storage/matrix_profile.py diff --git a/antarest/study/service.py b/antarest/study/service.py index 2a6113d5bc..05c7b24595 100644 --- a/antarest/study/service.py +++ b/antarest/study/service.py @@ -97,6 +97,7 @@ StudySimResultDTO, ) from antarest.study.repository import StudyFilter, StudyMetadataRepository, StudyPagination, StudySortBy +from antarest.study.storage.matrix_profile import get_matrix_profiles_by_version from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfigDTO from antarest.study.storage.rawstudy.model.filesystem.folder_node import ChildNotFoundError from antarest.study.storage.rawstudy.model.filesystem.ini_file_node import IniFileNode @@ -113,14 +114,7 @@ should_study_be_denormalized, upgrade_study, ) -from antarest.study.storage.utils import ( - MatrixProfile, - assert_permission, - get_matrix_profile_by_version, - get_start_date, - is_managed, - remove_from_cache, -) +from antarest.study.storage.utils import assert_permission, get_start_date, is_managed, remove_from_cache from antarest.study.storage.variantstudy.model.command.icommand import ICommand from antarest.study.storage.variantstudy.model.command.replace_matrix import ReplaceMatrix from antarest.study.storage.variantstudy.model.command.update_comments import UpdateComments @@ -152,40 +146,6 @@ def get_disk_usage(path: t.Union[str, Path]) -> int: return total_size -def _handle_specific_matrices( - df: pd.DataFrame, - matrix_profile: MatrixProfile, - matrix_path: str, - *, - with_index: bool, - with_header: bool, -) -> pd.DataFrame: - if with_header: - if Path(matrix_path).parts[1] == "links": - cols = _handle_links_columns(matrix_path, matrix_profile) - else: - cols = matrix_profile.cols - if cols: - df.columns = pd.Index(cols) - rows = matrix_profile.rows - if with_index and rows: - df.index = rows # type: ignore - return df - - -def _handle_links_columns(matrix_path: str, matrix_profile: MatrixProfile) -> t.List[str]: - path_parts = Path(matrix_path).parts - area_id_1 = path_parts[2] - area_id_2 = path_parts[3] - result = matrix_profile.cols - for k, col in enumerate(result): - if col == "Hurdle costs direct": - result[k] = f"{col} ({area_id_1}->{area_id_2})" - elif col == "Hurdle costs indirect": - result[k] = f"{col} ({area_id_2}->{area_id_1})" - return result - - class StudyUpgraderTask: """ Task to perform a study upgrade. @@ -2457,12 +2417,11 @@ def get_matrix_with_index_and_header( ) df_matrix.index = time_column - matrix_profiles = get_matrix_profile_by_version(int(study.version)) + matrix_profiles = get_matrix_profiles_by_version(int(study.version)) for pattern, matrix_profile in matrix_profiles.items(): if fnmatch.fnmatch(path, pattern): - return _handle_specific_matrices( + return matrix_profile.handle_specific_matrices( df_matrix, - matrix_profile, path, with_index=with_index, with_header=with_header, diff --git a/antarest/study/storage/matrix_profile.py b/antarest/study/storage/matrix_profile.py new file mode 100644 index 0000000000..55833d0382 --- /dev/null +++ b/antarest/study/storage/matrix_profile.py @@ -0,0 +1,160 @@ +import copy +import typing as t +from pathlib import Path + +import pandas as pd + + +class MatrixProfile(t.NamedTuple): + """ + Matrix profile for time series or classic tables. + """ + + cols: t.Sequence[str] + rows: t.Sequence[str] + stats: bool + + def handle_specific_matrices( + self, + df: pd.DataFrame, + matrix_path: str, + *, + with_index: bool, + with_header: bool, + ) -> pd.DataFrame: + if with_header: + if Path(matrix_path).parts[1] == "links": + cols = self.handle_links_columns(matrix_path) + else: + cols = self.cols + if cols: + df.columns = pd.Index(cols) + rows = self.rows + if with_index and rows: + df.index = rows # type: ignore + return df + + def handle_links_columns(self, matrix_path: str) -> t.Sequence[str]: + path_parts = Path(matrix_path).parts + area_id_1 = path_parts[2] + area_id_2 = path_parts[3] + result = list(self.cols) + for k, col in enumerate(result): + if col == "Hurdle costs direct": + result[k] = f"{col} ({area_id_1}->{area_id_2})" + elif col == "Hurdle costs indirect": + result[k] = f"{col} ({area_id_2}->{area_id_1})" + return result + + +# noinspection SpellCheckingInspection +SPECIFIC_MATRICES = { + "input/hydro/common/capacity/creditmodulations_*": MatrixProfile( + cols=[str(i) for i in range(101)], + rows=["Generating Power", "Pumping Power"], + stats=False, + ), + "input/hydro/common/capacity/maxpower_*": MatrixProfile( + cols=[ + "Generating Max Power (MW)", + "Generating Max Energy (Hours at Pmax)", + "Pumping Max Power (MW)", + "Pumping Max Energy (Hours at Pmax)", + ], + rows=[], + stats=False, + ), + "input/hydro/common/capacity/reservoir_*": MatrixProfile( + cols=["Lev Low (p.u)", "Lev Avg (p.u)", "Lev High (p.u)"], + rows=[], + stats=False, + ), + "input/hydro/common/capacity/waterValues_*": MatrixProfile( + cols=[f"{i}%" for i in range(101)], + rows=[], + stats=False, + ), + "input/hydro/series/*/mod": MatrixProfile(cols=[], rows=[], stats=True), + "input/hydro/series/*/ror": MatrixProfile(cols=[], rows=[], stats=True), + "input/hydro/common/capacity/inflowPattern_*": MatrixProfile(cols=["Inflow Pattern (X)"], rows=[], stats=False), + "input/hydro/prepro/*/energy": MatrixProfile( + cols=["Expectation (MWh)", "Std Deviation (MWh)", "Min. (MWh)", "Max. (MWh)", "ROR Share"], + rows=[ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ], + stats=False, + ), + "input/thermal/prepro/*/*/modulation": MatrixProfile( + cols=["Marginal cost modulation", "Market bid modulation", "Capacity modulation", "Min gen modulation"], + rows=[], + stats=False, + ), + "input/thermal/prepro/*/*/data": MatrixProfile( + cols=["FO Duration", "PO Duration", "FO Rate", "PO Rate", "NPO Min", "NPO Max"], + rows=[], + stats=False, + ), + "input/reserves/*": MatrixProfile( + cols=["Primary Res. (draft)", "Strategic Res. (draft)", "DSM", "Day Ahead"], + rows=[], + stats=False, + ), + "input/misc-gen/miscgen-*": MatrixProfile( + cols=["CHP", "Bio Mass", "Bio Gaz", "Waste", "GeoThermal", "Other", "PSP", "ROW Balance"], + rows=[], + stats=False, + ), + "input/bindingconstraints/*": MatrixProfile(cols=["<", ">", "="], rows=[], stats=False), + "input/links/*/*": MatrixProfile( + cols=[ + "Capacités de transmission directes", + "Capacités de transmission indirectes", + "Hurdle costs direct", + "Hurdle costs indirect", + "Impedances", + "Loop flow", + "P.Shift Min", + "P.Shift Max", + ], + rows=[], + stats=False, + ), +} + +SPECIFIC_MATRICES_820 = copy.deepcopy(SPECIFIC_MATRICES) +SPECIFIC_MATRICES_820["input/links/*/*"] = MatrixProfile( + cols=[ + "Hurdle costs direct", + "Hurdle costs indirect", + "Impedances", + "Loop flow", + "P.Shift Min", + "P.Shift Max", + ], + rows=[], + stats=False, +) + +SPECIFIC_MATRICES_870 = copy.deepcopy(SPECIFIC_MATRICES_820) +# noinspection SpellCheckingInspection +SPECIFIC_MATRICES_870["input/bindingconstraints/*"] = MatrixProfile(cols=[], rows=[], stats=False) + + +def get_matrix_profiles_by_version(study_version: int) -> t.Dict[str, MatrixProfile]: + if study_version < 820: + return SPECIFIC_MATRICES + elif study_version < 870: + return SPECIFIC_MATRICES_820 + else: + return SPECIFIC_MATRICES_870 diff --git a/antarest/study/storage/utils.py b/antarest/study/storage/utils.py index 65a33ac1f0..365eb1f370 100644 --- a/antarest/study/storage/utils.py +++ b/antarest/study/storage/utils.py @@ -1,5 +1,4 @@ import calendar -import copy import logging import math import os @@ -249,128 +248,6 @@ def assert_permission( DAY_NAMES = calendar.day_name[:] -def _generate_columns(column_suffix: str) -> t.List[str]: - return [f"{i}{column_suffix}" for i in range(101)] - - -class MatrixProfile(t.NamedTuple): - """ - Matrix profile for time series or classic tables. - """ - - cols: t.List[str] - rows: t.List[str] - stats: bool - - -SPECIFIC_MATRICES = { - "input/hydro/common/capacity/creditmodulations_*": MatrixProfile( - cols=_generate_columns(""), - rows=["Generating Power", "Pumping Power"], - stats=False, - ), - "input/hydro/common/capacity/maxpower_*": MatrixProfile( - cols=[ - "Generating Max Power (MW)", - "Generating Max Energy (Hours at Pmax)", - "Pumping Max Power (MW)", - "Pumping Max Energy (Hours at Pmax)", - ], - rows=[], - stats=False, - ), - "input/hydro/common/capacity/reservoir_*": MatrixProfile( - cols=["Lev Low (p.u)", "Lev Avg (p.u)", "Lev High (p.u)"], - rows=[], - stats=False, - ), - "input/hydro/common/capacity/waterValues_*": MatrixProfile(cols=_generate_columns("%"), rows=[], stats=False), - "input/hydro/series/*/mod": MatrixProfile(cols=[], rows=[], stats=True), - "input/hydro/series/*/ror": MatrixProfile(cols=[], rows=[], stats=True), - "input/hydro/common/capacity/inflowPattern_*": MatrixProfile(cols=["Inflow Pattern (X)"], rows=[], stats=False), - "input/hydro/prepro/*/energy": MatrixProfile( - cols=["Expectation (MWh)", "Std Deviation (MWh)", "Min. (MWh)", "Max. (MWh)", "ROR Share"], - rows=[ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", - ], - stats=False, - ), - "input/thermal/prepro/*/*/modulation": MatrixProfile( - cols=["Marginal cost modulation", "Market bid modulation", "Capacity modulation", "Min gen modulation"], - rows=[], - stats=False, - ), - "input/thermal/prepro/*/*/data": MatrixProfile( - cols=["FO Duration", "PO Duration", "FO Rate", "PO Rate", "NPO Min", "NPO Max"], - rows=[], - stats=False, - ), - "input/reserves/*": MatrixProfile( - cols=["Primary Res. (draft)", "Strategic Res. (draft)", "DSM", "Day Ahead"], - rows=[], - stats=False, - ), - "input/misc-gen/miscgen-*": MatrixProfile( - cols=["CHP", "Bio Mass", "Bio Gaz", "Waste", "GeoThermal", "Other", "PSP", "ROW Balance"], - rows=[], - stats=False, - ), - "input/bindingconstraints/*": MatrixProfile(cols=["<", ">", "="], rows=[], stats=False), - "input/links/*/*": MatrixProfile( - cols=[ - "Capacités de transmission directes", - "Capacités de transmission indirectes", - "Hurdle costs direct", - "Hurdle costs indirect", - "Impedances", - "Loop flow", - "P.Shift Min", - "P.Shift Max", - ], - rows=[], - stats=False, - ), -} - - -SPECIFIC_MATRICES_820 = copy.deepcopy(SPECIFIC_MATRICES) -SPECIFIC_MATRICES_820["input/links/*/*"] = MatrixProfile( - cols=[ - "Hurdle costs direct", - "Hurdle costs indirect", - "Impedances", - "Loop flow", - "P.Shift Min", - "P.Shift Max", - ], - rows=[], - stats=False, -) - -SPECIFIC_MATRICES_870 = copy.deepcopy(SPECIFIC_MATRICES_820) -SPECIFIC_MATRICES_870["input/bindingconstraints/*"] = MatrixProfile(cols=[], rows=[], stats=False) - - -def get_matrix_profile_by_version(study_version: int) -> t.Dict[str, MatrixProfile]: - if study_version < 820: - return SPECIFIC_MATRICES - elif study_version < 870: - return SPECIFIC_MATRICES_820 - else: - return SPECIFIC_MATRICES_870 - - def get_start_date( file_study: FileStudy, output_id: t.Optional[str] = None, From da198d825620dbfbb14d24458eb5b46bbe1ab3e1 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 9 Feb 2024 01:06:59 +0100 Subject: [PATCH 012/248] refactor(api-download): simplify implementation of `SPECIFIC_MATRICES` --- antarest/study/storage/matrix_profile.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/antarest/study/storage/matrix_profile.py b/antarest/study/storage/matrix_profile.py index 55833d0382..24efebfeb2 100644 --- a/antarest/study/storage/matrix_profile.py +++ b/antarest/study/storage/matrix_profile.py @@ -1,3 +1,4 @@ +import calendar import copy import typing as t from pathlib import Path @@ -79,20 +80,7 @@ def handle_links_columns(self, matrix_path: str) -> t.Sequence[str]: "input/hydro/common/capacity/inflowPattern_*": MatrixProfile(cols=["Inflow Pattern (X)"], rows=[], stats=False), "input/hydro/prepro/*/energy": MatrixProfile( cols=["Expectation (MWh)", "Std Deviation (MWh)", "Min. (MWh)", "Max. (MWh)", "ROR Share"], - rows=[ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", - ], + rows=calendar.month_name[1:], stats=False, ), "input/thermal/prepro/*/*/modulation": MatrixProfile( From bff6b0cf8af7daf1acde24631303a471e836a726 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 9 Feb 2024 01:07:10 +0100 Subject: [PATCH 013/248] refactor(api-download): simplify unit tests --- .../raw_studies_blueprint/test_download_matrices.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/integration/raw_studies_blueprint/test_download_matrices.py b/tests/integration/raw_studies_blueprint/test_download_matrices.py index 14b8aa9a58..3a565ecc84 100644 --- a/tests/integration/raw_studies_blueprint/test_download_matrices.py +++ b/tests/integration/raw_studies_blueprint/test_download_matrices.py @@ -183,8 +183,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st content = io.BytesIO(res.content) dataframe = pd.read_csv(content, index_col=0, sep="\t") assert list(dataframe.index) == list(dataframe.columns) == ["de", "es", "fr", "it"] - for i in range((len(dataframe))): - assert dataframe.iloc[i, i] == 1.0 + assert all(dataframe.iloc[i, i] == 1.0 for i in range(len(dataframe))) # test for empty matrix res = client.get( From 5a868ceae13e40802133e5c34dc9ae160c7f55d3 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 9 Feb 2024 01:09:04 +0100 Subject: [PATCH 014/248] refactor(api-download): use `user_access_token` instead of the admin token in unit tests --- .../test_download_matrices.py | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/tests/integration/raw_studies_blueprint/test_download_matrices.py b/tests/integration/raw_studies_blueprint/test_download_matrices.py index 3a565ecc84..53cde0a5b2 100644 --- a/tests/integration/raw_studies_blueprint/test_download_matrices.py +++ b/tests/integration/raw_studies_blueprint/test_download_matrices.py @@ -15,8 +15,8 @@ class TestDownloadMatrices: Checks the retrieval of matrices with the endpoint GET studies/uuid/raw/download """ - def test_download_matrices(self, client: TestClient, admin_access_token: str, study_id: str) -> None: - admin_headers = {"Authorization": f"Bearer {admin_access_token}"} + def test_download_matrices(self, client: TestClient, user_access_token: str, study_id: str) -> None: + user_headers = {"Authorization": f"Bearer {user_access_token}"} # ============================= # STUDIES PREPARATION @@ -25,20 +25,20 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st # Manage parent study and upgrades it to v8.2 # This is done to test matrix headers according to different versions copied = client.post( - f"/v1/studies/{study_id}/copy", params={"dest": "copied", "use_task": False}, headers=admin_headers + f"/v1/studies/{study_id}/copy", params={"dest": "copied", "use_task": False}, headers=user_headers ) parent_id = copied.json() - res = client.put(f"/v1/studies/{parent_id}/upgrade", params={"target_version": 820}, headers=admin_headers) + res = client.put(f"/v1/studies/{parent_id}/upgrade", params={"target_version": 820}, headers=user_headers) assert res.status_code == 200 task_id = res.json() assert task_id - task = wait_task_completion(client, admin_access_token, task_id, timeout=20) + task = wait_task_completion(client, user_access_token, task_id, timeout=20) assert task.status == TaskStatus.COMPLETED # Create Variant res = client.post( f"/v1/studies/{parent_id}/variants", - headers=admin_headers, + headers=user_headers, params={"name": "variant_1"}, ) assert res.status_code == 200 @@ -48,19 +48,19 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st area_name = "new_area" res = client.post( f"/v1/studies/{variant_id}/areas", - headers=admin_headers, + headers=user_headers, json={"name": area_name, "type": "AREA", "metadata": {"country": "FR"}}, ) assert res.status_code in {200, 201} # Change study start_date res = client.put( - f"/v1/studies/{variant_id}/config/general/form", json={"firstMonth": "july"}, headers=admin_headers + f"/v1/studies/{variant_id}/config/general/form", json={"firstMonth": "july"}, headers=user_headers ) assert res.status_code == 200 # Really generates the snapshot - client.get(f"/v1/studies/{variant_id}/areas", headers=admin_headers) + client.get(f"/v1/studies/{variant_id}/areas", headers=user_headers) assert res.status_code == 200 # ============================= @@ -76,7 +76,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st res = client.get( f"/v1/studies/{uuid}/raw/download", params={"path": path}, - headers=admin_headers, + headers=user_headers, ) assert res.status_code == 200 # noinspection SpellCheckingInspection @@ -104,7 +104,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st actual_matrix = dataframe.to_dict(orient="split") # asserts that the result is the same as the one we get with the classic get /raw endpoint - res = client.get(f"/v1/studies/{uuid}/raw", params={"path": path, "formatted": True}, headers=admin_headers) + res = client.get(f"/v1/studies/{uuid}/raw", params={"path": path, "formatted": True}, headers=user_headers) expected_matrix = res.json() assert actual_matrix == expected_matrix @@ -119,7 +119,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st res = client.get( f"/v1/studies/{parent_id}/raw/download", params={"path": raw_matrix_path, "format": "TSV", "header": header, "index": index}, - headers=admin_headers, + headers=user_headers, ) assert res.status_code == 200 assert res.headers["content-type"] == "text/tab-separated-values; charset=utf-8" @@ -140,7 +140,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st res = client.get( f"/v1/studies/{study_id}/raw/download", params={"path": "input/links/de/fr", "format": "tsv", "index": False}, - headers=admin_headers, + headers=user_headers, ) assert res.status_code == 200 content = io.BytesIO(res.content) @@ -160,7 +160,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st res = client.get( f"/v1/studies/{parent_id}/raw/download", params={"path": "input/links/de/fr_parameters", "format": "tsv"}, - headers=admin_headers, + headers=user_headers, ) assert res.status_code == 200 content = io.BytesIO(res.content) @@ -177,7 +177,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st # allocation and correlation matrices for path in ["input/hydro/allocation", "input/hydro/correlation"]: res = client.get( - f"/v1/studies/{parent_id}/raw/download", params={"path": path, "format": "tsv"}, headers=admin_headers + f"/v1/studies/{parent_id}/raw/download", params={"path": path, "format": "tsv"}, headers=user_headers ) assert res.status_code == 200 content = io.BytesIO(res.content) @@ -189,7 +189,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st res = client.get( f"/v1/studies/{study_id}/raw/download", params={"path": "input/hydro/common/capacity/waterValues_de", "format": "tsv"}, - headers=admin_headers, + headers=user_headers, ) assert res.status_code == 200 content = io.BytesIO(res.content) @@ -200,7 +200,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st res = client.get( f"/v1/studies/{parent_id}/raw/download", params={"path": "input/thermal/prepro/de/01_solar/modulation", "format": "tsv"}, - headers=admin_headers, + headers=user_headers, ) assert res.status_code == 200 content = io.BytesIO(res.content) @@ -222,7 +222,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st "path": "output/20201014-1422eco-hello/economy/mc-ind/00001/links/de/fr/values-hourly", "format": "tsv", }, - headers=admin_headers, + headers=user_headers, ) assert res.status_code == 200 content = io.BytesIO(res.content) @@ -245,7 +245,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st res = client.get( f"/v1/studies/{study_id}/raw/download", params={"path": "input/hydro/prepro/de/energy", "format": "tsv"}, - headers=admin_headers, + headers=user_headers, ) assert res.status_code == 200 content = io.BytesIO(res.content) @@ -262,7 +262,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st res = client.get( f"/v1/studies/{fake_str}/raw/download", params={"path": raw_matrix_path, "format": "tsv"}, - headers=admin_headers, + headers=user_headers, ) assert res.status_code == 404 assert res.json()["exception"] == "StudyNotFoundError" @@ -271,7 +271,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st res = client.get( f"/v1/studies/{parent_id}/raw/download", params={"path": f"input/links/de/{fake_str}", "format": "tsv"}, - headers=admin_headers, + headers=user_headers, ) assert res.status_code == 404 assert res.json()["exception"] == "ChildNotFoundError" @@ -280,7 +280,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st res = client.get( f"/v1/studies/{parent_id}/raw/download", params={"path": "settings/generaldata", "format": "tsv"}, - headers=admin_headers, + headers=user_headers, ) assert res.status_code == 404 assert res.json()["exception"] == "IncorrectPathError" @@ -290,7 +290,7 @@ def test_download_matrices(self, client: TestClient, admin_access_token: str, st res = client.get( f"/v1/studies/{parent_id}/raw/download", params={"path": raw_matrix_path, "format": fake_str}, - headers=admin_headers, + headers=user_headers, ) assert res.status_code == 422 assert res.json()["exception"] == "RequestValidationError" From 56802d47790a7912d625f65275e055c2a3684f8c Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 9 Feb 2024 01:10:22 +0100 Subject: [PATCH 015/248] refactor(api-download): correct spelling of the month name "July" --- .../raw_studies_blueprint/test_download_matrices.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/integration/raw_studies_blueprint/test_download_matrices.py b/tests/integration/raw_studies_blueprint/test_download_matrices.py index 53cde0a5b2..00bfc4a767 100644 --- a/tests/integration/raw_studies_blueprint/test_download_matrices.py +++ b/tests/integration/raw_studies_blueprint/test_download_matrices.py @@ -55,7 +55,9 @@ def test_download_matrices(self, client: TestClient, user_access_token: str, stu # Change study start_date res = client.put( - f"/v1/studies/{variant_id}/config/general/form", json={"firstMonth": "july"}, headers=user_headers + f"/v1/studies/{variant_id}/config/general/form", + json={"firstMonth": "July"}, + headers=user_headers, ) assert res.status_code == 200 From af99e6ba063dca105b75403353aa102dc2d84f72 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 9 Feb 2024 01:11:35 +0100 Subject: [PATCH 016/248] refactor(api-download): add missing "res" variable in unit tests --- .../integration/raw_studies_blueprint/test_download_matrices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/raw_studies_blueprint/test_download_matrices.py b/tests/integration/raw_studies_blueprint/test_download_matrices.py index 00bfc4a767..f8d89f282f 100644 --- a/tests/integration/raw_studies_blueprint/test_download_matrices.py +++ b/tests/integration/raw_studies_blueprint/test_download_matrices.py @@ -62,7 +62,7 @@ def test_download_matrices(self, client: TestClient, user_access_token: str, stu assert res.status_code == 200 # Really generates the snapshot - client.get(f"/v1/studies/{variant_id}/areas", headers=user_headers) + res = client.get(f"/v1/studies/{variant_id}/areas", headers=user_headers) assert res.status_code == 200 # ============================= From 5049503e3defb2f088be8a902debc25fa5f6eb85 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 9 Feb 2024 01:17:39 +0100 Subject: [PATCH 017/248] refactor(api-download): turn `_SPECIFIC_MATRICES` into a protected variable --- antarest/study/storage/matrix_profile.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/antarest/study/storage/matrix_profile.py b/antarest/study/storage/matrix_profile.py index 24efebfeb2..b2f8bb4ab9 100644 --- a/antarest/study/storage/matrix_profile.py +++ b/antarest/study/storage/matrix_profile.py @@ -49,7 +49,7 @@ def handle_links_columns(self, matrix_path: str) -> t.Sequence[str]: # noinspection SpellCheckingInspection -SPECIFIC_MATRICES = { +_SPECIFIC_MATRICES = { "input/hydro/common/capacity/creditmodulations_*": MatrixProfile( cols=[str(i) for i in range(101)], rows=["Generating Power", "Pumping Power"], @@ -120,8 +120,8 @@ def handle_links_columns(self, matrix_path: str) -> t.Sequence[str]: ), } -SPECIFIC_MATRICES_820 = copy.deepcopy(SPECIFIC_MATRICES) -SPECIFIC_MATRICES_820["input/links/*/*"] = MatrixProfile( +_SPECIFIC_MATRICES_820 = copy.deepcopy(_SPECIFIC_MATRICES) +_SPECIFIC_MATRICES_820["input/links/*/*"] = MatrixProfile( cols=[ "Hurdle costs direct", "Hurdle costs indirect", @@ -134,15 +134,15 @@ def handle_links_columns(self, matrix_path: str) -> t.Sequence[str]: stats=False, ) -SPECIFIC_MATRICES_870 = copy.deepcopy(SPECIFIC_MATRICES_820) +_SPECIFIC_MATRICES_870 = copy.deepcopy(_SPECIFIC_MATRICES_820) # noinspection SpellCheckingInspection -SPECIFIC_MATRICES_870["input/bindingconstraints/*"] = MatrixProfile(cols=[], rows=[], stats=False) +_SPECIFIC_MATRICES_870["input/bindingconstraints/*"] = MatrixProfile(cols=[], rows=[], stats=False) def get_matrix_profiles_by_version(study_version: int) -> t.Dict[str, MatrixProfile]: if study_version < 820: - return SPECIFIC_MATRICES + return _SPECIFIC_MATRICES elif study_version < 870: - return SPECIFIC_MATRICES_820 + return _SPECIFIC_MATRICES_820 else: - return SPECIFIC_MATRICES_870 + return _SPECIFIC_MATRICES_870 From 27af88ba0d22095e15c2d2b84896cf33cfb2d14d Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 9 Feb 2024 08:46:37 +0100 Subject: [PATCH 018/248] refactor(api-download): simplify and document the `matrix_profile` module --- antarest/study/service.py | 19 ++-- antarest/study/storage/matrix_profile.py | 124 +++++++++++++++-------- 2 files changed, 89 insertions(+), 54 deletions(-) diff --git a/antarest/study/service.py b/antarest/study/service.py index 05c7b24595..910cf62027 100644 --- a/antarest/study/service.py +++ b/antarest/study/service.py @@ -1,6 +1,5 @@ import base64 import contextlib -import fnmatch import io import json import logging @@ -97,7 +96,7 @@ StudySimResultDTO, ) from antarest.study.repository import StudyFilter, StudyMetadataRepository, StudyPagination, StudySortBy -from antarest.study.storage.matrix_profile import get_matrix_profiles_by_version +from antarest.study.storage.matrix_profile import adjust_matrix_columns_index from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfigDTO from antarest.study.storage.rawstudy.model.filesystem.folder_node import ChildNotFoundError from antarest.study.storage.rawstudy.model.filesystem.ini_file_node import IniFileNode @@ -2417,14 +2416,12 @@ def get_matrix_with_index_and_header( ) df_matrix.index = time_column - matrix_profiles = get_matrix_profiles_by_version(int(study.version)) - for pattern, matrix_profile in matrix_profiles.items(): - if fnmatch.fnmatch(path, pattern): - return matrix_profile.handle_specific_matrices( - df_matrix, - path, - with_index=with_index, - with_header=with_header, - ) + adjust_matrix_columns_index( + df_matrix, + path, + with_index=with_index, + with_header=with_header, + study_version=int(study.version), + ) return df_matrix diff --git a/antarest/study/storage/matrix_profile.py b/antarest/study/storage/matrix_profile.py index b2f8bb4ab9..080b5ffe13 100644 --- a/antarest/study/storage/matrix_profile.py +++ b/antarest/study/storage/matrix_profile.py @@ -1,31 +1,42 @@ import calendar import copy +import fnmatch import typing as t from pathlib import Path import pandas as pd -class MatrixProfile(t.NamedTuple): +class _MatrixProfile(t.NamedTuple): """ - Matrix profile for time series or classic tables. + Matrix profile for time series or specific matrices. """ cols: t.Sequence[str] rows: t.Sequence[str] - stats: bool - def handle_specific_matrices( + def process_dataframe( self, df: pd.DataFrame, matrix_path: str, *, with_index: bool, with_header: bool, - ) -> pd.DataFrame: + ) -> None: + """ + Adjust the column names and index of a dataframe according to the matrix profile. + + *NOTE:* The modification is done in place. + + Args: + df: The dataframe to process. + matrix_path: The path of the matrix file, relative to the study directory. + with_index: Whether to set the index of the dataframe. + with_header: Whether to set the column names of the dataframe. + """ if with_header: if Path(matrix_path).parts[1] == "links": - cols = self.handle_links_columns(matrix_path) + cols = self._process_links_columns(matrix_path) else: cols = self.cols if cols: @@ -33,29 +44,36 @@ def handle_specific_matrices( rows = self.rows if with_index and rows: df.index = rows # type: ignore - return df - def handle_links_columns(self, matrix_path: str) -> t.Sequence[str]: + def _process_links_columns(self, matrix_path: str) -> t.Sequence[str]: + """Process column names specific to the links matrices.""" path_parts = Path(matrix_path).parts - area_id_1 = path_parts[2] - area_id_2 = path_parts[3] + area1_id = path_parts[2] + area2_id = path_parts[3] result = list(self.cols) for k, col in enumerate(result): if col == "Hurdle costs direct": - result[k] = f"{col} ({area_id_1}->{area_id_2})" + result[k] = f"{col} ({area1_id}->{area2_id})" elif col == "Hurdle costs indirect": - result[k] = f"{col} ({area_id_2}->{area_id_1})" + result[k] = f"{col} ({area2_id}->{area1_id})" return result +_SPECIFIC_MATRICES: t.Dict[str, _MatrixProfile] +""" +The dictionary ``_SPECIFIC_MATRICES`` maps file patterns to ``_MatrixProfile`` objects, +representing non-time series matrices. +It's used in the `adjust_matrix_columns_index` method to fetch matrix profiles based on study versions. +""" + + # noinspection SpellCheckingInspection _SPECIFIC_MATRICES = { - "input/hydro/common/capacity/creditmodulations_*": MatrixProfile( + "input/hydro/common/capacity/creditmodulations_*": _MatrixProfile( cols=[str(i) for i in range(101)], rows=["Generating Power", "Pumping Power"], - stats=False, ), - "input/hydro/common/capacity/maxpower_*": MatrixProfile( + "input/hydro/common/capacity/maxpower_*": _MatrixProfile( cols=[ "Generating Max Power (MW)", "Generating Max Energy (Hours at Pmax)", @@ -63,48 +81,40 @@ def handle_links_columns(self, matrix_path: str) -> t.Sequence[str]: "Pumping Max Energy (Hours at Pmax)", ], rows=[], - stats=False, ), - "input/hydro/common/capacity/reservoir_*": MatrixProfile( + "input/hydro/common/capacity/reservoir_*": _MatrixProfile( cols=["Lev Low (p.u)", "Lev Avg (p.u)", "Lev High (p.u)"], rows=[], - stats=False, ), - "input/hydro/common/capacity/waterValues_*": MatrixProfile( + "input/hydro/common/capacity/waterValues_*": _MatrixProfile( cols=[f"{i}%" for i in range(101)], rows=[], - stats=False, ), - "input/hydro/series/*/mod": MatrixProfile(cols=[], rows=[], stats=True), - "input/hydro/series/*/ror": MatrixProfile(cols=[], rows=[], stats=True), - "input/hydro/common/capacity/inflowPattern_*": MatrixProfile(cols=["Inflow Pattern (X)"], rows=[], stats=False), - "input/hydro/prepro/*/energy": MatrixProfile( + "input/hydro/series/*/mod": _MatrixProfile(cols=[], rows=[]), + "input/hydro/series/*/ror": _MatrixProfile(cols=[], rows=[]), + "input/hydro/common/capacity/inflowPattern_*": _MatrixProfile(cols=["Inflow Pattern (X)"], rows=[]), + "input/hydro/prepro/*/energy": _MatrixProfile( cols=["Expectation (MWh)", "Std Deviation (MWh)", "Min. (MWh)", "Max. (MWh)", "ROR Share"], rows=calendar.month_name[1:], - stats=False, ), - "input/thermal/prepro/*/*/modulation": MatrixProfile( + "input/thermal/prepro/*/*/modulation": _MatrixProfile( cols=["Marginal cost modulation", "Market bid modulation", "Capacity modulation", "Min gen modulation"], rows=[], - stats=False, ), - "input/thermal/prepro/*/*/data": MatrixProfile( + "input/thermal/prepro/*/*/data": _MatrixProfile( cols=["FO Duration", "PO Duration", "FO Rate", "PO Rate", "NPO Min", "NPO Max"], rows=[], - stats=False, ), - "input/reserves/*": MatrixProfile( + "input/reserves/*": _MatrixProfile( cols=["Primary Res. (draft)", "Strategic Res. (draft)", "DSM", "Day Ahead"], rows=[], - stats=False, ), - "input/misc-gen/miscgen-*": MatrixProfile( + "input/misc-gen/miscgen-*": _MatrixProfile( cols=["CHP", "Bio Mass", "Bio Gaz", "Waste", "GeoThermal", "Other", "PSP", "ROW Balance"], rows=[], - stats=False, ), - "input/bindingconstraints/*": MatrixProfile(cols=["<", ">", "="], rows=[], stats=False), - "input/links/*/*": MatrixProfile( + "input/bindingconstraints/*": _MatrixProfile(cols=["<", ">", "="], rows=[]), + "input/links/*/*": _MatrixProfile( cols=[ "Capacités de transmission directes", "Capacités de transmission indirectes", @@ -116,12 +126,11 @@ def handle_links_columns(self, matrix_path: str) -> t.Sequence[str]: "P.Shift Max", ], rows=[], - stats=False, ), } _SPECIFIC_MATRICES_820 = copy.deepcopy(_SPECIFIC_MATRICES) -_SPECIFIC_MATRICES_820["input/links/*/*"] = MatrixProfile( +_SPECIFIC_MATRICES_820["input/links/*/*"] = _MatrixProfile( cols=[ "Hurdle costs direct", "Hurdle costs indirect", @@ -131,18 +140,47 @@ def handle_links_columns(self, matrix_path: str) -> t.Sequence[str]: "P.Shift Max", ], rows=[], - stats=False, ) _SPECIFIC_MATRICES_870 = copy.deepcopy(_SPECIFIC_MATRICES_820) # noinspection SpellCheckingInspection -_SPECIFIC_MATRICES_870["input/bindingconstraints/*"] = MatrixProfile(cols=[], rows=[], stats=False) +_SPECIFIC_MATRICES_870["input/bindingconstraints/*"] = _MatrixProfile(cols=[], rows=[]) + +def adjust_matrix_columns_index( + df: pd.DataFrame, matrix_path: str, with_index: bool, with_header: bool, study_version: int +) -> None: + """ + Adjust the column names and index of a dataframe according to the matrix profile. + + *NOTE:* The modification is done in place. -def get_matrix_profiles_by_version(study_version: int) -> t.Dict[str, MatrixProfile]: + Args: + df: The dataframe to process. + matrix_path: The path of the matrix file, relative to the study directory. + with_index: Whether to set the index of the dataframe. + with_header: Whether to set the column names of the dataframe. + study_version: The version of the study. + """ + # Get the matrix profiles for a given study version if study_version < 820: - return _SPECIFIC_MATRICES + matrix_profiles = _SPECIFIC_MATRICES elif study_version < 870: - return _SPECIFIC_MATRICES_820 + matrix_profiles = _SPECIFIC_MATRICES_820 else: - return _SPECIFIC_MATRICES_870 + matrix_profiles = _SPECIFIC_MATRICES_870 + + # Apply the matrix profile to the dataframe to adjust the column names and index + for pattern, matrix_profile in matrix_profiles.items(): + if fnmatch.fnmatch(matrix_path, pattern): + matrix_profile.process_dataframe( + df, + matrix_path, + with_index=with_index, + with_header=with_header, + ) + return + + # The matrix may be a time series, in which case we don't need to adjust anything + # (the "Time" columns is already the index) + return None From 511df6986b2003bee5a841490f7d6537d889b299 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Mon, 12 Feb 2024 11:49:08 +0100 Subject: [PATCH 019/248] refactor(api-download): add missing header to classic times series - Ensure time series have "TS-NNN" headers. - Output headers are not changed. --- antarest/study/storage/matrix_profile.py | 33 ++- .../test_download_matrices.py | 220 +++++++++++++----- 2 files changed, 190 insertions(+), 63 deletions(-) diff --git a/antarest/study/storage/matrix_profile.py b/antarest/study/storage/matrix_profile.py index 080b5ffe13..7dc137dc10 100644 --- a/antarest/study/storage/matrix_profile.py +++ b/antarest/study/storage/matrix_profile.py @@ -41,9 +41,11 @@ def process_dataframe( cols = self.cols if cols: df.columns = pd.Index(cols) - rows = self.rows - if with_index and rows: - df.index = rows # type: ignore + else: + df.columns = pd.Index([f"TS-{i}" for i in range(1, len(df.columns) + 1)]) + + if with_index and self.rows: + df.index = pd.Index(self.rows) def _process_links_columns(self, matrix_path: str) -> t.Sequence[str]: """Process column names specific to the links matrices.""" @@ -83,6 +85,7 @@ def _process_links_columns(self, matrix_path: str) -> t.Sequence[str]: rows=[], ), "input/hydro/common/capacity/reservoir_*": _MatrixProfile( + # Values are displayed in % in the UI, but the actual values are in p.u. (per unit) cols=["Lev Low (p.u)", "Lev Avg (p.u)", "Lev High (p.u)"], rows=[], ), @@ -130,6 +133,8 @@ def _process_links_columns(self, matrix_path: str) -> t.Sequence[str]: } _SPECIFIC_MATRICES_820 = copy.deepcopy(_SPECIFIC_MATRICES) +"""Specific matrices for study version 8.2.""" + _SPECIFIC_MATRICES_820["input/links/*/*"] = _MatrixProfile( cols=[ "Hurdle costs direct", @@ -142,8 +147,19 @@ def _process_links_columns(self, matrix_path: str) -> t.Sequence[str]: rows=[], ) +# Specific matrices for study version 8.6 +_SPECIFIC_MATRICES_860 = copy.deepcopy(_SPECIFIC_MATRICES_820) +"""Specific matrices for study version 8.6.""" + +# noinspection SpellCheckingInspection +# +_SPECIFIC_MATRICES_860["input/hydro/series/*/mingen"] = _MatrixProfile(cols=[], rows=[]) + _SPECIFIC_MATRICES_870 = copy.deepcopy(_SPECIFIC_MATRICES_820) +"""Specific matrices for study version 8.7.""" + # noinspection SpellCheckingInspection +# Scenarized RHS for binding constraints _SPECIFIC_MATRICES_870["input/bindingconstraints/*"] = _MatrixProfile(cols=[], rows=[]) @@ -165,8 +181,10 @@ def adjust_matrix_columns_index( # Get the matrix profiles for a given study version if study_version < 820: matrix_profiles = _SPECIFIC_MATRICES - elif study_version < 870: + elif study_version < 860: matrix_profiles = _SPECIFIC_MATRICES_820 + elif study_version < 870: + matrix_profiles = _SPECIFIC_MATRICES_860 else: matrix_profiles = _SPECIFIC_MATRICES_870 @@ -181,6 +199,13 @@ def adjust_matrix_columns_index( ) return + if fnmatch.fnmatch(matrix_path, "output/*"): + # Outputs already have their own column names + return + # The matrix may be a time series, in which case we don't need to adjust anything # (the "Time" columns is already the index) + # Column names should be Monte-Carlo years: "TS-1", "TS-2", ... + df.columns = pd.Index([f"TS-{i}" for i in range(1, len(df.columns) + 1)]) + return None diff --git a/tests/integration/raw_studies_blueprint/test_download_matrices.py b/tests/integration/raw_studies_blueprint/test_download_matrices.py index f8d89f282f..ca2c501374 100644 --- a/tests/integration/raw_studies_blueprint/test_download_matrices.py +++ b/tests/integration/raw_studies_blueprint/test_download_matrices.py @@ -1,4 +1,6 @@ +import datetime import io +import typing as t import numpy as np import pandas as pd @@ -9,6 +11,96 @@ from tests.integration.utils import wait_task_completion +class Proxy: + def __init__(self, client: TestClient, user_access_token: str): + self.client = client + self.user_access_token = user_access_token + self.headers = {"Authorization": f"Bearer {user_access_token}"} + + +class PreparerProxy(Proxy): + def copy_upgrade_study(self, ref_study_id, target_version=820): + """ + Copy a study in the managed workspace and upgrade it to a specific version + """ + # Prepare a managed study to test specific matrices for version 8.2 + res = self.client.post( + f"/v1/studies/{ref_study_id}/copy", + params={"dest": "copied-820", "use_task": False}, + headers=self.headers, + ) + res.raise_for_status() + study_820_id = res.json() + + res = self.client.put( + f"/v1/studies/{study_820_id}/upgrade", + params={"target_version": target_version}, + headers=self.headers, + ) + res.raise_for_status() + task_id = res.json() + assert task_id + + task = wait_task_completion(self.client, self.user_access_token, task_id, timeout=20) + assert task.status == TaskStatus.COMPLETED + return study_820_id + + def upload_matrix(self, 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) + # noinspection SpellCheckingInspection + res = self.client.put( + f"/v1/studies/{study_id}/raw", + params={"path": matrix_path, "create_missing": True}, + headers=self.headers, + files={"file": tsv, "create_missing": "true"}, + ) + res.raise_for_status() + + def create_variant(self, parent_id: str, *, name: str) -> str: + res = self.client.post( + f"/v1/studies/{parent_id}/variants", + headers=self.headers, + params={"name": name}, + ) + res.raise_for_status() + variant_id = res.json() + return variant_id + + def generate_snapshot(self, variant_id: str, denormalize=False, from_scratch=True) -> None: + # Generate a snapshot for the variant + res = self.client.put( + f"/v1/studies/{variant_id}/generate", + headers=self.headers, + params={"denormalize": denormalize, "from_scratch": from_scratch}, + ) + res.raise_for_status() + task_id = res.json() + assert task_id + + task = wait_task_completion(self.client, self.user_access_token, task_id, timeout=20) + assert task.status == TaskStatus.COMPLETED + + def create_area(self, parent_id, *, name: str, country: str = "FR") -> str: + res = self.client.post( + f"/v1/studies/{parent_id}/areas", + headers=self.headers, + json={"name": name, "type": "AREA", "metadata": {"country": country}}, + ) + res.raise_for_status() + area_id = res.json()["id"] + return area_id + + def update_general_data(self, study_id: str, **data: t.Any): + res = self.client.put( + f"/v1/studies/{study_id}/config/general/form", + json=data, + headers=self.headers, + ) + res.raise_for_status() + + @pytest.mark.integration_test class TestDownloadMatrices: """ @@ -18,61 +110,47 @@ class TestDownloadMatrices: def test_download_matrices(self, client: TestClient, user_access_token: str, study_id: str) -> None: user_headers = {"Authorization": f"Bearer {user_access_token}"} - # ============================= + # ===================== # STUDIES PREPARATION - # ============================= + # ===================== - # Manage parent study and upgrades it to v8.2 - # This is done to test matrix headers according to different versions - copied = client.post( - f"/v1/studies/{study_id}/copy", params={"dest": "copied", "use_task": False}, headers=user_headers - ) - parent_id = copied.json() - res = client.put(f"/v1/studies/{parent_id}/upgrade", params={"target_version": 820}, headers=user_headers) - assert res.status_code == 200 - task_id = res.json() - assert task_id - task = wait_task_completion(client, user_access_token, task_id, timeout=20) - assert task.status == TaskStatus.COMPLETED + preparer = PreparerProxy(client, user_access_token) + + study_820_id = preparer.copy_upgrade_study(study_id, target_version=820) # Create Variant - res = client.post( - f"/v1/studies/{parent_id}/variants", - headers=user_headers, - params={"name": "variant_1"}, - ) - assert res.status_code == 200 - variant_id = res.json() + variant_id = preparer.create_variant(study_820_id, name="New Variant") # Create a new area to implicitly create normalized matrices - area_name = "new_area" - res = client.post( - f"/v1/studies/{variant_id}/areas", - headers=user_headers, - json={"name": area_name, "type": "AREA", "metadata": {"country": "FR"}}, - ) - assert res.status_code in {200, 201} + area_id = preparer.create_area(variant_id, name="Mayenne", country="France") # Change study start_date - res = client.put( - f"/v1/studies/{variant_id}/config/general/form", - json={"firstMonth": "July"}, - headers=user_headers, - ) - assert res.status_code == 200 + preparer.update_general_data(variant_id, firstMonth="July") # Really generates the snapshot - res = client.get(f"/v1/studies/{variant_id}/areas", headers=user_headers) - assert res.status_code == 200 + preparer.generate_snapshot(variant_id) - # ============================= + # Prepare a managed study to test specific matrices for version 8.6 + study_860_id = preparer.copy_upgrade_study(study_id, target_version=860) + + # Import a Min Gen. matrix: shape=(8760, 3), with random integers between 0 and 1000 + min_gen_df = pd.DataFrame(np.random.randint(0, 1000, size=(8760, 3))) + preparer.upload_matrix(study_860_id, "input/hydro/series/de/mingen", min_gen_df) + + # ============================================= # TESTS NOMINAL CASE ON RAW AND VARIANT STUDY - # ============================= + # ============================================= raw_matrix_path = r"input/load/series/load_de" - variant_matrix_path = f"input/load/series/load_{area_name}" + variant_matrix_path = f"input/load/series/load_{area_id}" + + raw_start_date = datetime.datetime(2018, 1, 1) + variant_start_date = datetime.datetime(2019, 7, 1) - for uuid, path in [(parent_id, raw_matrix_path), (variant_id, variant_matrix_path)]: + for uuid, path, start_date in [ + (study_820_id, raw_matrix_path, raw_start_date), + (variant_id, variant_matrix_path, variant_start_date), + ]: # Export the matrix in xlsx format (which is the default format) # and retrieve it as binary content (a ZIP-like file). res = client.get( @@ -89,26 +167,35 @@ def test_download_matrices(self, client: TestClient, user_access_token: str, stu dataframe = pd.read_excel(io.BytesIO(res.content), index_col=0) # check time coherence - generated_index = dataframe.index + actual_index = dataframe.index # noinspection PyUnresolvedReferences - first_date = generated_index[0].to_pydatetime() + first_date = actual_index[0].to_pydatetime() # noinspection PyUnresolvedReferences - second_date = generated_index[1].to_pydatetime() - assert first_date.month == second_date.month == 1 if uuid == parent_id else 7 + second_date = actual_index[1].to_pydatetime() + first_month = 1 if uuid == study_820_id else 7 # July + assert first_date.month == second_date.month == first_month assert first_date.day == second_date.day == 1 assert first_date.hour == 0 assert second_date.hour == 1 - # reformat into a json to help comparison - new_cols = [int(col) for col in dataframe.columns] - dataframe.columns = new_cols - dataframe.index = range(len(dataframe)) - actual_matrix = dataframe.to_dict(orient="split") - # asserts that the result is the same as the one we get with the classic get /raw endpoint - res = client.get(f"/v1/studies/{uuid}/raw", params={"path": path, "formatted": True}, headers=user_headers) + res = client.get( + f"/v1/studies/{uuid}/raw", + params={"path": path, "formatted": True}, + headers=user_headers, + ) expected_matrix = res.json() - assert actual_matrix == expected_matrix + expected_matrix["columns"] = [f"TS-{n + 1}" for n in expected_matrix["columns"]] + time_column = pd.date_range( + start=start_date, + periods=len(expected_matrix["data"]), + freq="H", + ) + expected_matrix["index"] = time_column + expected = pd.DataFrame(**expected_matrix) + assert dataframe.index.tolist() == expected.index.tolist() + assert dataframe.columns.tolist() == expected.columns.tolist() + assert (dataframe == expected).all().all() # ============================= # TESTS INDEX AND HEADER PARAMETERS @@ -119,7 +206,7 @@ def test_download_matrices(self, client: TestClient, user_access_token: str, stu for header in [True, False]: index = not header res = client.get( - f"/v1/studies/{parent_id}/raw/download", + f"/v1/studies/{study_820_id}/raw/download", params={"path": raw_matrix_path, "format": "TSV", "header": header, "index": index}, headers=user_headers, ) @@ -160,7 +247,7 @@ def test_download_matrices(self, client: TestClient, user_access_token: str, stu # tests links headers after v8.2 res = client.get( - f"/v1/studies/{parent_id}/raw/download", + f"/v1/studies/{study_820_id}/raw/download", params={"path": "input/links/de/fr_parameters", "format": "tsv"}, headers=user_headers, ) @@ -179,7 +266,7 @@ def test_download_matrices(self, client: TestClient, user_access_token: str, stu # allocation and correlation matrices for path in ["input/hydro/allocation", "input/hydro/correlation"]: res = client.get( - f"/v1/studies/{parent_id}/raw/download", params={"path": path, "format": "tsv"}, headers=user_headers + f"/v1/studies/{study_820_id}/raw/download", params={"path": path, "format": "tsv"}, headers=user_headers ) assert res.status_code == 200 content = io.BytesIO(res.content) @@ -200,7 +287,7 @@ def test_download_matrices(self, client: TestClient, user_access_token: str, stu # modulation matrix res = client.get( - f"/v1/studies/{parent_id}/raw/download", + f"/v1/studies/{study_820_id}/raw/download", params={"path": "input/thermal/prepro/de/01_solar/modulation", "format": "tsv"}, headers=user_headers, ) @@ -254,6 +341,21 @@ def test_download_matrices(self, client: TestClient, user_access_token: str, stu dataframe = pd.read_csv(content, index_col=0, sep="\t") assert dataframe.empty + # test the Min Gen of the 8.6 study + res = client.get( + f"/v1/studies/{study_860_id}/raw/download", + params={"path": "input/hydro/series/de/mingen", "format": "tsv"}, + headers=user_headers, + ) + assert res.status_code == 200 + content = io.BytesIO(res.content) + dataframe = pd.read_csv(content, index_col=0, sep="\t") + assert dataframe.shape == (8760, 3) + assert dataframe.columns.tolist() == ["TS-1", "TS-2", "TS-3"] + assert dataframe.index[0] == "2018-01-01 00:00:00" + # noinspection PyUnresolvedReferences + assert (dataframe.values == min_gen_df.values).all() + # ============================= # ERRORS # ============================= @@ -271,7 +373,7 @@ def test_download_matrices(self, client: TestClient, user_access_token: str, stu # fake path res = client.get( - f"/v1/studies/{parent_id}/raw/download", + f"/v1/studies/{study_820_id}/raw/download", params={"path": f"input/links/de/{fake_str}", "format": "tsv"}, headers=user_headers, ) @@ -280,7 +382,7 @@ def test_download_matrices(self, client: TestClient, user_access_token: str, stu # path that does not lead to a matrix res = client.get( - f"/v1/studies/{parent_id}/raw/download", + f"/v1/studies/{study_820_id}/raw/download", params={"path": "settings/generaldata", "format": "tsv"}, headers=user_headers, ) @@ -290,7 +392,7 @@ def test_download_matrices(self, client: TestClient, user_access_token: str, stu # wrong format res = client.get( - f"/v1/studies/{parent_id}/raw/download", + f"/v1/studies/{study_820_id}/raw/download", params={"path": raw_matrix_path, "format": fake_str}, headers=user_headers, ) From 4c60fa20ab6f1a54574bf8a2b899b31f94fad0b1 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Tue, 13 Feb 2024 17:56:41 +0100 Subject: [PATCH 020/248] ci: add commitlint GitHub action (#1933) lints Pull Request commits with commitlint --- .github/workflows/commitlint.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .github/workflows/commitlint.yml diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 0000000000..8e08ce865c --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,13 @@ +name: Lint Commit Messages +on: [pull_request] + +permissions: + contents: read + pull-requests: read + +jobs: + commitlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: wagoid/commitlint-github-action@v5 From bd76b9acd326d59c1836516ef011b8c61388da24 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Mon, 12 Feb 2024 11:51:36 +0100 Subject: [PATCH 021/248] feat(hydro): add the "Min Gen." tab for hydraulic generators --- .../explore/Modelization/Areas/Hydro/index.tsx | 4 +++- .../explore/Modelization/Areas/Hydro/utils.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) 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 289913bcbe..ffa2df3620 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 @@ -11,6 +11,7 @@ import { getCurrentAreaId } from "../../../../../../../redux/selectors"; function Hydro() { const { study } = useOutletContext<{ study: StudyMetadata }>(); const areaId = useAppSelector(getCurrentAreaId); + const studyVersion = parseInt(study.version, 10); const tabList = useMemo(() => { const basePath = `/studies/${study?.id}/explore/modelization/area/${encodeURI( @@ -30,7 +31,8 @@ function Hydro() { { label: "Water values", path: `${basePath}/watervalues` }, { label: "Hydro Storage", path: `${basePath}/hydrostorage` }, { label: "Run of river", path: `${basePath}/ror` }, - ]; + studyVersion >= 860 && { label: "Min Gen.", path: `${basePath}/mingen` }, + ].filter(Boolean); }, [areaId, study?.id]); //////////////////////////////////////////////////////////////// diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/utils.ts index 8fc143d280..2a75d23f9d 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/utils.ts @@ -15,6 +15,7 @@ export enum HydroMatrixType { WaterValues, HydroStorage, RunOfRiver, + MinGen, InflowPattern, OverallMonthlyHydro, Allocation, @@ -99,6 +100,10 @@ export const HYDRO_ROUTES: HydroRoute[] = [ path: "ror", type: HydroMatrixType.RunOfRiver, }, + { + path: "mingen", + type: HydroMatrixType.MinGen, + }, ]; export const MATRICES: Matrices = { @@ -144,6 +149,11 @@ export const MATRICES: Matrices = { url: "input/hydro/series/{areaId}/ror", stats: MatrixStats.STATS, }, + [HydroMatrixType.MinGen]: { + title: "Min Gen.", + url: "input/hydro/series/{areaId}/mingen", + stats: MatrixStats.STATS, + }, [HydroMatrixType.InflowPattern]: { title: "Inflow Pattern", url: "input/hydro/common/capacity/inflowPattern_{areaId}", From 2889240620f45b6bfc82f3b39646343127bf968a Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Tue, 13 Feb 2024 13:57:06 +0100 Subject: [PATCH 022/248] docs(hydro): add a screenshot of the "Min Gen." tab in the documentation --- .../areas/05-hydro.min-generation.series.png | Bin 0 -> 66421 bytes docs/user-guide/study/areas/05-hydro.md | 6 ++++++ 2 files changed, 6 insertions(+) create mode 100644 docs/assets/media/user-guide/study/areas/05-hydro.min-generation.series.png diff --git a/docs/assets/media/user-guide/study/areas/05-hydro.min-generation.series.png b/docs/assets/media/user-guide/study/areas/05-hydro.min-generation.series.png new file mode 100644 index 0000000000000000000000000000000000000000..23372726ab0fa8d10a328ee0897a82a7a99eea8c GIT binary patch literal 66421 zcmd?QQ*>v|*Y6vrlaB4Ala6iMwr$(C)v;~cwr%4V+s5hVy!(CL|GC+BXW#6LwZ@n= zR@JOkb5yOW@BFNAIT=w{Xe?+TARt(AF(CyYATR?UAmFec5PwUc7ezLKfRHdfl+>IQ z^j!(;9BfU@t&9nr-0h4BjNQylfPmcAi_+BN4cQ%pzw4l>ff;uv`pk-PAMTuKMn}sl zlQou>`=sLeyZiPAa1pLP06jJDv_1~yTZi)=O&Z+1YxP5Y8CSf#0B*>fo%{E)+t1=1 z-}lYOFK=vj>CWOA^e!J-mlrMvuMeMYo*Mld_t%Bs3$LEk9$)%y^oUQ-!0Y2L&ps3$ z)|+?FyxT6hh#gK}kmq~PV~eg&cc`8F#*Z6s@9(T^2aCF7ER7>}(H#xuZP)p^(G8Vc zdvDnfotv7TjvPTE`k@4YyX!XJe5tpb1-F{O87QrpPTC#Qcd8Y627%rFr>RTVvhgF^ zwpaI0UZ2e!_2m{^AIj$%*Bur+UR{)T<)oeU-0W+_Q`)799-W=zs~X*)yAYN|FL^v8z5-G>;^lJwmy6?djD^zD{sI=n1< z^@r7`30XW!wXnpO5F8hby>`3p3{*7wBR2y8^Dyedd!^#fNXoJk^;kPY$z+!^ront= zq?}>TE!O0lE6ZyiN~fz%(9q%DA&`=f9{6WhxHr{UPiDzqorrl4dy2cI^uJ-0WoB?h zg!?~9nmWP0xqNI#ZS|3fsc5INz~Oj#Q&NdP<}^=HIqbFe9wQ~JeWzD~8J=mtkdcUQ zm7ODR=#0GSN-ZReJ)mP`Bo zyd)W(p)8N}(~;@chjnSz9fx!47Tpej>QcAzXURy9eA7*phF8+O{iW*COJmbah399# zuo5E$nq%(1G-_^MfyI&MD0{g>d0NY;Qli)&;ik2|SUE!%@*IQ-*dB{#(ZjE*_O|qI zYrIzm^3(dwZogp5@(#*HPhb=YJsW$bfwSbOFYS*5pQ}dNE zLbe=#%*$yn**v{)Y$0D!V*#oxRy#Mk-}4odu;i8W&QEF0&B5R;5~ zxn(%1P25kCX`XBBCJ?n&p< zgRUowc+DF~_WCCB^WrX*RO(r#<{#WS?y{|hzN&OFfUqpITLm+KRKU8a|8O{ivQ1Ir ziXJ!3>PQ5n*3o97#mk)7Z^r>K7*Tk$!!}`oR=p?g!WhPL`KOh#t`aZJ*f?E&7PqV>sQ?{)$0s&Hc`ZG zOzO6#8k%}+3@3916w+eM#@Me6AVD2Ayxh~BaEAOl+dtu-lp9EAyKtV#OhqI_)7EuD zGWqSByWZ1*dCph|$JMU1#WirgLpu4V zhvK2aVU|?aiCnnnmoG<^ZHZjd*K@MNmhI*_^l|_X!+pAOhSOW{!BPhg8nfg(i&3JN zEnQO%GUt|}rRS}x5pJ0xZW{P!3e;O&z$`Cyi+@;+F*;CZYK+m58%-ke!}M3n-(#yr z+89H;IiSmc2io8n{ZS)T*W316;gPrQEv#B0NYM(;F@$Gs=kYh29$81LG8~=NK(hwV zrgL9gc=Nux;-~b1_OtuYD69`i%jJj2K#!I;A%&byB_=tTZZ$V-gn^8;l8}qMk>dre zP1TcCBj`0a>oqBu$xZNgBP!x6YD-bTCMXmMPbGK_T;{R28<{NYw!CBnwFpcTk4qBa z-M~vT_qU5NO66SOv1?Wqz5?E&TLoilbq6LFADct;UdeZO)UwW0C!l;Qf$r?XP?ZH@ zBYP_6o99>0lrnkECOOcl9cj;vFVaHlqK9eRL#NRKq6jj8Z&7z~J*yMHTiq3_h74Tt z&J0v}j*G;W?n5jjIjHkO8@EV}*&~(E%FWgt6CM^_6(CEg`MDMl;3*Rc9aN`7dY3-V zOj)Xc+bB2TG=Hf%v57hCnA@vY{tP@rSSS|k^jy{9%FB9V?wF&6zLnM`eNL{o4|JUS zGu+x-%FUWSV-mRS42EZ}8LHP8pod@@YdEF3QJLKfLbv#q#<^x}!b;H3TLJefq8%2_ z(#J^LSF%qY#E3dw&mySx=z+yVMFPLw-X2!VqZ6xm3rM1~)^LS+}Zln5dOTr&Dp7JqG}lAPY? z&&S<%`dT`4%Ia|W8P#1dY88@TAVRl#GFU7Rsih)kBhGZ7)}-^iB=a-v<8oWGQM~LS1x`eYCZG&hK|oXd&PNJMIP#BC5FE=h#=A7=@TElp zH6UCFM^#epX9D7xvUSlXeSg+~7jr|w7Hy>A(eHj&po;Moz*Q5ATr^G`$Zs=~#U(E* zD9hTvdq}}7^$}m`+u#$15{3CETl2dzSvjz0GYgRR!k~=inzYsy2cHmz(lcOIZPU#l zvmRmC^qM~@a0pl%g67@OUlogDhtJgLI*{VJwfP`RRQeTTBMHOS>dZ#?*saI^T7qS# zgGdwo5}={1kdXun`@tvThxr>8#}KCUNr=R+969gTQD6}+282m=J!4%A!2btZhI3$N zsd&60=v;GQ&@t|55(#j#7zQC8x!ebCiJ*y<8&$~sFTUkbWhU<*SwZcZrOONV#qU6u z*hM%#WI-;%qi^fA9X~145lQl4*Gpr<5jGDc>iLWmxqA$L>9q=WH4hE}2qq#cS=~i4 z{(!n!gU}$x{cOU?i>9Y{`XLAwGMl#x(g0jUqeT6~CI5+vjL4lW)lA*t2Dq;}1=xyl zK7$@M7x-KWq6tbk4}n-uWapHIQ6zwH!=GlN*J~JP&Jl%5z%l^y5f|HOeFCBkSSU@r z@z3!aE~J5v!@CXx>{GLw9R-yyZavUq|DS8ZQ37sC=GOp*hlz2!ETc!@9ISm-z8noc zXwxOqlA|Dw^YAM=CD#JX5@8`rofoW+Rq$PTCZayJzH9JLLX%@MM<*}Dgt;I(b0E5P z8(<66p~$eCy;LZA$U;c(#CKs25i04<+N)w~ziDqX(GM-bJy645W`fQb&=|Qn?9ch# zfm%z`K{}xtkn6RiYb9M5G{xtyL0u8>8hQg zT$oo#8FN4a6Gm5(ZX7d5sp>>BhnAzcpaPLFb5D8}p5evGu@cz_b*G<|SuaJ$rF_Bwa%j~<}7Rh~+7!f;Tp3D!MVyBG|bR!%JwP|7Gt<;cGi zTkq^DovHX~7_nsbR{_@{Xx#Q}B`Glo3ZY(n)1I_?TXQdHu@|JCaP9Wh>WCzK_IdY2m&AI?<29{1mf1>EUR4_ksJT00kUp@lE2j z#3Cf)gJC%i)L=ZO;y!|#i!l2Gph3FqA!@F<&>2Jw^Vg&EPw23Ea=k!QNS0b=&QXC} zc~p0IVUSqrF&Xq8f!e)mgmcqII&Rd?iLhJEQV$}nmZGih`qAvT2_SaAN`$$Dz%YIi z?fqD#G=N4JsJ*nu7{#L9v|M0>`mk_P`Lon~n&IUmL7$9M%)3vSgc8~G$ZjyHd z-a;J57Ka#-7{1C-I6tV+L)|p^HVW#;)%x*9Nmy6p=!a%aVYP@bD3jn%SqcKwk;Z>t zx+55a74{qR$E`n;6kY&cjM*-+eK zl21_LTIIMHNNsuKr`}92=U2P*6c)aYg<-Z)Wt{AL<4S_$rqiF>5Ll9KdC~TSRc@if z1b6#!06%Sl0g<-){`Hx=z-ppMo< z&CcrJwnd=m_5-;aQ5Cspo$q^WOhjY3_m9%UR^*x;g0sxKv_{_ireJcF;bXY@v94A~ zpkn$t%RJ3Tw32P$on%g11xt-aSRkN|n<^S#zK%<=veS1E2NVQku`d&r_nc#Fx*uMf zSm~6~gJefG5Ee#=X0xstUXWg5h^;NBTs4v2^>F>i^SHOhRs<|&qrpRF2>MiY=K!5&|aG_o-RR7%^4O}ZFg z8UQ?iffjCafYPt+;oE9!cx;;&2>p_c7vdKq${jsEw@v(8Z~dkYw@?9FKc8va7EYcF z$bYf3P>}@;z{OBiIxqCmNW-y7m&a5E0hFA#=idn)FL9p_-R>&5Y8fimsNSX@Loll- z*jxeY6pmhrc8+8`=Mdl*%Ln9|hjqcB&|f8z*5pkRG&ouie9X9c9TMg;NzU?qHp3+L zs$!Pq?(MhPyH-8~Y=TO^zKw5t292uQ{8kM~#-9N~umrM?6UcOv#d6a((vJ~A{cypE#1H|uRCT<50#=i9kOj}3^Z2cc>e1XrB{qm|n)Qeyt6!4>kmktFJro)0-@WF<_ za?a?$U%VGsh=sKYzVUw*qd;>(K{;_j!T%PC{z^lc-f`Sw1AI6`x{5=D)Nn2c_M>uH zR4>777N`+&zZRjZI8wFVz@@R`QP4&Ens#?b43_$a)Kwu>`4K*V>|N{>oZ^taQ&2Pp z-Rw4FU7zt>uYvO9_0yM_Ab$$^9&)OXY5856Qe zV~`?;;8ZwLlcb{EBG`&8I1YcR$0|>2W;RJgH(ibCVoaY?PH2^|2mMB?1A`(&S(Fn2 zXW4H&D^wF(@aZ4nJ$WN`AU~i8j57pQHv6N^`-1;*wM-!;<0ifbHv75HhCjeJLo-{+ zIsIG*V3PIQWwa}5`CNjayBGK9sv3&%>$cwAZ?7Wv>^+2|yD=IqzL4Ng7svzZ%$Z|7 z^!w7xb)7n|YKmQeBoNdtvhfQP)GHKQJLkEEbAEcdPE+ya6`^%V8V^&-_$%E(7)Xi= z0TBWb0WprG=`{W=fwB`*cLV~WA^PV6b}Qm_`CAC-BrYusc>)3p!vZXBw(I=22-8Ve z%}LPK+S=I02}sbvSl`Lmh``m{$&5f$Tv|@m9}XP|hyX}jh+oNV{bI}2OG$aX_jV^s zoHQB;DhNSHNsnf;jvN~z0sj|Hqe50JKosuFqpG?>T3PFN`y6}11 zb7pe$ITlPzV0@PFmmd>!M$T#x8w6SoxXd(uKU1}f^1P{ZrVR=J$-e+_65g0B+~PqtJJ52h#Qh77N& zica5jR^Q+VsdoHz(to_cmr}88&SYy{!SuPSz(D?L8{N_7BOk-T@zk7x)ADzgWxmkC|14mPh!R3}TN58Fe}bU9FC;(sPA z*}u8)7BoE@jc1lgVO)hIR6(_m6Dr6C)jzf%>`OczHL3y;qtjvr9x!^*b-RwES95*|7s*%Y9;8^{biZzf==SnLjcZqd z-2H;T>OC6qz5DutR*Ew7jIj514Zei%}OwF?G8HydF zK9j36`4F6F^>hRMh2JyQQi3%dje4m)ftugo(>CFRckQCCOM9p821<6s2B|f&Sw?h& zJBuuNMm8oxXs{yFEPNnW;es%NWl26IPU18n!h%aJu<~}mLB6qoZ%=GCj8c8;I?|>J zFSVp0;m8;s$1P;?bt(4WO)zSSx5gaH96-&dk{VHUI!SGT~jP?ew|)+#;-HL z%)!SWrLN%(Tk&^wH9f$q)Dctl;4h?*fF#}Jajbi1oGA$ ze4IE*D$+-cehuc(KskCWN&5Hz1Ift599Jf@e{!N{M7@4Khs_P$4P^8nRW0bvZDgFX zP96G(?(Wps{;D!JBl5kb;MvmjvoH|1y$+{RcY=Rj?4Ek>BbN5x9!xWjddgsLA2UvLTnHIn9Ava#AFH~%XK|n?y6$nS~_He4~Mgb7_*T|@Aj1}?qxV) zJ+(LxYmbEm}1(UNRt8g;HDPz!_#D(n94@g7Xs>BgGrx;b;}pMoi(L_d_vD@&B7z*;L6m-SjZ5j!{wt_7SnjK%w+Y+dC2tu zbb;ehJ;5qi$AR|K3it`Ov`Q7>{zj6OxFw>gQc}7B_X%UTtc_88EM&9hJ-MY! zwVMa$-=!$BBbIV~bg;O)(7A79BI<)kJh5W<8DH=WF4-4XJh)tMeE{m4JyPv%RzO1M zBc@b?h*Q&a#Sd+T{rPs3DT(NOrIw~HNW@X|jSaqkrVm09{H%H&xSXX5pZ20$?{X^b zLL7gvIilF*nuk2AM9bsmXv2GyuG9^vz0qg$wF-2oR85_gZnY$c^N6_Ktda5AYdr-( zeZ`^pjpR$6yqPMwPu=?USlEAu`)}7#-Q%PJQ z(YM|g=ihC!+B9zCikFdC{pKwKCR$Iz$UGaVi)5fd42iOl2L`)@^bC-XP&#=IO{>5p zqb(bvv;%_+g_qm(QqVXC0(n=KSF!f)18kpKa&dNN8ob+DzR*`>LMUp!y+`TYQ8SW9 zyob;gFwh^h&Zv(YI(o(-*N5h~6vyk930fZdMV8Z0Uy2VIx<_d@F`7e{vkb z#qOU2m`5pr1wPtdUfh~9f?n??Y|AP*u~Wy#Da2%F4!Ym3*hn!G;^Lg;Ysf#p!ZDh% z=Q}~}ocMI`cGBDEFMN1Ip0-3ZbndxY%7Z>SK_2*QT!>=|MCwOZU>kOk&gAVnU4H%K zl3crcf@Ues>3*LkWh77Q?D!s@@0mO@`+yf($-?!rkhmM0SO9%B@-+>-&iHwQZ#9+u z=jghUjDYffvm~I(;T$&;y-(gKnb}v&QaFjfD6iA6axwr787u)Vq97`>@}fB0ZNq+M zJWC%>L#FC24tFG-mV`B>F-Xz3GCNO=(PRunKE~^jF4me{p1W$ z#C!9>G;GAnT>7Yn2>q#KA{6+Z6oQ_eFkYPItiMvoWtu3;yQ3~DFVK)3iV{#UGF>;$%coc$hZsr49^#lnqA>?@r?AE5 zLDPAneqP8Ln6bI6z?x^c447Z2X(2t=ImA+D(?%I4Zb1o*SI3yg(SO#TzkyVpJ8!Zy zz?+|MA181JPt}peW=0vsPh0_pG;Ix*Z(Y@L%58aL4j~x1PH0~|HtN_b*iSc<*C>|3 zHak=4-{^SWo@WLdJ3VKlh)BG6Bk~*5b6s$-n_6KW`?`03P^&OY?ph9Ng0>ei94Rl6I@^Qe=D<8-~@!ynABstkgQcjqrYb$Y_hwRgtP^^^~s66qi z&BLzAm3EVg-tEmiXp9ksM`>-ZjDcLmC`sW_V- z<0vK>Mq*SEkdK9JoAEBGP31Z8MsNJ}+eO%xNHG>+U)JdDVeI(B>Udi3?CcDa%?{}P zXbMPgq4=_}#I)YgluiwpW)(0Gbp-ccd`s;pNAX_JLDbjMSX{t;_L-g3!AZ&V+$NG?v~?>v^s+AxU_ zNZ6tEFhT`Oq_s9)P{kbm7j;UcBpUGy)v`!fA_ahKuB>9u0^AS1OhKH`SmmtuMe%b1 zJv~{20fRCsuHiT-)&LNkRt*cl{DUNHH89bMD)LlyV6e5#Zx|F@IFkB$)&fw7SsOE^ zx<2cEu}x_hCZ>}p8qFaKI>XTP1Q&wTRk&n=vhq6CQwRzPO8HC3eb(NyacnIPi*ekf zthqoH8c!CXT-eO110QscB?@J6aia{lep5X~SW8Hcefbm#DL2AXxGT3c3crBp6v*l5 zvcarrCvCoO`AJWDs$S%caVLccnz^td6?v8F0f$^x9hY@poyi6DyF=H{QH;zXO8-MH zJ53r;Lx>xRAoUbOAM0YGihWeC&=kPB4zfJV!y}bRNP~o6LGh~_g`h~%zP#bgO6Zhk zk4>0>Nhy(L#aSA2{ZaYLeTqg!H(1p3V2B9TG$0zjDL)gs2z}cpMvW|`VW%QdBnYtp zQ)*T$qv2B&FxZ;wuOu3bXnnviRbgITtde;kCnY-^?P`OiIoSmTEf>a-xp+z`IILA* za5#~ghgNtEPg4E1S+7`8QN~g(#@q@KRj4~9?h=C1`0b~pOsgj>2^zlPVpo35yveqW zmj(=dd%SneTeubNzOcXuoXTno@#NCHy0MlmKAP#HTt@84sp;j#Mf<{4oqMa<*H}Wz zXaX}-zRw2bUQ)uM@iya`E_)0DO-3e{GnVWP#ROu8bFu*`0e{%^P7%mM8>-a>E)&B- zhLVH5P3^(xb@kheDfmK$Msx^mcAw!IQ)SZfbxb72`w=N8Xc^m82c1LxR(+B)ht`If zzXs+tz_dGDa0g@N=o)U{Yb2|ay*Rz|0Y+g#AuK%VH$zbXuLOhc!UUqIH7@xY@uifV z^0>oY>Ac(CqNKkB>d<&B)EBIfcYFvOD7(HWc`8dX&^uW`ht__+LcAtl)o>Eelk~{D zAU$qhU`~wLb#=h%y}9S{#u4@c8gsV7z-%bbI){RTMkUCc$88F0ibIw60A20veK2cq0u`Z+#h=nS3>p~+XP&G}R2MTEL9 zbO_q6IMQwCLMqaP#ziJ18C<~VElzpOZa0^k3k+1lMjCHoqYrZd{Ysp1#>DIEvQcp| z=gkQ^H#uFgdWA;3(m8}IwL_mQ5d}&kA5q1>5=X{`g*HTi4txwD$D@t5i zwv2@Wp{QUilI~``UQ}v}^IveJfZgo_0`CWo_;uhrnG*jwO9|wrA>%DG;%)T(~L;-+16kypzrpt2>Uj$p=>X$8G50+4`49Ps#VwjXVCt#s(lj zb?D zg;TN3TjsN`ByX(r%oiETg-Eoq*bx1;xNZ7226PNkGJABdz4vNQRc{!S-+W`+K7O6s z`UaqXFf!OT!*O~%VtQlywb`8;GcPWYhY{kN2f|AfJ4`E1=#%|t7BvRaL9D|C=l)qn zcf-CJn0=%P5}O(8d3;UC!+z0aiUp~4<0E9nkMAeP=ECyS=O^Rke#C?7?a495Yfeo0 z=1Xo6rosl}*NzKDdgIafkyd$mjU`LdI{B&zy_E=G#Cc1gY`kh1Cr8uO&5ktYd$w2r8-$gltMUPeyB@2gy53 zzl|kq_BNbRQ1P5z=`ZrK`13AEJ*1+Mje*3>&oQOa;}u_+vJUqi_t@0pPx_-;x6{kS zo_kbCXc&={zo(hpiJt)98e9N6Ix80>Z12=S==oLFcx8~T+jBzTaq)y)C>^Xrb;XiO?7 zaS2(G*;@78^LM+EEslm~@0F5PK!Q-O!gSrfx|NAhj%Z9~?;H!VnxY>13&`+AhkBX$ zf;(!G>f+$eZ_PXAgLw2=Su4SC)FKxP{?!qiLC6OrnFK~7GEX}%zh`DLBfhi05mh`N zUEu`ne+7H|%+&VC3%AJ-ldpBXifqbD{V+gH)Byu?*Pq)-LFdid_E@$=?)A&k6kBoS z;l4V&2n)_j%%A7gqbTm;rjhcH2R*jRwphfYKb|{+b(hB(OB;7~U7zh>hTe$v$l2;jw%O`RCc9 zqeytQVS$9UJ;dA)rch{bt?|1Vp4XwukrXtEh2RtF9aHOl{dwcJ(<1BqQ~m9#+Sm2) zEzYV`$JkiRGZm28Z(<(YfvIe_^`%{af>{3YeTS7K1X+VE`xXFM z>l{hVUr;H&g(cp+2hXLtP-IWmnOtJBHOY5u1y@Nv$QK)l_uL=(lWbOVcXJ0=+G$U? zGGb0%O%6im^TZT7c*>h!S6je_*(~YIMI|2_u|j6W*czwDS{iWp%7}^U@_21V9CVWY zHwjR{(_((_vJXhGb8t9F7xzbZu6+9Vs<~x_Uu0*0MQKb3Vm}5Pj;wm9>Cg8=JuCrP zK#dU2_qh@(YHfV5qYb6+Ee!7}*~izugg8P1GQIAgXQcbJW{n%UYp$^Q$!RDAmo)VB zpGMpUl&ivtK9smYNp%8ajWaCk?M&C3Y?uax5Vec)FL~3UK=0RX=Q=u{);UeT-=9tP zaY26EFMSVGJq_|^}JIPAwax;G`>2$r$AKa*aI{a$->Owi*NjWxED#?bw` z&r*4Oesd+Ajlr3atEgy_&av%b`h(~mv{ues7bLm@U@>P1q*YX}P$o`X3@9oZLeq~evW56;W&bA z>GWHIrdayB4NrqJ7&X%6vQQAGvTAppDdpC9)KeUCEa3a1I;nW|nS2dQ{3?mTbJq+}$lL zC5K7Z?3cEDWEEX6;F>fz_`vNc=A1Lush^DAk}j&tFE36MQ8XlciM?RcvtnxhB1Qve zID$xLLiJ@LjuS!F21aR8bI_LVe?f$Z(&|3TNgptZ^cb&Ipn)tZ*|m4 zfmq}mc|LpQRXB|4N%!p&1r{XVrAyoVp>|nc2&EP+j`D(ff}F4%7P_<347ImJs0@}+ zme1ES9z3wAv>~BLMX!59l*4kUY=2<3!=O81DY!OjN|7=`s#^~tf5EfFIXn{zNhc{Wnp)-0$3t1##?E^f<$6~8YSGxrt!(mb+~#2x2%#0kj-+Q(B* z^{#;wQS|Smo^>1jXFt6`wUoAvt*ynT>vId63+)ZZ}FL#y1oXsIf3CjEhY1Gr(M5zu{ z?4CNWA+;fRQJt?e#mmhWMonUpj0MvuM+XpkB@#{ zMM8+heKHqO?V0&Duz{+797oP{7;FTG@w?@sjBNa*}*uU8!;rj(oPPRV^&e`IK z)G{ky__R73Br-W_0}J0!CBv)t?S36_(Hl(JUgjF-0F}_&+XYMZwLep1irZ2ctdYI$ zPimS1-tNE3NLg<7h7s;yRn>+6QSxqo16R>Bhy7Q6QQzxUgh>@Yq&BFZF{o#i5%9RBF(RvjwY_Ki+_KlD9>JYh5=|(uzd->wXu>u zh6@Rwt6{OlZ$myS!s_i$#iM82e10||dfl}w;e2yn-_j`eC|QcXJTiV|dE+`3PwSmN zWUDF2in|aZ?s!c$*Jjd?j8ZI(JUPCEh^9JESiy)SUGhLj7QCEI8IVk7jt8Z+Fm$D$ z7Kd<1I5387^hB@*$|_hYSw-cteW9!=iqc&si$63s^e@T@%9|j0s@NR1q*A^TH#8Wt zI)-rO?UXRZ{yA=DhY7ZNf7blST(>bK(BJr*+v*5LL=ze0eE9wSfcCvT_o0!>lkzf% zWg^o^K>q9U!qO~6AwHgx&hc8@!V6lN!VD5?!xxU_@tS|-ezIZ(lmbE<_pq%j=);y8 zTZ6mEtj6cDu-Z4HrQq+52U;PfWS@uUkMPPven}Iig*7|7IAZ0S9;E)EZ;Vt+xF(II z?zpfr>Y0Vq*!U&?sEO>C>uB-?&DA~(F`Sn-Q%bA}YAr)M{w)NxK6Y0dIcaQY&D0^F z3|6gxn06#&XZfv`aPy90L$}*Ms0(r|oh_~HEA*A2Ja3|ol!G<~AdHN`ZVA=kv8GpK zURY0;pqSBpu#$yIz3y{--^@|)=FI@>r{R$qSm)R}(i8JXuk4@6p!_?6r~sCuiv&V!G>ulk#S%c6=?r?_T0q48F6TP$aA zk{r-#eyVjrm77-|G+Bt@45hQZijs7wbmq10-0eyK z4W;uh@A0vR%j*+Se-{zSBctv<1Wu~YheZD`UGkE^_GwzidH04r;rD32Xk2HV9%L*u=edgiZ`;Usx3~W& ztbesEm$$b1J7||HnC_D#x)SmKbfNJV+i>&roLw2aoy=*Oo}v9s+vE2S>XGcnF8v=} zMc5KzIY<4s=O-lbVgEzP{GYc-6u|!JOY1zbFA{n1EG!ou9(C|PgpZa<{eQe(e#So} zP-$r?1UH%tw=y}>zkvyx5L6lbcccZJkj4Ku7HkAcz`qd}a{Yt)KOsceDkMiNVEQKg zH!iMfB#=>Nqvc-Ga7}_*405_z=TNJF@aa)zPnvl_nqvo+^^puKrby6 zv1GpUw&Erz|HGLKT>I{=`&0Qm+MBRZ_(hbj$ZN_A*mxkxLq0cL3jse`WbDx6PiEEZ zjDRI5emoa_BWe^iTc8bG`y_?pG~e{!1tykRf5VbJMd`kw_}2YA+PS{^rMZ*82|=s3 zHozm~`@zFIxRTdw?(k0)QIIM#4oz<7L-60C7keeTM5EQb-WZOVpw)&~i-<39C*j1- ziAJP|b(W+Xgbt<3z*UefonHhxklTmR)UQlS?GVT8vUjoA9*B>x6<&z$Z+<3Gt;fcA zcB|8o*SNVfH~$b#*PC#;k_R2eIdwHrM{l-oQ4tZ{%>kVU$QN2YX#?u8rCCjQ6aG?N z&cG}0&m$^|lJ+V{9m|y@Y>K{k&TH`!r`xjb`qoJ00F-P`XP!Wdn7X?p+aEPnJjg>K z;;}u0AN{oMHqIb#EXG^$JdTt#J#~Rp18tA;y7EHn;ej7_jz7qksB7j?M)X(c9LLE% zARha+1@iuDAruIFc4X_oZ=veIjxC^kS&=1QYtaO^MUu zwFD{Xe4pD`NAXss$Gjz7?cq+Xoy1B!I(J!NNea!YOPIz1V8shlYUq6H02DsG3T-IMyBHw8VI94f{_gr*Bo5qQbZNNRUphhJI zFC4BCiUc66x$RMQtklVpb)-U7j9jbR65)!SY!=j zSy=u%WA%@9#;<3#WcE2 zSW-=7rzGLR>>tMGw!a3G&N#yy$HAZ7qyHJ^Lk$L=H_{5g18x*CZX#f(r|658)~;jC zMCWZ6p~$*h2;jg?OY@WPGJ*A#q&mm`HgCWYxbf*lI%i-Ls=M{6IjEgt9J*eNtv&(& zxmfYpvqk#&-2Haw_P5s!#11J_PLIJvA*s%ROAXU9)qB)#{gJ?YyW{;iJpIckw-qKzR3sTb^u}a#uJLWJr_{iyBSgewv`{TVuXsP)%sEo9w`-vb> zkoRComvIhMqlLZmW7GLTVy&0*`ihhD`J}u`9pdJugSC&9_Ey}?dqTo=G^^%DtY_;3 zaH$8aY&Z-7E}23C)l7%_j=0%!f#w0}JK3=z-P$+mo@jr2@AUa( z@bZ8ug?~KIyRBk|O))0TGxkOO&(?Emj4S{Cj`!3n64AQdJ;q{@J=63DUQR5)1Q3a|xa@HUWPYPIZ)tF4< z>_{K~L~)$y7*p{~?;{3PbAEK$xqYQ2{r zbkm#Eegbyd0l@!q`Qr#IjyZcfMk#S|{|XSn!cb>Ux6r-|Fc$fW`#r8q2RTbzdzBSE z*oOm;?Gj!6BFV&$>EZ0J54APv8*30}Yv5ma?ZI3Ys{RNl!2PXX`DwFj>kHe@rO&(3{TaO&{}17H zi-9=W84Uc}r!j{Cs42d-Mzxk)ICi-|h9CLJVd2v?&(o1Tqh3R6)_ti8LXi^tt@RDT zZHq7)_RjS3?z^70<&jCOD%xZ!=4g&Q>EmG=ZU0f)xv++!-{}UH%f5%V z$dl#v^$cblCi%h1VR&Q)B7>6LNGqaV$%E1TQ~_;cn1n;WMJL5SJnH@z%WcLMVv)P)k$au-@jgH0+;c=vUPbev( zSQY4*+wrGQ5~7txLwsw1$0RN{R61iIX|}8B5@!Y5=9=^Y_@g_KlTr+75{7_`{Q{^V z#=`LrPH31agR$IRpiIepHOHfg=x})45t9E6LgjPJKm8|1NZ{3}v+FHaeAKdC#F5oY ztAf1hA~v>!y${iOiWLmcZB01=BWJ~HVg75i_+Q#h+VcaAw;yCdyR(NSJ^kpeg4`k~ zF2ztwjvNM_$ma_dEV}XNB=2uxGCz1n4aBNp&AE_0E{I%*pJ)zea{}rcX!w!0_o#A$ z@#j^gECtKg=Iw#BTk*Bg7OCY}xk9arol$NLwu_4D!iAjd_BOsc?*$av4w_iMFPyv{ z5DMq;cD*ZeA`)znLF-v`eBL)=>i#j$n$qYwfdlHde) zO>lP!fk5zt;2IbtxXZu*Aq2PJGDvWD7~I|6-EA0L2ECJW&hx&{hx_4wtM0AaRWnsH zy?V>q%X;;1?RH8byvkTRh%#ca&v;47t4=?0v z!YDLDobu`B^U^T|iJSn2U(N~}OFygUt%(Z-l3@YQzRUx+d_CgNuJA1sXXj5>mr;Ns zlVS4H-?P5(`98u$DaZU|u@h4ZHm6WvqPWH@nIo@BnTK^5ZVz0-S(7#UYJy%Mh0Tq>ZLhrO}zn7P-@;ffD&x>(`r3|qUed0k426{D49 zaO2=eKCKoQPMIq^f!RGg+Si{YSGEG#%{`Sk3{tsCdmkEgmlMenLWM@ItpZObngw`R zb^!djU~cPEyq;OqDiC1sHSW>9trRLM5H+^oSHd03q29f_yYzAW5O(wv*iG^MqRckN~J;Ec7$PM#aP_C0Xiu z{Q}GSEfU8oUG%6sg%OK=)6aA4U1~8YzWa`SO;9aZMm~X;i7{XJJD=GSO4u@gJiO^P zwB1D}fe#?=TM{YjzVDm8ch4+*=t`+45?JEG1;<#qKZNEJ?j}iDLLIQQbd3eic~JNm z;9J}KzMx7cKN89dB2e?uT#CBK)y=MM?N(G$bl-USb6c-jT7%TDhmgJJIp> z2R{~!28rXr`oWv|QLJY!oveZ(Q=L&;N+b%uulZGtN0l9`A5e%Teuo5Io=!2(gy~j( zk80=y)q%t+@-(Toc78?wQtCX%b{HJa3WD?gaJ*E#0s@|yG1+i4hV>1g|Mdzw2*YH% zZB9a$L>B88!x`sopC%=t5(b6MylkaWo{|A|C$H7*FxA6Na_Ai|Lh9{CXvGVB+Dj=qLhpCu-ag;F``b^pOo=KG59yLhLp$OHYB?4$C{NO%Lkf-xn-B89= z!}SoIAi+ZK)1Znyrw64QdPLj$3GxV{K)(0{n>++~W=Ae0hNOGTX=H+eOLa^u#}lIa zOn<(CJ$#Vsa<5n=`PCU9$P^7c1Ep@thocB-)62-}d&Nk5(cHBz51p7g)q6SOQNiAJ zvDa)2}z3H+G#FDA8!BhtHZba8b#(7$wf{e(spvpLo4Kzuf4uD30ut_dG zPK}CWR(^@?W6_JM{-$WAxt@pw8LE*RA-%yp?-iSYM0c|bLA{BQCIj=OxmO8nEsx>^ zXOTjmxJ!QmmA$m0W~)QKb=Bc{$cW%TKew%w4hYo?tX{v_h)RmHepm^x%xcl-g?=Cz z;=9;bxz@ud5V~vKsPeFABZNO9;IkMO3PaqJAfb6b%jO!?fcdHTn9DEJOASjPy2^d9%8N?8Cx>l;VV1W zXh#EWE0hKqif>Asl73cgf89#e0=F6c-m~Y7ifbmJ;6!5kUnJHf{Qm}tr8)Ui zjQyws-J?X^@L7+iu@oI8n;s^mBv8MON{u4 zmgiVb?<}l;XTZ%7Sh9Uhr|47~)FX5!bsMZD7kcX(-{o*Xomc-~G9#@?1&kTRk3ta# zShS4=vu|V;6YNcPPhD;QQadQJT=3G#PMllgH=>6zqLGra51*OVFh-@`@o-*}1fmx*b$_BeoVgMAsnEV4zzQIo zbb}J3)%?}1A=S$@AnfOtQLh)hpQrOdyB=%Mcg20)qHJtt?xQ0}xDd=lxubNNmtHH9 z+mAsWtR#E59$W<}fGvCJ(TGK5Hz|of+?Kny{*MAnUDZaCOOU=) zsG*OEoq?v)kE`9!`aLQ+M32a}I7A&QVLeW_>%sc#TZ>qLu-f@;mZwnBK&(Z`jDK5O zT30}~seEt)d!6HDJ+D*b%fwE76EA}hhq`4- z+gcn5TW7(Vylh0$J&O<~x2f-Kxd*fq1OIj&0R}R&BAJhRD;RbDNl74e3 zXTT(akp@82q-rL!`5zx7ju(q>9AQZ`i0pTQx+^ zE^w0_sowy*p^sUR|3YBRiW;rTn7Lq`*1tSGt^R6Phm1|7`EgNEe$csP|1ZjWXX!(v z_{LD58c^%(R64r2_KI`ie}GS}m7tIN>)lKKo%u?AT~EF_-&%C~M=OSlB!|!(kHTBj zp!4nHnGu;egbBdWa$|!-a16k(26(*ssqfKHD(U`)HTNh40MI~}J^u%Cm-_DI--tVr z7`5?R){Rvb!qj@@e458Vy6gjRhcuO}=bJu;vQum9iu8LNyOo3adks0{#Z_Y zX6Xr;tYn+>_sfzg$8|9Ry=pNDX_A&YFSw$jq;u7( z=b#2nl`)byW$qT`$Bg~(UIku=ey&4?ID1%mWh=Kg$OrP2988HB0q9**ry3^e1z)>- zf|7`}`!$_}Z;`b9B9>FzY<`Zptf;t^dP@{CEAj38vRU{OK!an}WlL8jK`OWEJ(`^y5IPltk}@jXA@X6?7; zS=ePv0z6zp=@Vv5(R>W~{T_9YDBd1E4lg!_sF>3y-A^?t+PXLv+@DPJLYY;|N>9(4 zU|ZqfPJKNuNU(W>*5mb%JeInf=o8eL+*ZKEO^n|Vc5{38N?qrQqGHn@97@tW9a2Zr ztytu;wnE@f7A7ifYAqqI5V0OypL2g7hqA5D=bLs}XIVo&C~|jzyC81>aRMP3>=Mb2Z`Nq8?kNW$;GT5LpYf`Qo5_ioc7;< zH6{EjG$~9uxj4OJF{GF`Vk-#ofU4w96EYk%7cFSX6``9nxO;n8(Q%=Dfr>I!x>x#! zThYFS*>CZ6n?JHCj-#I6nzv8gt#++ay8l)6-x8-0~BN8T#jYK+`$kzxb9)}+i> zz#O-}dOj-cq?p23ej#obOZa;7S3@90Uaoxt+#AAR&~YR&&D_!Ykiu5LywmKJRAc|V zzDU5SvQWt%q3rq>NiS3N3S3B_I;R}(JuurQJL7z9VXwfR0##;|yRFB?G))&TlC%t> zD|tUTLWx#FR^%ky+(ei3?5`<$k4BZv4{}F=Pqi033Q3K&fgUfeyaspM{lD%@x82Xz z$v(B&^<$cg8+!AG?R}`_HmasDx*Mn<6%UAtFSsXNn9^kLUkE&ho%c}Ip7UGug>HQs zaNa?OpM4p^u%wW|*|J*OdQye}!v}6|#$Xm({->?85vQS{WNChAYdh zcm0xdcYWKYZ{!&(oEg2vmA~EGp71ua=dj5pY9MVPh z!8)Y92_XWVzSuS{_xpIc9~o4eDEj$9H{oA~cXJ&`hkyFclRq|Cv{GG={FG5UyZC7| z_rc%wR%aD8&dJ`j)0}m28Z2o5tE;cy5c8l^8A%&jQF-c&$V3l$g|gr1L z>apF#(*_^>pun=jPgBa&$L)Dk*k<7xrV|~4X{u8iR91G8i6POhW(w0+pz5OU;j_Ek z3?Sd8-V83y?(OM5vF%mYWa$p{ zCk4tphQ&8R;KoLA=OX0C%}wSE%8$>-g)^U{V-z4*l~vwbWOsS)TTeaF@bs}Obkq_$ zadG~wz}`>41-=I9^rAERtQXu-DIoDS{09rLGoo%-(fY&*-Eegia(9EEOCO#WQCoL< zmwI{&S9wMETOZTO9N~UZHgIJ(Wi~PO4xIiH>2!R-x-uW{M@iw2YPvQZ>7Dy&PL%Sn zNTII;tweQsBnD6Fi|*KAV;x%DzzyzP(jnwr+XbKgWlt;I~9Bs{MvDAnVh z9PY&!n%zFWe~IIfVDLN}DVrq45Gip!2Mzv-s zW$7Y^tpJG#pV;7eM3bRnM=S4o0)yd`>6wE-f|pn-E9=DkR1rq!c7xh=`<7{7{t(F2ylg!Nl;u-!Q-{%;mW9iFTCy`~?9V-08tY@G3MDp{#jWl1$C_E~ua$58k znF<;6!Z{85oTR`Cuev2)1>NjlMdfS=iAB?UUDPa+L>0i?f(2c;3XvFm~TMCl0iMuR^ra&ve~_$R6FUXshNv%EAqv(A9wCaz7x z=cs6B3q>lMo10!Vlj|mBx_twg%6eQ43_cOcx`gl1^yO6|^;~TgdTbpLwNIsqwcdk8 z9K%~VSw2r|eybt-oWDg{E_n%?3fI$Rcg#@cjMfPE-BwW-dbs|e<&+agD z63hv8`Ke09^N9(1yQ8+ILX@QzCmZN}=Y|S*64+?cq@VcpCi2*n1~%q>R+xZf=m{K5 zG{&zqogGBrOB@lZAt7x{ZPu4)j|cdP8L_GV+3l)#0@!iICl zY=Z+CIGRgKqyr4T;WvAJf;!UN`MS@QPaUS07b)nwDZ7>+bbgD00QxRmL*)C6T?kJZ zoy?BLjEu;n*E=^4OsKEn1mg^a5QoqQ&fk$#T&t~}gT0Rd2?&sxJP+E1jmPaveoy{= zZN?5A|Jf#)3f>G;9;Of;?$P5V00%Wo9$pOi@B^3KkdVG?>i+3upV8fp-CN`VQ+GJ}Zm%pk@B`*&=KT`OKS>ZNPO^Qq$SIGOO6_*!;4z=@36; z`)=|nd-o!=Gk90Q4?4vP6(9bF*x||nb91GMq}?PYZC|j}s_y0IyperT5O+;F_;B;) zwwDu^;`KY@(D%u`=qC!W`secl8~!xXwI4Sh*{>ExX&A&==d3aY+njF=gYst8-A`9I zR(={Jcc^>IM^6PxJba0Xpa(o&-JmpGY~YK+r&wZRWAzd_ibT7jfsG{gi`2qG?WJgh zFPT()-Y3s-vMRgZ@a5PzgsBR)+F5a+CrgI|3nW-J(=U+Po4i5IVVk9-vhmo1NOSWKR&2 z6zw)3I~{+dJA9B5NGwwM8r&lEZac#ibfrL#ttS>P`#!z`q)WSx%3o~YTfIN)i!}Kb zc9jx*rsaJLqOgJ0NAwS=S0ui~Z zXY<7)Vxg6M^=q^AiH!XC8k%-LbRk(!C+*G-;X8wN{0_Dh#zWP_o+9`tc=OdfQL)fx z%qb|}1l(Cs9$w5<^8}-L#;ofZTkMYvV>{=$Phw?aoBq;&wKCkmy%X}$8hr=XOR=bI z#>4%L4jEmugwbg3O^@q*-_ue40g17~QddBAdsvgh^AS`q+~}CAmpkj%@am)V=>Dwz zHJyfcT5APjxg<+SIAM-x^|1BHvf8xUV7cAOQL4$W=dWI5X`fasAKDO@nW$Z^h{OnM zlMXMieGuh+v`iMst8#y6kC{oBq@q%;U`vW~z8h3q$hHIMD!w2A=4g58PPpCY@N&$L zPTT}=BPUDB>563yyAk-VCdOt1>L#6lugrs%~W6~xKpeJVg}Ps*M+8xEU>)|BX-8XZ5`TtSK`8! zyv{!yN?erOk7bx_8PdKO|vV9TQ zUK(jeUSW72d&G=lU$UuQJVpg=*EmtkQfXo6sg$IXQhrM!a_(le5=n!~H5UgR@th!O zuA|Mne5b~@NRx!3BRqK$H?{!>Y$LRP`QdiwlQ<J5y)7ZrAz=Oasxg%)kc1eE z%+w#4P2W?#)m)}k&*SIVDQ$l&UR@Msb|q(9v4HPZDQ3s{a2Hr{(;FiJ99VLEjx8aQ zx#R10tton?tVR)7ADc+^G)UH;z-4>$4Mw>iNVdI2-&=`ra#{p&s!o~Qp3LJF3}GZ& z@_IwZL{LG;;82b>p^nI(dnin1J0($i4_gx{XW`$foydm>ji0>@bDudf>O-H%&AHH{|(`q6AZ z^=^sM>M!a<*G4!JACHv~Rd1y8pYQ7&IU^%6#IqcELbBY=Zxf)x(-^~P0|&1w!kWwo zP~uxh3!#o1I}d@H*jPqG45-Q3NzFAs;V6?&;xDa!2Ewfr|U z9Af@;N2@$e^NbtkqYt$vvd1()cQ+Y5fuHZF+)~IHOei7%aHyeHd2JIimE0&zd|>o^ z4O6tQ7f0UH>lXJ0NuW0)hKP^xOcNg;>C2j#&M!8fuQcLqD7u$7(JS1qjiaIJE>4>b zQen-xs3b-F4)-V|(4^0x6A$*zUj3fmWH95(1bj%QBAy$&>5OM<1;@4Z;Fa^;ebh@u z754glBldz*lu9k`P0XEih%)7TuDtQA4Go~r^mz}58K81=DFQohDc+~jTlN|5!z~Zc z9?3gxgg6JD-DR^AQ%i&&w`zauMK+Hf)L%Iv{3a=}ZWUk~OOF#{onsYBZfsoTV9&n` z>C994Y4Gd3g9{^9|FbpQeh>BTa`AGCp_p75(=wV%hmb2}qw*UR^rS`CDCh03Xw`;Y zzC#NG*Dfsvr(AX-JzPe6)1so|_)MM{+QD~PCooVv{ldu2Qn`qYgh(aY#O7I1y=dD@ zHzx-Fq`>*}^T7MMEgT~Avdc+C7%_axlNK^S5PyHj#w{CewiQ(HD(xVmKJ$BZ-7@HI z$5?|(@UomSZz1O{<%d!l?^s{dgJMwNK#L7OFW zZGIE}fN|y=aQ1!zySu`4q*a?R&QJ0Z_+WBLB67OgGC~$wMY@;etfIP-GY)#V>LpMj zZ@+0oUpq#74JYZcehjJIwJGv)cW0ZmchODTb!06CQYx=c&4$;cf;sL_FC&dJv$sIt zNi%*n+kwKB;4nd@UJK6%iO3OWqk%r>Qc9O!iw`z}BCOdtz;aiY=P};rC0v4q(`zXA zyf<~rE}9Cr%|!3oFl&O zxOBAt9I7g@LI0BD7BI#Lnftr!*@n6IIQl~xjkY?r#bBMIG$eB}p*0lw3nm#D9!{!Fru%E~Re*RlF3qHILABojS+@C7`nMfeet+HKm7LWGl%AJ1(Zt)c{%BsUpn^tNt;VsFc zUylFM=p$(UU0uwueooTulNX)8m3OYc6`y_qhEA!ZiWvSM2t`*R&flEp#qwGt<@M z)Snh&C9}X1rpIMAm*_D#xIKFn}rh2Bj)J5*~G;(rm&T6nt9L#%T77%iOhuMB=< zYg#@6J$`+){9@@fYRfuj#RR;!bA9rE?hw38o>w&;9g|luS9(V@z^2 zJ^wyR6()3S62|Z*wSF|aYIm7klnU@nflEacAF-*uS-j6oa3jB<5xMr9-UtQu@p|xI zbMP;+r3TQDhrY9Rbp;`)nIpHD7KAyBq)4n!uVZen-BNe>o2-X zC3IY8_ye`YOJcDNL$)TDVy32JPHdvOtGSZGto3E^iSU8iu;O`w4v|5SpnJ6ZILMv1 zRgrb)WaBFAX`W({>p1lj5esX!c|(7B0CVW;&V_Y=7is@0L>PcB*jZU+ z?v5LmE}zbCsjOnxQI^y9Slr?woD3>YRUANS7eV>i78D7mx;fihoubNVVLbF=yAcza=_0y-jB%p&u8M&*kmY?uw(P5h>*030T8W|oWouA8QKLQe za9ka|%Fd`=57S#$G`F`MufK`ka8BxqoTooQwzv7jp&uPg4O6I&een7(j<9m|5;|}a z05Q)ACnoiSRh&%iQY5Mf@1hTN`@U221ja1SgVipHV$6H&wIYGT;d7$r#~2g^>XORp zVSs2+t!9Rw2`d((01*m3pbK|``tIdO9TW<)4ywf&hXl199IQ_j|Dt4Q0oBb1!;a+b z3m*-hX8fxQE=)SxR}Vj%f~XC*2^^mi-;0iH_tp`doYZK$A6xb-Jy~`95Omv66}z#~ z7p!e2lQ%9RFd)^7*iMWTtQpvmcQb5x;QM80U9V_iC*ja6dzeXfhu5rCEga_gIG)Tg z_|o0jkza}ZhcXwDIc?8G)PAciy=&*>E7yq;_CNOf*^~u#L?|#Vb`zg1=FW7IruZk8 z5rZNu!E3_w;73+p!ts?RXNH@F`sQ@~FFCQb+3=N=E8SN-rg2{@h!YjJ!=T#Fx!Q+> zKzcEw<(1YzBn$&!Sfq1`v%1E$YL9_xkOZSV3&-Pwu}Q5cC2_(4%BHGHVm{GtJCpJ8 zYgyO9qD2mYlp7Zy61vz6;^_rsb9GlUNOv88mm@om=ewk|32rHd!F}~AidE73g+yfX zi*LGN`@e{E2O_o85&KSgU-eE1B#|Yjcrg#>D6waOQs5T#93;J7O`;4zDp$#S3oHpc zvOWQRj}ML&&=aP@snqmZVXk5yac}cqT3qr?@NYHfBQ|HmTc^8aPMCI;24xjZt?LXj zb>Ba?#A(W`cJ|6K3BA*z1)k&yiH@y2lR=hP5)0TLd`E8yxh_cTg3pZRA#s|zxo!8oAbEj-PZMULEIdIKhF|(m<*nS`=OieIMP0&aq(k; zGg%lePA&&kEn48J1t%TYa?R+OHP^&xf1fw`+9ADg6+fgPpntL5%rz5=-kUpyc&L_r zd~Kuq5#e&63XYqbuiIT7A0W<}Sc1@>XmgzDrt{mBC$v36F)hQG-oaJljY>kE{6JOv zzIOIjR*$>3mLGVxHLf_dGcm=RGum}!Z=b?f2cAmS{Oe_XOuLhO;E z>Lt10!vbO2#ly=>g4BsbByR_L&I&J1-P_l{n(8U;ppY0w;|0T(NG||VjPE?GE5e1% z-LmE-*1+gv=+R2kX{5b|2>DNGld~Hf{Hm5K31Q9Q?&9BF9hX%Lr`>+z&VO;3+C_`p zMHhL1juafTq}<=$MK9HPYlys%XC?Y=gt42fQEm>zjRSYAK}@x<62=9V;_}Pb1m#78 z#;O%NK>+M0XdF{OUSozaKdi!z}iQZZ-W?-SX z#pnsZz>ze@BjI#qvqXRaUs~z$sIhK1y94Z1qG(8Y zh)wYvX}>8-1V=hv^fi%MoWAM+Rv0=Oxf; z-1FN+#78>le%{nJ~V-)JIB1@I+gfGyNb{LYH8N;4j&iDgeqyE!= z`7iLH;#Cy#Ge#o3N~8DRNW>Yj9V4vjb>7Qc04mBwCikxiLyTw`ro)NJy678Yb{iw- z1xzZQn%_l}YIElT+%ZNK#ltYzL3n88XbHGRkaD)gkWPnqQ*$=}pvfP+NQmqDI~K-} z21>Db)|oVXs$m<>$~cwvYi2ubNZ9#oN)FzRV|FyObIDS9W44}T=jNLG(tn3`fsc!N zz0L(pUX5Awoz(}6?0Z3q!^4XcOP#jJs)-2g5u{OTVb+&`YYT2JC2;Jzk=u@aDdS=D zcl6)V-nX_QalpkHfGc{5A08^( zHA1W94!govwz_@y*pw!ERb-+F?C!vJ@oy)&W$p48huHR5T!SgC zF!T2=LVw;!?6m})0P9$y2Ar4!8NFLBIbx*FBnDkEYHmMpGR3Ss*Lnt&@rF$^}M zJE(e#Ie#TQSGiJ`7-kk8K~Xpam0D^3pc_A|8^P?IAz}$73cy*W4Zmwl0Rj`c;Z=3N ze_(W;{Ur{QV$VogS{nblODKUQ)P|SbnQdkZx=F_hd%ENDd3puNGQ^6ZvplZ;>d3Kn zCCL!m3$M)G8a13P#&nw`&t^^RO-48PIuwgMq}5pL5tfqlYmmvlz?}cQRbOglPImOR zjgS*+2NJ4g$~l}V|7uIH5`{u629;SkAP%zWaJoTzy5EI$9Z}v(Mqe}#*|J9~rk+GJ-eyl{Ux13Td+0oX`HqYa`W=x+l%|4yWghzJ z&aI+2NlK`ZxHrEZO6zY`;hB%$6Z=O_c1r4zFPhn+glgk?4h5);Lokye1M8)yB-M(4 zZRSq1&vETdbAW@U-cc?bYgDl(6%U0f06dRo{76!Aw6>)x7fOh#*u?8+ z#J@}ExITWNLlKAmod3a6x=5Ehrr z!!djDrcKM0xkN+0mZ7bFfl*LL3YcT_3gftH%SWyTo7VB@V1#bK)nchX%Wc-P_`YmQBQWbPR^fYpL?y}kvGQ>xLS(Sg zY0Dii&UB2YFE#)9;P`czs!FCR`71kEN3P)Rk=kI7lrasdJ$*=YYCEP#lVBt)c0Q}zVPZHUK$y!(vtx{%r| zhXbQmKm(IW!}@xwK6qYlXMIo}$qIQ?yav4!NjE3%;*|gd`mM)>1!Ydd-r-`@axR!S zMEl`LRkf@s_>U|yQAi`THuYykUV;geHHgL_2<{{<%N>KTs&MdFZHOb;%6zxPIw zQtGFdfZaWD;6n=kstk1GTLjhee%}E7q-|zgEfI|IjmWNZS<>$`K%K4So7Uq7EJs!r z;CUX(Tm{~I79Ay{`K{OkA2?0?(iIKu&WQ0TacJBBAM=3=-^d`oP1~}NW2!=gX@{#3 zYGZ0EwC2U_OvdFO&O5O^>kr2VlSKEByaC`(Esu2G;vg7`{lFlLGH84~|KNogB*hgC zAgfHM9f!u4N1gmUF{9^%h74@j9Nni`^tj7!i>7OaDYoOqv(l2582(}Bs_gy zgBgOAn?B|R1=Z^RofopK<>){9!OFsHhkL_AZ)|UGzcOlle~7Q>afz#UAENbm^n&}T z&w$F*bG~7;Co%1acy2a9NMb9c+g%R5`$LYP;-er@PldNlz>f3ZNhphV29J=WAHUBC z>%Tq{P`n}EAG|SB$6ZnS;n1(wQ_J%5aV|KMynyxI7XL$?)4sldQ}FJ*BC!dR$DO!5 zCd)EAm3(4D`JcG?qnLcDA6z;5(0zmeH>ziAGzyLLO#AG%bIJ2KYsqQVdM5sHoNSBw zD6kgjDMb^{KYN}&?_;WH6&dqFr+&`H3jwFrNuch`Y+rJ77A zmOU-^oOz+#H>PfT#)@RNA3x@x;ZLVX*PyZ6CcL-(^CL^=l2)jyh_sIO6DyfcTD4z} z9OgGaX6%#x2kZ^rb3RHESDq&WLH~=rWoVcJQ|(mSJkzgu3gO9|dZL{lg7J2O_jfqi z8%jfKfpFwV@2&}lLoEnR+K?j?2rM(!?86~^3tOwtD4J7uE+zYxaardHyd_m} zz4u~kg}#07bQ6LYQXe8`6d*eL!50S??x*$ohm-l%vGLXNY^BMGdnp)k2O_q>y?3Ty zpOKQeq=+R`w!JB&0AJ7JgN};1F6jGZQlvXB{yb8wcVWg4Z~+WWA=e3o^otU2+Y`CP1|YStF18G)&CV) zmdvd2?^XYkh@EI7X2j2xxv55tDLANQ@n*J*tL5Dic5C@JIcxD1Z$f`>qN#5D0meQY zD)-Ut^WV)`jJd!-Z~w`3PP8F1k6hJG$ojej0wE#~jQGwrpNGaqP!WH>{inq}VYijV z|49?1;iSvoR;JVxu$9z8VtIKk^S_B$-1@K4F<+j>j%%5T|EDi0shEFR_WxTl`u}OJdnTZoi>6)j zzos?8!~OTPs2wX_VqmE@C9AE!O5(Oyb<% zgI;qMBzT~YHM(JoKS9MHHu~-H!-H@SnCvUcspINHN^TRRMM&w_k-VqR)`xE(CAzgH zSLk*XRuPBq)mBQ`26qk7dw%>o!wd3nB(BVZszUf4OT%S8Or3t>$N zMQ_@c!^i~AzDX%nlz`_(Qch`8g=^@TEm_!fcmWrC>vatcj*CawZy>uQi-^tu{9x@0 zW6Whw=k@sfvt03O3&tf=>+-OT^JG<$Zs&tn|o^ZI+)uD5iV!Ls{9xVH6D-S!^0llTJ% zE-u^NtPd}uzTCg*?lPRoDg|!Ypd&F z($;(;`?RjHvC62^%)rBfai1jVMa#kR+r#Cc!GJXwY@}4*v)*!J7zqKmRsH%oOWELZm_0XRWKEU&$clu45%?DtmdQiI2WRMjS&fW>NyO;&Lu%+}T&2z-WvN8XhkyqS1+8Ch*Pof1VCNU6{M=FP96A?q}cGn!oG z)Gj}4vF>|#C~$i_kC?ZP3K`j3Ylx8Fc4>%b^f7g7Yf8qe$!3E7X0P?QZj({`X+sz4 zite@yJU`|VGuu0Dd`fF?^YyZKbE9M6OVze(?ZuX0H(BtiJ?*2rKAm2oa=+czB$4nY zs9Nz3r}Eb8yVxJy?_!jeuKtQpq+*?#2rLTPBvJT_70|f^0U@Eou~;p)*DbI3B`oVHs$?Xp}BHLMq^ z<9_|3HXCvG{rwvWiPGo3Y{}<|Vry%w(q>V)CyZo7*RHkjoqS1V)@FWjakfH2#E{1w z9~u@^N?TjVwQ(~xUAxI0HA_Zi9Zt0J3F%M}f>_#*9|fPcz24zJ0r1HQH(sd;0RP3v_D!E2Qpjh9!@~OQa=_!OKKNMu^R#c1=p_ zs+`1XtsO_Qe;{6s0}DD9J-108lEKqmRd|a5+H6mm9C6;^RLbUK)e> zOCr2&w+CvVi$qc%KA@WBm_p~P(&6Vm-6q@)yQuQ z{{F~!WG{PH*B)aoo;$zV`m+Hp>*WSND3l!5bZ!P~yw-S~smAn2iRIdHq5_!M`20MN zJ;SuCR?ydW;jM@_%wv~Z!!3(7SW-pY+Lo77!FCM+9UZi`t(%gRzzc1D8Jirt^fUxB zSb`ko17lR{u3LH$tI>6Hnssv#^Hjc2(rHzot*^_49IV6;=gWs ziL*#26HeO0!Xfd&)fKC(Ra~LHIBV|e&<#UiiPIn9sns+VPOi=@V^7b^l+m(tb5EDA z&n*`De~f1Z@;FQgYLv~lK@Mm>dOy~TYF>54aBx|ivBk#4O(GU?Xs5@^XR_79yY^b* zh5$U=WBBMufoK-BXnH{8c&IdFpUbID>~u>(vGK`)(tC@f1#U)37@Bl-v}jx_;_iG~ zg=px!%;$l^(2$DK6AFCsGPD{|mc~A!m?9X_)C7v>FdW8~U`6_R;DV1F6skha$e4yO z-3Sd`t6W#_+#r0cya+2JO5iZ;6uVa5P4mn{;oPk%t3SFzzgfiRINea-LEc-c7h72| z(BlXF1q8ws>u>oD+>Oxc>*2DqJoaBD8eEV17HU3=3O@+_;kdlKd>vv1nLmz`bJ3nL z$@OKMBCYN3e|~s!Mrvwq{_DkyHpJ$E2%YP}47CEW^HyW*?~}avoiY*f+DZ%I9-2K| zHW7LV*cyxr;m4bXSau=6OE&NGDIR&RDZvO4ZamqR=3|%lq9r?y0pA>gZQ~O zkpq}^Tv^G}*F;FHkWl^40RHyQ&gRcdFF}R)-UxGoc-<41rV_D2AXXG~sXl?vN?^lY z@Z_{Wr)POMJi!nc{W&XO9k%0IEs#)D^b!dji`?Y$;4+MqFKliOMcQT|H`U;Kp>%U( zq=1~b6Np_uY|Hx!2T;0BJyw;a@*H=j|hyTBrrNTks_2M zJT9IRKPo8n+PZ!E{J9+kTU^e;p?`d|yQhc6UA5@f8Z{#Fny)lP9~FBNZY{Y@3JQwX zVl~TMeyV$>7Flt$P(x?ebSer20`)wXDd-s~RdEo^HLD^C%sLRP_kLhR)KHwBp7vPQ z7+1}cszaNai*^FvGBY!Bo#GT}mYMhcLPlILB(q>y)1kVS5poYRX29N7^ z4looG?SbJvSNWei`-X-XxhwhHmb~ezBvMe`?T%2KTwa4co9k5mw%U~s*gO(Q)Xl>ep@taP0hzgK}58xQ%2O;p{&^B48`fEi(HabR1}Sr zczlMK{(3MI<#TE=F+UV^EIi!tACXVTtJyg@R21CUzfx5B?4}+cDw;0NB)lce1Yf<* z{sSKI@!sBk4Z409)pNRhZs4_T;d-)h3bLM z`%B0I0-74dT9bRxoJeVD>KO48mE1_;x2wC=*7GUzRl8q_(M+eysFKF6t^dTX*DFpe zhzYO7>%#0z97+x-2$NLrek$)zKEs!1B2SGDc37MOfm=#QTn^PD;wacf2+gEC%i}R z7SP?1XePgnx-Y+Da(Vi+@=)ZENV`mR(e7G|c>*@Y^ z7M-ict^XVv0gr@)|#|j&l%xi2hdk>csKRHa2lT&AX|BeBcpFWnkjyk8n7cel| zlY_j^A))ak_eECpVJ}QB{2miK4stmTyCJc_Qcgj#+u$kybXutV``O4AeLx{CMsw}7l(WQ9>F&4(-gP=k!;?mf!o%*gMW`3V{ zHD#gaGQXWrSm5W61eXkS$n=-5G@PwrpQ+Y_H+rC!X~uVv&cMAxNrdWROJ(D7DbLJ} zi91e2u{RGO&RvZirmr5%G-0`h$}Dqj(1C%8DL1MCwr81As9GHr8beXtF(WRA0R7UB z1mn8<-jPsxqhZJHo*t)%JL$_!j}%I+GsH*ob~0@;F!E1n`4%4DenMN<50C(?1g@o>UG@cW(hJz*jLgg+vzd#CqH(#&Hp;B%b(6XfUa4(>(jezUM^J~FZqn;hqBq@8TCkf1ZX+M8{cv29e z9=g8=0|y5d9T^#!-jF0ZUA?t2r9$O0L=|DLm7Se7pWs?BQ`onzXJ^$67aC9{6Q0Yc zsPqR!B@8%n-QUlbn@oiNg3v@l$SyWfOdMDB-GMY5s)KR6w$;Bnmek!?HGxJX*2Zds zwA;zO-I?Rq(i790$%b#f30c~@NbIax7F{=>Kpi#-MLU_JVXE+7b?TqS{J5 zwnd%Kkd6<7-FDLR=a(l<&=0x?4H)A^3z@&ve@(Kpu^r93SRCJ7oKyn_Lw3ld(V;q`V$C0Nl@#zBO@)ib&}1)!;_rI1m8 z8((q`B_gV|HS+xQeU4bHl`)$UUOihP+qX6emtlXS24Q&XtXGZurU$VQAGx`i&4?8x z)25GoaiN+g#<`8b!j0Yj*K8)?iVNH1VUIgKyv6j(O%W3h#o0t^*6sk2z(9R*Ta5xP zmc#@`;$9J*xdu~MT$%ElH`KyR8QurH_G?(IE;Vv!jS*4f<*LBx@y4qgbPw#*+oG2^_ykS;E-Xx=rVd{zmCk|$P2MCEl}(Vu9D+S!Kcyh*Ev zIM`wpLwKLMykgONpZ7jdp5gFdD3rUQD_) zi0zU()WHO%c@Ir(WL{xSSc@om-NQYpM7=rEBR~T`UZITxp|G&u%Ll5=b_*&%ynC{d zC(KXb6L@x!KQCKg2c3>jZ3$`a-zfxoD-`gVkZ8Yc_7C9}(Qtpgif*+jgH@pF3$_{L zHynB2tItq-(W-+1#bMw6&K$g^r=E+9j7&jm$*>5M<;%B3M4#Kl znYGHMOw^^pu$nAmNqz<$F6~%Uc?NLuV3~nSH1&c6FK^hmYR3*_u?p4c{+$1JNf!Wb z5-94)UXM6hL%Q2;PCgIhiol(bE7sAusKlUnKoYB!daWe$cD=c69@E0Zpwz)s*TCnC zxR&^}+OYqtshz2?HC8?Aj*%H;`6U9_ALw*qfn7zGZ5=bP)lsXsegp`X!K{I1bYiUK zc5wb#sEb39$fRw#=d9hY^)m#Q=)$mwcF>QvF4?P!Ti%qVi>?60A2v?Y6g z`Cv9`X$IxrttM)pNNOZIVhXmRd^ETX);>OdIeqDr=4J zW7AXB7bh#tM*_H9&Q~ly?ETEm-CA8=AIi3)B-Z|{P@tlP@#=gp^p4Bj<$R<@Qv74h zi4@AuXF?z8Q&r402sGyRVWn_chc8X%*z4BW7Id3?dXi>mzb>pKt2)}U(OMu_+rWjK zKP4k42OEAPSGAQ|S@Xn1 zAag;&5Lgxh0>mckIg%qsdq)a$lpSCAJn@9GZ)t1upPl8aG@p^?wye?mE-_a@li(J5ujfvu@NSP2!7HI^kfI-Oh)Y9@q0GN-%5yE2+ zBtAEa7a(t{BvNTr9o-m>3uV2`SLkt)s58l6ZS)$3#m=hkBGSchbjG#$Vw{(n2|k*x zroB>Td3m#FV*M>D(VLL<+Z#BoV-m(z31@4(ob64sQl;X_y~$8fpGnhKy$Wz7f#BVc z8N=?oF7}qCT6AokfeS6uxR{X`IsUu_JF>whyLEM(B}!D0k+<`14d1`N@bJ)t+<4kC zzAz9I9v+UB0!URm&oAkwXFzcV__AP#ss4L0hKEPnhHLdt))Y(9n$l*Uz(71JM*=oB zWx6;CCT&e+W&glHWQanV{>iTSg1wAv)--g+{pj{lXme(4r!AWo4&) zrRunpjiT)0Vz2wV8smDqat#Yw1RzHh8x)xf`61B-zeA7_lP!5>*)mf9Bg)v$p#u^+ zOR#McHSI*#vYi1BJ1SBULn*%^r*SvRzE~-A%U-{&OC`#bb)p}SgvEpXbf;yK(udxv z`3;Scag%L8rnjuDsCU~}$YQsqjR6RkU2R90_}=;tI1sq zdk*FZRMMW31Lf4?hpF0=!^&=khX)JlaedL~H71P};u74MBQ#$ksi)7$5XlA^ZM)xB z#ff^1NTo5D<=E1W=w)Y7m)%a&m-FXHq`|#36D8LZf1#@>;ukY1Why+sDp_LMHQwO* zV41$?GbyFZ_v>P(CVZzm%ynlqgODfGoQvJSc}0I5o+dp+$#Hfcrg`ZdlqYv(pCKbX zJ3GlzUE5R$d&fJB?4tMYu#hpi_971Nv zjMb>wkbkWJ3&k&xF=RAFN<_4HA)Qpzw}FrGfISSaep*pZL;V7%~~6oG98fGx3f_`fcqBF53x(Gjg}9r-{2)XU9aBePC$4 z-_TsUkUm&Dd~Y~@Km0+sRn$S#t9O=>-g-oK8X6;cNjPI4W)_wZZ6)qKioa4g%FlLE zKOYi|6p3G^AnP^kG zC3`2L#42kvl;CB17ZEAuj!I=J61Dx@BHU{A2qMx1?LJ{oUSFXI#9H-Ry=3_~p#h0z z5-tr13#IyexxD#P93s3>{CW+J0)lKgav0&}vhldYQ*HX>+C9@?OV+n`8)@cJ_UvOH z#j9;j&StBxoJT28v6)9B;^HWv>%Ix~)@u6csCr0eC<>0%^Q=yUa&>#mMFeY~esn*Z z{g#Nop6(4Ji1beN^gHT1C+DEgOo78x$WZlYo>E*LMF`}g6ch%MAK9-PFTeas#jVwT zy_TgA1V!kCT1`|7wTTy)(UU48!K4@|_JLx(ApjT+GY}1e;@^8-j_BARJw=j3FEjo~ zu;o!w!@7;x&YYf=`Zgv~fQwvE5M?uI^7>97!yJh5#Wd=C8sV>WuC=4r$ckr#Ufc@e zG3dY3!6jH!Y`n+CUpOWZ6OSp@?-Mhx-6KhM)XsZ;f@W|te69!s#eX}(bH}Bu9Ort~ zlLAbr3~TpT2AmMtN(&F~n}f#C#6+q})2nw1CiEVT*UB)YQrwylwQgWLVr?Ykv&cv) zp}eudIfgf24zbr>lPSjw%Y}!X+EO|>#o5}QKap2_9w3k&8H29jJ&{gmx8Kmv>L3Qj%-Rt)wp?AR(u~Llu=6l~;=tw>Wt7&JH!%$H({s z1#wGD1~`zSJ8;mLDUzxW8l|MCYT!doj>ES|6gcpGh80-d-LM!~Sj31g#O9p7*Pi9> z-P-Snm++)n#^Q3kQa?mmbtZFFsAETrWs^A)EyzlX}-r5#_ zjoD#uotW3%LypHSFw;r?sxnGeRaH7nh?jNKpm`{m2bRG>7iF^#Z8)5C2x5c=3e)oN z`d)r?T;VWTj<{ju84mkk@J@O%P1`7`%uud=x1|gAoLyUftrHAP_dDLklp$mr)lbN{ zRU>P?Ibu_iF*e&g+03z`k{{{w72e@EwuA_}O@=T=D5*A2U!ZszMD{-`04`oi=h%EN z!5n?GW*L-vt7mW%{3%;6WdCr080?(@5X1%ig;4@LV>9t_Fz45X@-3 z>m=nqVr#hDqb*xI;BFn7NVuHk@lfY;@ho!RTezI}L^xcAQ!SZ+zG@}qA&n_aCKV2; zlx@_;WSRSk1!~y5TgU1SL`=r(k*UBgL#>vIGq09U@d+H-q{P7jZ+GWhf@*5$#iG}3 zoNW_5eN~9c85rZAN26?LGh@T}wgiRLIND9@l#HPysk?2Ss$h*%krMC*_c6a$bzyl| zQA5Vm_Em-J47F~~B}7(LzPmjE`_Qp&cixTB{&wI4cqhl#XV3{s3JRS8HF`m;i8!32 zI4<{@=B{?$j+A895#l_^#A1v{?ji~CrTss01CTsWN$@gITgJb}&D( zxpuc;x+BO}?Z}8!yA(=M!wYmNy&QcTqxA%X5EY3a$4jHs=1Ee{+9G_wu|^Fk@+{9AXvqT5dOz57t8e3%Z2eoe{Gm> zq!5-zqWa z;pgzALg27JA|<8AgXh*QAOKcLrBv=|_17$=i0^rnExPoU&ZBK<{L~Us&N@|;C^Cvw z@Z#ZNF_rkPBUtrr{3Gt`)J_EYw606|$~EPFh4OQ*TjC4pxuT^>2)Vfmc(?n{ zfVcUy*6mvn&mr#lE*n{sT6T%BT73BjxSkkVV)8COm*$qGmJTa+b$YxG(X`L;+j6C4 zBeHoPf2u{`m~eh+p<~{2udc>KL0t!e-lRurToZ3c!uZHeMPxKuF&P77(ZoOx2f{#g z$e3|DBIhyI5eSAE7=04{*WamJS?pimj{Vr?k){vFclNUy%2$^6?$bB8Nap?#O{=Hp=Cq6M?pzbq9iSZ~bQcsHy}$pZO7FqM zL`;?ph4bN7!}^3?xvVC@;J!Ri>z&t-VQPBax1b;tlmH;=eJ6|AO|-S&=M0j{$x2HL zC^>@h@zooyN5lvgqM+!$+!)~bH8}XKE6A>t}3>>0jy&W(34- z-O}#Ih#jiZ#TE+$FcxT_*z??Nc^uXs+xnMvR&A)m8cB<)OLipRMW|6uYZadpO&p zr^zU_udoVaXyIF0mS*^h5Q!OWX)BD!ob0AtwC>9*ss@b33-lb(BucSxryM-BaUzMr_fvTX16#~z)B{$7c-^O?esJD0f{Ec1Fj+=GSkFBkJQ z$;->L$|m~MVuHCJhG&QUJ62YdR$%1hsR8aXnD^S=+T6{muC{4sH^KfvuBm`=b76X;3o*m-xTOb3=w3)zgt3DgI zW56-NDlHv0GUaT_>=0H>c-hGF-hDoOa&M(j)P9fKd46F0Lrn3~^fjBF%!gQh7Z(T2r!31r8cALd>2uN#363G%h;s zzyP~rpweu>>S8W|_$S-<9~97w=%GAald*xC59=4KNjj#J#Zslg^2R@+b_#3b4_r5R zHuuU-ItfGR*tb_hW>~~ntp;rL@xTKuQ|W`SwLM3A;kC&=7P^4{hLTdUeZ}b=>*lY$ zb3}L_848r2bICR*P5F%U^m2|)8=rR1U)O2iqdVg_mNq0tuOuyn=<2F)@lrS?3pkB<*)GA7!cKAh<9Km^98_|<4+tz5S+k|!qzd>v@ z6>D9K&&~r)-s=vkyRQsCvg<+mQC5sY+g*!1aHT9pHpPeP?`^vVx zTb?$u^;2)d4B@uDl;mzIZx~_Po6$M!*DOT{{wEeM~JGB`KDs8moGULo7Ax%;b zlfPKjTNYAJ_nQ}3!SSG&rt^mKrF}bc#LL&H09w)MNyEOJ%r|eoG_94smt-igP*6BH zZuNI{A2rYdVFKF(tYm4%J`zhvI2xMow{pLuH%XdJgin`MnvwBVNccRmwca_ztX3%c zNHU&uQwCt**mhFT(OKvU_HszfV3W8nA0Ip4Io5MKJ40b&+eE}ibFSRksk~gWbeygP zl}RDT--M^VefBp*ho@sX$SW64XRk_z)3kH8WG!jEEC5Qi{LSP751+oinCY2>f&zv8 zjW;%(N&(5Zg#Vt)8!rWpn}!)vP*pkf``!Skz?T+#&f27fSfEg-TY)&|e$!XyF;ehY z`k(&NrB9d3J#_4MGqcdvewsJ-_ER6Pv^4(3!UIIgRWB*)`L$HC5mwZZWAg6I zLT5_yT;Jg#nPO3qU{<_yf}SY-r?RQ56B`$&rM73W68;|)P*N^V!2Ku$=4*tVeCn&}NZ#{Hiw|VcpRl!+&?s6wy(jPqTLF+p4SGEMf7zFDx z5*zGS;@p~AT77U3tG^HdA)z+_!p0TxX_)NS?j8^o*R<#A8SMv+KiyKuJ+9i0l}L zYI$8#3w?OwY4aoF1I5wRGsk*&zO;8{d(40_oH`ebCi&L%bi*0(3{h|VKmssGy8-em zuPu6s95Gffz3M{yUOW-o6iPxqHat7PqH6nH;`0N?dV-s$SjjH#_|g_G-d~<{z~8XC zdAK-3clGoT(b7iD&!=HZGcwaHJwZ{T%ATy$4TP!cv5~#ukcS#FM5&tLw=QL~_W+z! zk$XAGVC)B~>ltOrS_XbCi@hNJXD|~L6J^nLSO~=|8KdPPY-HGs}S8;j99Kf?uS;WtLn||Z9)#p;JR7(8Y#)76q2@h0e*fv z)2m(Oe@F{0Wu1DXc?}ftiN#Mu#OTA5&56OS?(gSjV@3Z~ra|INs-nUHf5@Pb*u+0_=V+h)rA)HhQq$eF+!pl z@eRwMQnwpQkOgyFUDYko>+uCs`>JJR04!bs2D`7z#6@IuD&nh+^&kPZ!{MIA;t0zP z>Nr}9PicHn#`d~wN<`gkE3W99wEFbV9GA~uznX?e^bwJ4lhbr(0ZdxwGzn(EyT|8% z0=5EFujdRy>yV7Jh z`W+$s=ylJl5p4(nZGclSF+oQ~6@-5oY7GcecJ`}iMH=3m5wL=4Kif174-XpQ7G$SH z@-KEcpxfT=28?Qk@3xHbuBjK4w`uUJoA-3eSAkED1_5R}1b~(K%?)u~7-XYUkANNi8cwj~M#p zW3mHS8d#b;MF-IkBf3P4wvZZ5vpkl?HLr6f&j%JBXu;MM_)fP+q{@jYJrRvgD2)pP z2=0f3)pf^$ms1%JUFm{xIGm1Nk_p_+N8`vPGek3&u4g6>?H72ipV2iZ1Ln8^1HEFa zwmsvl9xV~>HJ_U{hKJ%JB$TSO;9=d%?4-xjWDh3!Lflpz+b0C1xr_`Njkz;tQzk($ za@gymeVBSg!6PhtIXFJ=HqY66fKco*Kxb?sHsxsZ3@hOSoKT@z4{%|%)eF3`Q-?Byd9&$T&YV1Pw zrlE|z)AN^I|CB9$*VBVUL0ucPa2TARmBjdikU6r*WTYmOmEtM;QA+5@LkCOZBK=AG z>H;N_Ud!@w!H$dL{0F(Sz^yh8|7+*HR=u}i4fyjDemw$VCe(Lhad{+M`I4f}YkRYC zL8pvPLVq6c;wbw6MJD-)IpK2GfTycHp-4DqWO#hM$pQbC_7%RndUG}3e~(l`0>Bx3 ztmh5ZTL3>o@DI{!4Gz;8WXMu$et6y93JSb^Jw0uj$T~#`-TyRAj@)T+=fbAt<%AX^ ziS@BA`ojH$uXf`IV4Rv)#|Bg7TM9rW3FSP-2)*BhfiVvO;Oy}e^78UcgWo(K+*db1 z*io`H3HqNf%epq5P`}VF-PG)w$Ve0h4U5l}_Snn`>VVvkqsYjsi`m<=8&|-_n1t&D zlUlO2XV1~Huz037<4~a?-ykqDux>hzI6K!z4-Y>@)5~5wyLNKgSYn$fQRxGk8VL|Z z3TC9_<$Xt!I%*&Q;mKE1ZVJKmW#wY0p@kp_j`d!QNV&wgm~|tSlt>`dr8<>^@mAQ`kMpIQj zfG=(FYU3h2A*L&S>?7w%j{0*?0zgj;P0HxDZNp+>MBUsVUv*$a;4KIOJ`yQzFU8K` zfF}f=@SsfFnbw@e5BoI?hZGV8hDL_t`5c`%@sSgLSvJ``SAB?j(8AZQEEJED?rm^WGy{?HeccMg^Hff6Fs( z+v<$}HP7^B)J6+`xEv4$0+;*we$w^mUKYBCFfXwErUJo28U1u_v^dNtdl9 zwHY6Yr(l7ctjssNrbq4x*M|S4FUg3?p}UZ6Ec~or>=t z#Bf6BUg2_w02Vv0IzBW!+?C6a;;Ltm1BQ4r!@#cdNXO_Fqpn`o$*}lM-03BF*s@CB zB;DuVva`Fexn4T%Htg-qeTYQ=L{Ch=btEgV&Sza3hf9ke&-6Hqr)O$0u~R-D!2lva zPSG9azwq>UJJ02bh*V{&90i-*_AF_!gPSEX&=F`^=bfK^Hk(Oa*{@|0FVlK;>Wtea z2zk50;~ocCENx?5!j~@uuyDslW?TeQ?F39?-F6e|eYg&@{1z!m!gx+WwcJ1bIm{P2 zY4JSdo(8m-f~lv|T%_=cnb-vr|7)T`=j#fNrGLG%+U?Lc?&5J53K3tu2V{QXS#n?oAs$u0F&4kxz(nbx0aZ~_U zU|->}OY!qZ;hgee(ZTr-Y=km>3J8P+$tYdjRYoqZJ23jVk17wRO$vg7g2JNpmwrT{ zvI-^2BS*u%i$ey_sHCvEV}%g$g*LM!6G8v0r-FiP=oPd@xJGR|XF7~nfN}M0jjT+X z+6VbNgGFzku{g!+Icfz{)+nI<1_yN4do8-UnXOoKm|F_FcZBG!@EJo80y%dNi8|&I zpeG|YBJ2|}27Ftz0ei=6$rICT_*ruVl87fcBX;|AiTi>5{BO&N=Y@kkO3F+aN)tj zmnC%f)VBNO)yv4kW)p)Zw`~c4-3SpCO|#Usz$s>I4Eu0p7GALRQLvaE5Hz_75?0mjmbhpLbYjzbR~Li$`~cjPil= zLPCiG8Q;Rfz`6hl>ii%)u3IC|XO%njn?hM>4)ieMu1+S!iJ{q9QO$=#K?4Ak{e&{W zeI_7y(#S&wz48<}SZM12f00u2PLZBr1i*Y;^NZ(8mKdJA__ZlRhPt-45>FFG4q#s* z0FArhy22v^D3$l?76&Wy(s#HMnpGO5!4V^DK252iOqEZ99^Kv~;6bj_pZkd?9;UDv z%!Fl_J+6wo({7Of{OMAQ0cGG{5?YI69P)pU&nkb1hkC~2a>_%O(D%3|I$dw^h?AWV zA;PEE`bANIIzM&IY(7EM)s;P|JEt|6Hbk^&Ev<3v9!*QhmosrVdvfFGJVOZ&&#FJ! zv2`;u!XW}U3UJ6y&tLU9rkD7`Af*1%rfaJtR+advF2CS3zQy<`*4l`Zs^?tOl6x95 z2F&acuuZuZR_oBUMTI&V1PY`BZT0QYi!c$d!a{Qj%9tdM`yTPFO-^FoK3+XN%s5P= zN1pJ+aV@GIgk`xEiV9;)S8TUGu)unU12R`)h6;GG{Uj0437El+HK>Rm+F>loBYOEN zS>Mty^3*mjsN;kRD=FSVFfQIe(zz~HVxvJL=l_I*%V#-b#sUIyAuUUb*E{1U_0agS zHO-wBCd7&2U^o>kWye1c38Tv&C$hF-7~HYjJhQRrMnTgpbG-^bshokv5qK-x-2(@l z{0|?g+mHc z$1S0!N9XVFEGCAQF=o8EA6N5LUfJl0R1RE1Ux-+iK$cuPvPVq9pILx-YKvZ-O89@^ zrFJz{zmM%BnZIaIC8JQ0u(BdpxL!xc#I>_NHAIGJygoa)yo^SWRjKh}GEuMwSA5ip ztO``$&8;m$B!BCHIR|Ze%-q5ssqTU=sXJzfXIR;AGE5ilVJ{Xu@Ig++bn5t1{pHBemxz~)oXJeOf=V+J8YLQ*FeDVI2!x6L_M+lo9rz|CpA zT~ly>xZx44JXy@sykTj$SbPRz`U<<}yxd1!16^5p8(k6rClz&7IA{{~PzL7ak^>EP zWNb{>;o@^pht;qll9F6v;FKv5&MOA??d37`BCtWXt^HZrh>4L>St2Oj%rg*XdvhCo zEw}d&wHG)UmD!QOEG&yhj#KqFhP#(3x&4=y&!Jy2m*nSvL$&L{uwrF%{LaRV9#L~N zhPqsk+F22OPazg*NOW7wOuQ9UWn(};Jc5CR7iJ*XY1KArEtVwJ<71*|d1}Z3%*10| zSSMUq;Lb+<=83`*%r6z`f!*d?QH_#MX3qqDXhmbDX9>T_6cLKQ1~=_!wbHd5(C3nq>|6C ztZ1z&!Ieq$Pj3}*nLbP?=%4doBwD}fmBbDjA`$)d17d<1wOha1-PtLAqQ@${ zyi=QzkyKXx!ujcs4<6B^M!%S68!ysqS$Mb;&Q;L>V1DUW6QS#lDvy?>rP6<&Q>XqU ziy;=b29*sAWdP~7*vCAEFv(D4)IaipfCiW|W@gM%iMwIX44%+qz;j+DJbY@kiVt<$ z)qwaXRNCZ~@ej(Wnlq1qnfnak?h;oY4=I2<{h8K!))P(1RxMFJHNw<3wuB zFP8|nvfxh@r`^}>6__!DT!AiUh$r6@rm-wtgPaV7Jj-elwsFU&zB;DUcc46zQGj=@ z(3L#AXYlk4VJXO&Fd?U=){@nS)hJ2<5__@@Zr+%LXqUE}Jk?~)9wU>T$edF?+_#jN zuI?z*-AOCIk&Kn&r-;LT$}dVw>B%Y7dXwv+J5?gP`dHL`hhjU0d9KM}HefS9P$-vW z!zT3g?{m3*pw{f}zCNC$y#>LRDAYEL;A>25>bbdR7CrY9l<{(W<;y}n4F)}jzu5If2b?H-_ zEiL22{UB&XE1_Rz#htMq;KE;QPhhs3P*)WZEjf-z;cNxk+U|^l$kPNt9eFsWii!4n zb%n~vhpUu_#VG^LNYjlbmc}E&QDY{S`JZvYRkObUjplv{+T!e`MOZDCfPFOg-6oNT zv!3DHBCxG>x?&8#fo?~foPU=k{}*oT2mZ3s(pW=A3g`xqpyE58S-AfKn(bf1dcTVc zs&ls`%_0^jigo(mrN=farm_^kGFXq6N*^NYz>>D?X|DjTEWqp-!OnoM%I1AQLD5w& z?gs&fL@9LYj#`MH<}G4CP4(gLIRcyzi;Tu(Ix1^ zu`4gPweHr3egh7Ok4H*&(w|7+&X&p{prlNS(U>0i&7%!&{%U%Bf;KcXg?;n z4B6q;Q;^Jq0FEKZrIoF$@t5iUhh6)Qn^$_^4X9hd2-MoIF+ukChjPE4EK;g4ZmZtI zpKX=)xP}8F2!z&5<;KsO{k~zUSWipVuRUuacQ+>~jWNOx8&=cPQh3qk>wj+E-jNgU3qlSXyFXxp zNuFf!eVN%~W5MqgBLprHy2dPi0;PH<&_h&09EQj2?kQH{{+JLmCi?hL zux&od9n-Bf`T4O3vrZg6dEHMgyQJ|Vv}k%-XPQllD(LCyg;e6b^Ar>oF|4E{m1utH zO0{m;w$-fTg@(R=<2S`P=EchK0!XK7qYZWTnW%uV4;gBi@iXN}hw79~(UEKeC7Sc0 z*&PY#!{~UVoV984h+$ba`@e{I<&xxnY#q@N%jm||`j%{m-(I6bU~TKBYLYtMj8 zrnmPMBXebI<)3W0Jh0pyP?U|0qcGtIUZDR$LS6}EyjB@ttbmADohGBupjv7*q5&(b zroKJ}iO$LToCpdrg1Lkk6jI3Tc>kpQoS& z@tij8bWn4*e;|Q~BT(J7@iF2mq|BwDhRA>;&6g($PS|GE5SwNq^AQX|tO2Ls6?-Nd z`Fb$Ot>Nl}gctt(p{A_*M_yK(CPR8LvKaq~osONep%I48$=HO^@>R@(q7ma4zBvw~ zo=u18yw<6#NL<^X=2m$*tBIhX+uQ3?;xQ z6d4|B$aqam0j}%rVxi;V{x)WE&vT?QK|<&Q1uEV^bvZjdURTu zRGzY_f3__K4>gx>2V_^EVA39N_Tr~ZdC%;9I{UFE=a9Vpy9?W@kOK!)RmjbO0W$-m zE-~i$V_x9LU7ot(8*B}~h0(`L+>-6}A3%>i8=m0yfQS-fGE?RIiq%MK*>&@M=)1Dr zN8+k_2QJY3`Z5qL3$Oy3!%KI@_*_8?U~{C)c6N$@l+9O_0)0cDkcI{P_XYQXZWG^+ zZgLJ>-XN~FcHx1lx0(<1JROluIJ$i@&{3#R-BF07ySip=hzB!`*G(>@R9N zqtA$iiHjStNW-V10;{P05i~#CQ%&_F{}x=vH!*#NA^E2pTBZ;&S{tq zPozrGZm0a8VX(<89#n6{PSfduFaY+K1?FAJ4Ujl~9=|@II;dX40;rR9zBHykq{Vd! zE=B*YW(-i(0slWU%Db~ini^ul8{{ziZ)YuAm;n42G{~W@lq?M%$d#WXLjjGE14$VL z>2;C)uM)hFa{_T)THAX1r8?H)fw`T?EDQv%dw~r3xa1`C|FgvP@BqOraDnR#gXeb1 z;c8Ak)M~6-#m5)7yu4hLHRjEKZRws!$D86?K5E~zw5?i$vL7o@9#PclMml-+y&QQI zSyTXvnY)?@@ZYDDjaJw6TWOe*{xx|8xDZUH-#DI)sHAB6Lob`bd~Am)Cg2XTrHr9K zO5fU2I6kJ|+TD%twE1#*)WVbT-BECNz@gzJrLM*Vyp;Jt_{rrzaeAB7y{6;W7+I>4 ztJF*DZC`be%~UlugQ_AdC4~mkf*5&BaXg%N`QSNBwd1{RL@^mIw`ocwUOCDFK5xX| zS-O*{e_6WnC)w$^=@G~q z13mp9Hb?g5z07t$|6GBz*+!MBX22bccME`#n_pwzplj0rs&ViJEMKMCf=sBWdXDuP z8q@a_3lH}QItw@G=s6V?fgn`k<0H(*#SZKoXdsrRju^dw;xBn(%PcC|Zj|yi3DsX< zvi1VQ&Q1@6nm0~2S)mas)Lah)={~}x7K{nGxv{a#)j>tcf$Fok+Lg>UhKZj7R*5vA zJVdbbYvml>w6`TF3J z_}}FP4i`Gim!>}}lPy^#Y;1fJpG&rK{v-0a@fH;w{V>NsGu(7zO0n_W!u6mD+VRM{ z0F-yv<%pCBam9g%IPe^J%Gko@842L{|9`>FRWsQtN*C}KYDO@T57%(wa3CS% z3Qk*k?WEv^qbNb0GrCo7`qLg#IJ9zPhJ{Ev``bj?pJXn)0C5@jUL_ipj!!KuItmq7 zTKtW3yqb-SpZ*|&%X9Ame^X1tN?tKhvn7XDN4!}eOZCrn-oP zT!}#1|F56dsw&+7U!;xyj**sVfo2Kdh5SX2TlENoF-o-5J?1{f2Ki9B^Gq;*kXKN6 zBOUw4chtds2jR(Vk4xXiHHL>%nD_+M>bnpG(Km|UQB07)D2Nzoh0Rz148<2>%v;k+&*1u()H*W_wb z_+U9&b)LUznNwj@(|2G4NRE;5#%oOn+`oW{Q4BKe*UuI{4J3Oa_3AyQQe|@^sg%Qo z&CN+b>Tl-$5_ZW_064aQ@cz#`wyTvh=F5+F6lNJV=f6oW$?Q|I#E(0BMXFcf0|KnO z`b7;k`dc<_Rj{yrU|^?cIVzFN!FdBZ`{h~DQAuMpOJ&~y0)*v1XO&+Qo_RL?DssPi z!Bu~E+tbJiRs|5i)dT=|;kF%JHg9a!aYdhb-ufBB!{rTQ{+!UcxSZp)ti-uD#%+UfBBxyKHC`#t$ebK>*AP>4B9ETUt(Kop zMS>OF{e>1d5rb43i5*pqXT+wJct9JBc)#BDfxq=v!(A|hu70-lShscZ{5dSd&iL3j zUFP&L62uo?hGras!omkZOl7~JG$ZpZBCKHR{G;bprfPuz%z%L9(D$5-GV}^DL0;Z* z$Q^*n+qZ0otn1)D?=a^Rk#~uZp`{?ziMMl+0!ej9oH>tj)$+Rn+ z6XfH%y)_+R##vAh`;ywpMp2k@7F-{1$k0E1mWm-J&^Gc?6F_0gdQ<6PC z@xZ`PfPheLF3qQbd7aZ4->yy(eBx+*fxb)_>S%-xVr@J$K@G>-E zMl28(ms5(B*3l8)ZRqPWd%XEDX0L!amcTp*+MUGYSMpesR&Li>8aFx+9%x~``AUQN zd3m{q48h=z*f=&Z0^q3R4>U7?g9HY0^9I~u14khN;)@|5*P0}}^yVX2IRe{I7_+gl zZEd*CWu?A>1Gf?M%E*wR0GkI?*c+!j@ZJbyv_SvmbhRIOVzIG@gR`^#Zd$tI?A2_T z0vX^Y!4@zBceoF@SwJ{>jHx}1R79a;-Tbzm8Ovf_I`8m}gJq#T0Cmju;D#r&(c}Ho z>fSmH9?-CuoXiVCy_wm>1CAa^3jC~`^%;2ZK7-qgR(l=B8(s0#xAm4Zlb-yHkapK@{LBKrkS`SpnRb4o|P>1Q^ zQ@4D3%yfZHNN)3{Uvg??Bo1MaizdOp z+f+yD=VblI#;WFG_=W7hZL9)4K!zeJG743K<7g^N_wP+r>s=){;4#R%o~ZIHo^x=w z2D5<3{+{(;&9nBJ8AF|$o3UQZ&@cw>JV~rmAS98KyT!_(G2b?Amj}|;Y)Lv8@YhN@ z*uMNW-|dl=$;!$C zQSnEat?uFO0P>fy&gXOW~3~+uB3#bP`7J_7gFf??9cIQ+C|Pg*kZSPi`91TKO?DW_apT1PBO>03mSYOe(2( zR97s>Og`@e2vcK*ZYVRdGaUrbGY!O_I?~1UyVXl&Db#PViE!faIM>^&@JnRLA%%Du z6fm%Dwdry?%uHbcWc0{;5_fHXnXps5wBYN2~Xvw;bu0&SiE=!I=mIAee*PW)O=DM?AjF6Vavrt}sBS*A^ zWN1*vP2*YH2AozEg1oY_gm!vzcz7F#57)Rr0fj4Z!Q74fd?RZQ8qoX4=1;GdFr)t> zgRE-dh5)iiC}`^^R~`-M!9sl|!r4;scX^r*A)yvmh*??UN@e=QRde>p(wrq9`4UDF zXz&Mk9_TOTZya>>^h%xWxF*!fq79Or9#aG@blvBBMV1eD7hw?*ZPC>D?O>cCNb^ws zG4x4MU`HlcdQ0^n!9@>`6abhqJi|fuPdwEa=KcTVsp?cWTK!v=>ixrX?a5{3zV*Td zexE0?a&(7dkZ-HKX$SwZgj7y>mXV4fnK!=$T*Avp7cKckbJkkh7y{qsFO}sR^Ddv0 zpF-Afyce%YGa|J*K*c52kv%2U>G zFFzg&_u%2VHYy;uS@75Twl|YE-~wk&pwCY;PPtyt-p(8lXtRZnu{=8X!p@GGidxxA zqlg$W)(<|F!9SQu(g}SlNQSI6nM+u?GXL9cn{px+*3x^s7VirUln5ZkV$I~{Q)%B7 zn8=VvUAnG-@D5uZJ0W4l@22hQS688oWUmqYkFZcVG}|C#FBfCkJI#$5bl_NC&GZ2(_DCq&YK`Ck&UrmzcGS>ijJ80Vt8`27rpUR^7;lE z!g0SkMkzL`Nt~8D7#Ori9UkNkXVnl0ZU>|w#OPUf4z~6Vg`OgS`vB=H0Zz>iC$=s&)q+T^YYGcJ6nw#q|UQ5aP?tH3H$A==zudra!X) zN33EXST5*<309D=O>g-5Y365~h)UzrD+;jdd4I&GCj0pKo{)>YD5qDnKSN=xssG*pH)rG%~}`AF+0j*|LoMG zSBuK0w)Uq>EBcAKx_aw$64&-ZD!TMun0UiH}D_0T#|drYoo{QEt$>(qH;0G)*WN%{HIm_dWd$+#p- zVXG;<#!*Lg<&U7|W>cr;KQ#fu_tdY|)a~m|U@d`_w2$$5pESr^3sVewQ znXZn03*6p@YHzO#E22axHl0GsFCnQk=Q#Yr1ny{#>%dz2gh{TN26)vL4j7&Bgp#Kh z2$J`UGI>|4?K?TUjSd#nb6n5RBfs!H(aXBh)uVoc0y{K1Ds|#7#b&jRS+CK7-eAd6 zHFr)|imqQ;cP!+}%|j6te!z8V2Hw?b50cK(Txamo5Bp9qrqSaCwLS~C*B(++m37A? ziT$6tn8jdVnDN|nk@FO&JIoJOd=uQ;_vfIAV#PVn{Wfzyh@nw;6A=d}GHmoEa}clL2$H-SmT;{$<}x;>GpY0LfHK?R%KqPSYO9Qq1%jyQv% z`lEA4`z?EPBm0N8Xh&k9{qz%9_-xmqZBemooV?%PldVyGtlaIvhsx(bBiVf}P(>a% zTnq|v!5#s>yp%wJD0k@(~D1xFrEMk$Xk;tyFAaZ{Q%&D&3J z?1&VqTIBkh7i51mayttmjh5-SstJ3C;#(?AH`MPL_DY$VE8J^U&KBfz&|mLr2@e^$A84S z8VsMR@?|5(AT2emMkr-aXJqGTX!fAFl$@Nw;($kxi#s<@X+%VlgzDUJ4UOuLru}%w zpSIzx9JF_=`v;a39waSLaJ6+wVYb(ZUrI{o8%2WU$PU^2c#1F7eNVSu+Rcq@Vt=4M zWuvrO)Qz!zr>lh0+=4)_Bbu)QlU$vl&LLBD>Ga+1$@?4P#YtD-?Xap@6M#=K`BsA1 zSINn1Q=WIc=&4DHo7C}u4_Kj%ysZ*(db$pje?g4-EowKmY%&LleuKrKeuu3U$qgt=TGc z@5@IxTgXom_|)tSh;U!NcySiq@Yxc#9wpZ$-0O@e$;+H*Dc&VvAB+c41p4Ijto^;j zCacb9wD|%Io`5=pu=@%v!yz|W+*~PQpK}{WUrhNSQumxzV0bg5k zhlYe0l&Hf2=W2ptOZHfyH@ImG##+h!DKBBWT{1Q_?`2zCRUzut$jtfl6;6vX1R}zM&DP6UULqCqo!j*XQHK{;T*_Zo=WrVm`C#r z$huW0)z=sKmXD8z4pZKhvK{B2ur7^X1C9EKE{a9d(Wa)-r$0xAMc#hHdv1LUMmW>y4nXaa2s*zTC>tHvhl_%dgG;yzLz^$~ro< zlbug{g#*;rc3MqpewOx#ddf%0rYlWv1?&FcG%Gbz&10P;rKyh&cGBv3hEE8D*@jS^ z=x0g_N_!IGyLNU$Yip_?xH;&x|8a0=+qd9_aLf}}KYs$O=Obw9g)15^i_6&TCOhP3 zb|xP&y=OKEp;=q*%V#{j#N=CD?jU!0`rOklrt~Oik8zgOmqe5f=6+bF1{&6Y`!elb zf-D1^gmBdbT;D$>({YaT_1Z@`S?p7mkJ{m0yeLCI!o;7{L>~O|K0s`v5Pj3O8?BYen)H^C0jGOG>hPdg-bD;(U(~P`&;AugalDiXIsNvQ>5>USPY?KIx3~ z)yk2E&=XmCJcZ;X90qpB8);nBCsXUaqnN>1ABUAHliS+PwHb|>_JIV}rV_=riwKOK zN#iqhneM8sDW27Kb))K~obH=mUcyq(FF=X_$S^a4njUyaK-M6oVB%$Zaaio(mVI&ez0n;3w5H^$%lD1V@2Llv~!+-GM_ z&HR~x=XBoH)s=$6HX<>SX;@-@22Fh76g@tEAngcPJ4`K~#Mq?SUxOorLuYjcU#4p= zwv89&z}SArIpjrC1Tvi?Vk4I`l&oZDNlQCZ)#ED<7C;mDSV{i^&_wtqxm>(+EfxZM zrCdDE8ejD#`Bcq6k&29Bo9lHNYtCA&A}p0|sd9+Q$hh7{2qSDRn*GJ2jfXm(J7@Q& z$U?tkVmwhO5l38*jF_33(I*C;I|-@@d0s+FpC+jKMn>16QgD(RPeh{8@`m%D#W5D; zTLLGcMYuSt$Gre1r=TXzppJ_z;M(1x0nSi4+mC3yui@ck4a0f`}uQ1Jj=Nxc^OH}3{|4C@)W#V&E!9g9>0MMSux#-Y~(r? zEtOfVlbMiQF{izOmM}Ixm6tbWXV;yeOXLpw0|dc>8v`|3A4C`V^`18iYBw2hy!Ly& z)}!`BByTs;4&V^Stq8@oO)mwN@(j9S>)6>paxIf`oJ#ozTc$3E1QcP&OX z+0F`mKl8a)rE;~d$FQVjZBR`I?hkgr!8YD}@72o6v1nFwD?G}PMs)P_Pa#u{A~ZeY z(_3`AhIb2zPm6(px_IM!=qrWsFBK)}F-uF_Q&X(X&E#zzU4Ao%hb}{K?sy&{TEr`% ze8DeuETC<9BrB(PxL?w}CJG)wdx4uTUJOGk4yE%|6;jb@qhn-yV=gaDl(g~cNV!_d z)+NZ3da0GW#XPm2Z*R>~D+ob5BOl3|FYn&|Ss9R6g4Q95hi1Qgm*>UWi^9gXh?w29I?ul;S z2oXk^ZwN2UAbLtBnBkY@};#hyx#M)|)wm_SnI{DU$Qo2a9w4&QF~x&EQBF9kq8| zBCc}FAf)>f3ew;MRGd2XP;>B(rFJ0RUv6HSYj#CCe|>QWtiAtP8vHj5zQz{8vXcK# z@ES16Gk@yzJ@+4Nc?2(j!Zu?0E5Pd9RHyk)69dgZNaYIINT#+3@_&3gVbD+i{iSW3 ztPY(-qn-bM6DCi64!_80FkY|&&1jp%_#dPdKWp~ zN7C5OC@2^KVN6!<26BxWpBY6*>i{&2)XNuJD|-}ulLfGaomrc>g9EOi;dtBN#Z!;U zEC>f4k!qGxZ8uzo%d1j{u>;EJ0UJ9lCnqt~%&sto-96toOQ83I>AQexPMpWy;`|~~ zOA+#;BBSOaqDs9LneM5m2zD_F%r?g{ws%Mm)$D9xERJRiJW-?y`=_F$(OcBJyiVgT zf}Zs$S;?+1v%I!y@SL6km}9Tq)+C_H+QxX*(TEiXE8lq*EJ-um{O(~#(a7HR>sh2TP$jEN2*jcQZ^DcH{TJ7pHJ_{&s;CV@#9$$tn5yw97S#(S9&S8YFC}L1 zufpN4J6d%p0c&jZ;n8+*2&T--1E-V>-Q1t*e*l`0@lnFDD>zej{yL|(uE z#Cl`--roL)Clg9nNyi(^yIp)+ufpO!#P?^`Y?deC9Vf+M&0%UV4$PceNU9 zi4U)$lbb`RPJCK)cHdpCH##>>T2_EWl!fSNQ`_Tn_c~3s8&{m5cpa-$X!z+kn}S8RO4A z9dV}XpQu*K)sU=syuU2}G1yj_4bYpm*vtVn(bJq2z`Cm%dEywu_#G+y!nTp7(Hf7%`6S<>$?{o zF%4~`hpeouqLTjaM z;%Iqd1v5V(BB|0#h_iCIlmrxHk6msNh4)R(@vq5lE8)hC_`rt<`ulUP`!rthdIdV?8TLnaXoZynkpzr zmiO#qp^)ylhol;L1b>K)1!)qEyMKhLKDhMBCO4|AEE$Q3>g~gNs2*G>wA{O`m9La7 zi26klM@5NKz7>&p_%J&W>$crMn~0R0&r_9=4GOD2oK=UOZD*QBch0Ha>-LAr8B%Ll z-@$VWfq>b6R1`~qEs+2tBP&L~tof^b6)iUR3AOEv6X;S*Xls0~txK*x)sWx5;yfTP zw2_hJLy9c<;E&^0s0)T5pIe{P-1}(y0A{9&I?+8V!jkhvuuFuJ=1KE)#^J&(qU0@3 zSq8du$?K~~;%ZJ!hJx&fq$IG*>ow&)gm5KjTl*;a<*#x)MGjH@7Vk#H&x z{rFLj6Ooe*&Cc6AOTQ%*-HB%5txv>&QchPP2YmmdO&k=;a`j!uG2qX~z2|O9k0LNIi;0cFg0Rc!jWU;1>3lK_6gHJ!TO~Gmc_w>i2nk;_UyDhf(syD5WIkR={vT4Fr6sC zBdeR@GxSJh#+($JcY7A##N3=b2?;}J5d@e;Mml4K#@X3~L(3TJ=5O|2kmF}g1}UWW z%28MU1^`myEH67QENB{cilr+7?0q97fAySLR)nB>m!xjxDiZSXQa}ld3;X3>|4M`M zw6M0fhqX=2`dy!K&$|0e98KacZPwkuUfAXq^)=@jmFPF2Rj9(@sq2YMUEG^~m0|UW zh=GCpHuhMM#s=5!neB`$R+E#HPgR+-fCP!FJMOUP{PfS=q4N3=wX&tJ&d3uvYBA7q zo8-c{z!ve+#RPR*$HS5lSiSz7Xz4Njcjla^S7FJN>Pc+RVR%DPYHW9`*V=P~HB@N# zXk(g2lvuVdf;G*lN|KXBWMVt0yl!&`rIh_ZrR)heWInA0ib&+CcD;+Dcb}RbEM5y) zR4>GyOt#6Wt@M8RWU#-;f6%`#AHpay9-xu;#w#9l#iAh^2WL_F52v>jsH-6W!oE)i zZVe0^^70ZQBezetUp*Ilm6BM&g)&&eXM*Ncgl)2vY>>%p`(prMt6?B6W`dxt^iSu8 zjc>(4kYjX%@ilgK--kId8=YKU0vPjfOM4g?2T9z`C_1Be3&p`b6ZuC=o(4u7_*Us9 zIjbIWWI#9uK<>atAo?YQ{j(;B5|$ICfV(~Q!nU#%4k##IQ(GsDAO6ZW4u#7a7kl!v zeGOG0J%P_{Mh8slN_ZM&2xQWfzQbO-obC`SoapFF$6!#w=r#Ov7w%f{iR0hc^)jUq zl*VuBf=75Hv=aGmsqQLlcN%@=w=){!&PaAR?PJDuP~0fQPiMjEUC<(2 z1CR--l_%`nyaEdl_>?!-R))x-C^ui{>M^8dH{KY|oMJ9rz!g;x)Fyz56U0AhCDIqy z_nU<<)7Uyx?aVFm+olB%3~aWQK}Vvgd4ir^raSpa9RL0MZNGWN)qvC^5>$xn4ZGO_3k9Jb#yH!*{#4(wap+Z)crqTLA)(_dl2 zffOfEO@7TN9%^twh#ypgoJf4Y8mHeKk)$9f>0hW6KUNV>&O;xQCz zFec!IH0oQFJ%SNFWPp(ql~}jW7-bdN&!-{FM%<`ijsdzG7`sG9v$al=C*Rts)C)i# zy^WLxkEGG7l;E;5tIx`$=+66ZmS<*uJ^}gz+6?jNp5)1mV8imWipt@VvO!OwGXlIN zAi%i#wQu;7jH4q=jxHG(5P|;q-b&5)Lm}h#)Y80!{gybGe%+TY4vUMsV{Tran7AP; zzN5Ubc%^6f5G8uRh<)>6FmBKlf6;x4bkXH-r;B8Wb$lVhHlsArAacp- z1vQYI*U`D@>0{XY!dG7tENVS|d57yfQL%^HPZw*jvs(Nb6rw_?F!i241>F}Dj`#+3 z>!ZiVMLhQ-7!i(370;39|8GM_sn}0%87F^8Mm=lHmZ?8G$gNcAPPk~Tj?Ibc8BY(R zkISE1>xJ>U_$R9(1f2T}+*Vl{T$kc?JGm2y{;$ZQCtwWa~DQY_O zNO@&}OY-{2H}|6xqO(Pkd~2JU3D}N%VX%&_JU^MPH&~d~6?!b~F2Fz2 z18jA}=mTW0cBIKntMNWlt~v=`)Y2SdRb)X}n9?TATSF;*;*OY``X47}#2h?~*ccqr zb6A=)Q`_z{5&1SvM#B~jMKY6}C0`c~w3PpkvqbGBv@OUAzJbu#?}9v3@nZRuPS9lTv=YjjBEdZf;ZsbH`hD{6TTVG-#Jx&9TJxT4OY#! zM#ajtvK%4?Ril(1#oqi|8|;n;+Ta|N%F&)JEWoQmmev2js5yUc^rcT_QG}t=#QRHf+7C7*tIs)&@1~pBuDIqib z4-c>5h&W&bBHOb)w0sR?fhDX-CAx5miKUB?#r)W7MZY(19OD)2+x@vhu@P_czGoG6 zbY#8ieU1+LXohNBPD9|tZ|P3UOIRhI=Qa-Cgu5_<3K66H`?ZLpHSE%@Fbj;>e*pnW zLWOAsj7!4Si;quD-*m2>=BBdFsvTpSwvB`zik;=^&6|X!8ZSC{T0ea;rVN?9vG>^M zdPG75!~z>n9J<2fp5{@{KXBm@KQd>2VaVdr`UY9}IqS^YCV6e`z`mrWDjfB=8@Z2^ zIyaa5LEaEX<_&g9_d@HVYptz9Ve_u`YB)H={ZjXCKdARwGUU_a_{Q&2eD}lu0UA_O z$76Nr?D^7wD}#Bd=_RYrfG_L1dQ;dRg3{9l7a=P4v9nR+(_?Y=J!A?ZJFsBNBXqBR zO`DlHdVQvVI{o!)|JGLHmD(+?(PB?3rwI?5wKZPPgGENfz)d2*VxPW<(H|9Me%si>}0RV3Nghs)J2hW`rV>?Cr)o05R5fC!8cV)m4?e zyDrH9Ci^2j7}va|HNIjq{*-*#79_?g2_rP>f}2!Mlgr`uao~J*U#dd54~RG`Et@o0qsgJ^mSw=bnWqf| zUvY^_kERJg68Wsamxh)&qZL;8MMba-j^SI;S-Wi;Qh=a<6Cq0hVAHQeC;fM4K_INVO^Ceh& zEMAk4(5l(!W^;AhL!-OXii+a?E6#Maw)}*51%ib+GdzN^r>^?u|16l~oK(vxj5QpZ z(C*oG_69Car+}sejTrU|TSG9LsLDZ47YSibTf=-{q9K)%55eS>Z*g*-Pb}*WXs5j# zQVL0P$dwwqcmW7%2naY{W+@6vZe|Be1)H}xmr!9O%RA zp%*1-gVi3I5dRw1*r-oJnwP7k6lD03pI@KY11uf<#hL5 zty-+7ag%DMan&<$Rb-nET=XFl>62qyo2d_&(P(s9S=qH>FAopWtx;qQp3^bUoj^(T zdQU2#5kxk0>+r4)OWgP!U21`%ww+{Pcm~Q{b&X;_AK#F$SaRPxuTu+-E`U(Eb(+7> zwO{#jZ$$S_1$1i6K$Jq!1>^I#Wd}v%%r<{c4PZ@}(?K3+yYvPH)lWs;iGW%sn^pme zdGzz9!m@W4lH`3LRTu`-Y=f~_J_JX+rSiv5eypW62s=7|Z#R4E)M+y0_pj$Sm%@57 zbnA34AApcnKyVLIt0G>J^DQ5`CPnMnm`MtsixtZnlyIPIYO(@xg?!TGHw8VnEpq3V z?y-=6zy=HlV`GBue3;?f{6Q2RP7->D(o1m@#Fj(YBwt@ zfhPH_$e!Ef6gAyy@eS=N`b%)o&!z}=j7TGx4jbAjjOXitd zGvG}*a88oW+ifs^<@~>gPwOC8@4Cpigt>Y3Xwo%lxH+neMiC91h#;uv5)!uve5if& zH|63Im$;+ZwC%ec-3ol5_$w(9gUIvq=N1qFRA?#5T3m=YQu<|VZM!OZ1#}-7nGE4^ zVX`A=^kQ%Gh(Y!{=0MgI;U>%#lod@X)caU+T+kzYnul@vW}US%S;tUp%-ULm%P(Q* zZ>dhwq@*!G!dQ7I_zc>m13|YZ-&8b>v%jWrgjQSGqXa$~B*XYA+s&@YUY;d*e5YYE zIP6#8wL_AH$HX*+(s%;B#mK|s01q=|$h9&{x0n;u+XVCT+2Aa}6b(;pFR~Q;bsOr? z7m4oz83JkV&YG-*YpS5R`J3mo**(Kd+QEEE-s`#h`ao+ERV|2G&Xbv3V`vrAu_DmU zQX{SRqUPn`cs?Nt`Xq4WMT3sko7Qe`bAAEry60)E>AAS@f%Mha=RG*M3)__r3H#rD?f~&`5^ze&6xh6)-fU1|8q;(5rPwXBZ4+ZP(=Th26~6HNqubTtq-8 z0mq7ffGbSI!&P(im2?Qel>=*dKE4Xva-{apePe35C}I6ksnqxe+||uZo%j%^M6RN{E*#k7AVLj8%cW6Pl_(a4i{ z_Lpncb{OO-otm)Eh@=GV+(Lu-@(x;#lG(r4;Dq9o-m`Jo*+yAULLffvJVmy>qy0vp z?tU~Vk>s9L-f0;z01^7RlHs=OYh?ZXXM%!V=ycVX^oEP+9~sKQ4w$dF=JpO-3OmUi z;st}sl{J3N%?L1I^GL>jTcwHLZ+xw)q#CcFp+VcI=$D`G38GA>jOD)=c2intP%Om+{aYf<0Nr~OQ^J&rDPlkO1(q+hN*BV>XLoHzM`|QxN$G%N( z&rt22Hy+X-MSVW08IMIKhK+%D;JI60Q=<SecwSMnAlc{+@hQnSEKzUk(Y=4CCTf#d5M=!z u6IwhDyj`JzI45W(kY=X$ba1B94ro#`Y)yU0F5JU`4=Hi^d--?tp8W@T_ZnpY literal 0 HcmV?d00001 diff --git a/docs/user-guide/study/areas/05-hydro.md b/docs/user-guide/study/areas/05-hydro.md index 40a02d4eca..c2a535bb2c 100644 --- a/docs/user-guide/study/areas/05-hydro.md +++ b/docs/user-guide/study/areas/05-hydro.md @@ -71,3 +71,9 @@ This tab allows you to configure the hydro storage time series of the hydraulic ## Run of River This tab allows you to configure the run of river time series of the hydraulic generators. + +## Minimum Generation + +The "Min Gen." tab is dedicated to configuring the minimum generation levels of the hydraulic generators. This tab presents a time series that represents the minimum hourly production for one or more Monte-Carlo years. + +![05-hydro.min-generation.series.png](../../../assets/media/user-guide/study/areas/05-hydro.min-generation.series.png) \ No newline at end of file From e57610836d43712c59b4d807003e6228ead64bd4 Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Tue, 13 Feb 2024 18:35:59 +0100 Subject: [PATCH 023/248] fix(ui-hydro): remove dots from labels and add `studyVersion` missing dep --- .../Singlestudy/explore/Modelization/Areas/Hydro/index.tsx | 4 ++-- .../App/Singlestudy/explore/Modelization/Areas/Hydro/utils.ts | 2 +- 2 files changed, 3 insertions(+), 3 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 ffa2df3620..0baab19ab8 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 @@ -31,9 +31,9 @@ function Hydro() { { label: "Water values", path: `${basePath}/watervalues` }, { label: "Hydro Storage", path: `${basePath}/hydrostorage` }, { label: "Run of river", path: `${basePath}/ror` }, - studyVersion >= 860 && { label: "Min Gen.", path: `${basePath}/mingen` }, + studyVersion >= 860 && { label: "Min Gen", path: `${basePath}/mingen` }, ].filter(Boolean); - }, [areaId, study?.id]); + }, [areaId, study?.id, studyVersion]); //////////////////////////////////////////////////////////////// // JSX diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/utils.ts index 2a75d23f9d..ed8457afe4 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/utils.ts @@ -150,7 +150,7 @@ export const MATRICES: Matrices = { stats: MatrixStats.STATS, }, [HydroMatrixType.MinGen]: { - title: "Min Gen.", + title: "Min Gen", url: "input/hydro/series/{areaId}/mingen", stats: MatrixStats.STATS, }, From 23a813c8eee271d0c33f8d7f82d2994e5640a6a6 Mon Sep 17 00:00:00 2001 From: mabw-rte <41002227+mabw-rte@users.noreply.github.com> Date: Wed, 14 Feb 2024 17:57:16 +0100 Subject: [PATCH 024/248] feat(tags-db): populate `tag` and `study_tag` tables using pre-existing patch data (#1929) Context: Currently, tags do not have a specific table but are directly retrieved from Patches using Python code. Issue: This coding paradigm results in filtering that cannot occur at the database level but rather post-query (posing a problem for pagination). It can also potentially slightly slow down API queries. Solution in following steps: - Create two tables, `tag` and `study_tag`, to manage the many-to-many relationships between studies and tags. This step requires data migration. - Update endpoints and services - Create an update script to populate the newly created tables with pre-existing data. Note: This PR deals with the last step --- ...populate_tag_and_study_tag_tables_with_.py | 101 ++++++++++++++++++ scripts/rollback.sh | 2 +- 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/dae93f1d9110_populate_tag_and_study_tag_tables_with_.py diff --git a/alembic/versions/dae93f1d9110_populate_tag_and_study_tag_tables_with_.py b/alembic/versions/dae93f1d9110_populate_tag_and_study_tag_tables_with_.py new file mode 100644 index 0000000000..0cfc66e8b0 --- /dev/null +++ b/alembic/versions/dae93f1d9110_populate_tag_and_study_tag_tables_with_.py @@ -0,0 +1,101 @@ +""" +Populate `tag` and `study_tag` tables from `patch` field in `study_additional_data` table + +Revision ID: dae93f1d9110 +Revises: 3c70366b10ea +Create Date: 2024-02-08 10:30:20.590919 +""" +import collections +import itertools +import json +import secrets + +import sqlalchemy as sa # type: ignore +from alembic import op +from sqlalchemy.engine import Connection # type: ignore + +from antarest.study.css4_colors import COLOR_NAMES + +# revision identifiers, used by Alembic. +revision = "dae93f1d9110" +down_revision = "3c70366b10ea" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """ + Populate `tag` and `study_tag` tables from `patch` field in `study_additional_data` table + + Four steps to proceed: + - Retrieve study-tags pairs from patches in `study_additional_data`. + - Delete all rows in `tag` and `study_tag`, as tag updates between revised 3c70366b10ea and this version, + do modify the data in patches alongside the two previous tables. + - Populate `tag` table using unique tag-labels and by randomly generating their associated colors. + - Populate `study_tag` using study-tags pairs. + """ + + # create connexion to the db + connexion: Connection = op.get_bind() + + # retrieve the tags and the study-tag pairs from the db + study_tags = connexion.execute("SELECT study_id,patch FROM study_additional_data") + tags_by_ids = {} + for study_id, patch in study_tags: + obj = json.loads(patch or "{}") + study = obj.get("study") or {} + tags = frozenset(study.get("tags") or ()) + tags_by_ids[study_id] = tags + + # delete rows in tables `tag` and `study_tag` + connexion.execute("DELETE FROM study_tag") + connexion.execute("DELETE FROM tag") + + # insert the tags in the `tag` table + labels = set(itertools.chain.from_iterable(tags_by_ids.values())) + bulk_tags = [{"label": label, "color": secrets.choice(COLOR_NAMES)} for label in labels] + sql = sa.text("INSERT INTO tag (label, color) VALUES (:label, :color)") + connexion.execute(sql, *bulk_tags) + + # Create relationships between studies and tags in the `study_tag` table + bulk_study_tags = ({"study_id": id_, "tag_label": lbl} for id_, tags in tags_by_ids.items() for lbl in tags) + sql = sa.text("INSERT INTO study_tag (study_id, tag_label) VALUES (:study_id, :tag_label)") + connexion.execute(sql, *bulk_study_tags) + + +def downgrade() -> None: + """ + Restore `patch` field in `study_additional_data` from `tag` and `study_tag` tables + + Three steps to proceed: + - Retrieve study-tags pairs from `study_tag` table. + - Update patches study-tags in `study_additional_data` using these pairs. + - Delete all rows from `tag` and `study_tag`. + """ + # create a connection to the db + connexion: Connection = op.get_bind() + + # Creating the `tags_by_ids` mapping from data in the `study_tags` table + tags_by_ids = collections.defaultdict(set) + study_tags = connexion.execute("SELECT study_id, tag_label FROM study_tag") + for study_id, tag_label in study_tags: + tags_by_ids[study_id].add(tag_label) + + # Then, we read objects from the `patch` field of the `study_additional_data` table + objects_by_ids = {} + study_tags = connexion.execute("SELECT study_id, patch FROM study_additional_data") + for study_id, patch in study_tags: + obj = json.loads(patch or "{}") + obj["study"] = obj.get("study") or {} + obj["study"]["tags"] = obj["study"].get("tags") or [] + obj["study"]["tags"] = sorted(tags_by_ids[study_id] | set(obj["study"]["tags"])) + objects_by_ids[study_id] = obj + + # Updating objects in the `study_additional_data` table + sql = sa.text("UPDATE study_additional_data SET patch = :patch WHERE study_id = :study_id") + bulk_patches = [{"study_id": id_, "patch": json.dumps(obj)} for id_, obj in objects_by_ids.items()] + connexion.execute(sql, *bulk_patches) + + # Deleting study_tags and tags + connexion.execute("DELETE FROM study_tag") + connexion.execute("DELETE FROM tag") diff --git a/scripts/rollback.sh b/scripts/rollback.sh index bf92685dc4..46d04a7966 100755 --- a/scripts/rollback.sh +++ b/scripts/rollback.sh @@ -12,5 +12,5 @@ CUR_DIR=$(cd "$(dirname "$0")" && pwd) BASE_DIR=$(dirname "$CUR_DIR") cd "$BASE_DIR" -alembic downgrade 1f5db5dfad80 +alembic downgrade 3c70366b10ea cd - From 2cfa5529b28596584210cac682aa465c008e6d6f Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Wed, 14 Feb 2024 23:25:15 +0100 Subject: [PATCH 025/248] fix(tags-db): correct `tag` and `study_tag` migration script Avoid bulk insertion if the list of values to insert is empty. --- ...0_populate_tag_and_study_tag_tables_with_.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/alembic/versions/dae93f1d9110_populate_tag_and_study_tag_tables_with_.py b/alembic/versions/dae93f1d9110_populate_tag_and_study_tag_tables_with_.py index 0cfc66e8b0..c6c29d3716 100644 --- a/alembic/versions/dae93f1d9110_populate_tag_and_study_tag_tables_with_.py +++ b/alembic/versions/dae93f1d9110_populate_tag_and_study_tag_tables_with_.py @@ -54,13 +54,15 @@ def upgrade() -> None: # insert the tags in the `tag` table labels = set(itertools.chain.from_iterable(tags_by_ids.values())) bulk_tags = [{"label": label, "color": secrets.choice(COLOR_NAMES)} for label in labels] - sql = sa.text("INSERT INTO tag (label, color) VALUES (:label, :color)") - connexion.execute(sql, *bulk_tags) + if bulk_tags: + sql = sa.text("INSERT INTO tag (label, color) VALUES (:label, :color)") + connexion.execute(sql, *bulk_tags) # Create relationships between studies and tags in the `study_tag` table - bulk_study_tags = ({"study_id": id_, "tag_label": lbl} for id_, tags in tags_by_ids.items() for lbl in tags) - sql = sa.text("INSERT INTO study_tag (study_id, tag_label) VALUES (:study_id, :tag_label)") - connexion.execute(sql, *bulk_study_tags) + bulk_study_tags = [{"study_id": id_, "tag_label": lbl} for id_, tags in tags_by_ids.items() for lbl in tags] + if bulk_study_tags: + sql = sa.text("INSERT INTO study_tag (study_id, tag_label) VALUES (:study_id, :tag_label)") + connexion.execute(sql, *bulk_study_tags) def downgrade() -> None: @@ -92,9 +94,10 @@ def downgrade() -> None: objects_by_ids[study_id] = obj # Updating objects in the `study_additional_data` table - sql = sa.text("UPDATE study_additional_data SET patch = :patch WHERE study_id = :study_id") bulk_patches = [{"study_id": id_, "patch": json.dumps(obj)} for id_, obj in objects_by_ids.items()] - connexion.execute(sql, *bulk_patches) + if bulk_patches: + sql = sa.text("UPDATE study_additional_data SET patch = :patch WHERE study_id = :study_id") + connexion.execute(sql, *bulk_patches) # Deleting study_tags and tags connexion.execute("DELETE FROM study_tag") From 596c486cd285b58591826bade63990478e66b71d Mon Sep 17 00:00:00 2001 From: "olfa.mizen_externe@rte-france.com" Date: Mon, 8 Jan 2024 15:30:54 +0100 Subject: [PATCH 026/248] perf(watcher): change db queries to improve Watcher scanning perfs --- ...fd73601a9075_add_delete_cascade_studies.py | 69 +++++++++++++++++++ antarest/study/model.py | 4 +- antarest/study/repository.py | 15 +++- antarest/study/service.py | 25 +++---- .../storage/variantstudy/model/dbmodel.py | 2 +- tests/storage/test_service.py | 32 ++++++--- 6 files changed, 117 insertions(+), 30 deletions(-) create mode 100644 alembic/versions/fd73601a9075_add_delete_cascade_studies.py diff --git a/alembic/versions/fd73601a9075_add_delete_cascade_studies.py b/alembic/versions/fd73601a9075_add_delete_cascade_studies.py new file mode 100644 index 0000000000..ac063fe516 --- /dev/null +++ b/alembic/versions/fd73601a9075_add_delete_cascade_studies.py @@ -0,0 +1,69 @@ +""" +Add delete cascade constraint to study foreign keys + +Revision ID: fd73601a9075 +Revises: 3c70366b10ea +Create Date: 2024-02-12 17:27:37.314443 +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = "fd73601a9075" +down_revision = "3c70366b10ea" +branch_labels = None +depends_on = None + +# noinspection SpellCheckingInspection +RAWSTUDY_FK = "rawstudy_id_fkey" + +# noinspection SpellCheckingInspection +VARIANTSTUDY_FK = "variantstudy_id_fkey" + +# noinspection SpellCheckingInspection +STUDY_ADDITIONAL_DATA_FK = "study_additional_data_study_id_fkey" + + +def upgrade() -> None: + dialect_name: str = op.get_context().dialect.name + if dialect_name == "postgresql": + with op.batch_alter_table("rawstudy", schema=None) as batch_op: + batch_op.drop_constraint(RAWSTUDY_FK, type_="foreignkey") + batch_op.create_foreign_key(RAWSTUDY_FK, "study", ["id"], ["id"], ondelete="CASCADE") + + with op.batch_alter_table("study_additional_data", schema=None) as batch_op: + batch_op.drop_constraint(STUDY_ADDITIONAL_DATA_FK, type_="foreignkey") + batch_op.create_foreign_key(STUDY_ADDITIONAL_DATA_FK, "study", ["study_id"], ["id"], ondelete="CASCADE") + + with op.batch_alter_table("variantstudy", schema=None) as batch_op: + batch_op.drop_constraint(VARIANTSTUDY_FK, type_="foreignkey") + batch_op.create_foreign_key(VARIANTSTUDY_FK, "study", ["id"], ["id"], ondelete="CASCADE") + + elif dialect_name == "sqlite": + # Adding ondelete="CASCADE" to a foreign key in sqlite is not supported + pass + + else: + raise NotImplementedError(f"{dialect_name=} not implemented") + + +def downgrade() -> None: + dialect_name: str = op.get_context().dialect.name + if dialect_name == "postgresql": + with op.batch_alter_table("rawstudy", schema=None) as batch_op: + batch_op.drop_constraint(RAWSTUDY_FK, type_="foreignkey") + batch_op.create_foreign_key(RAWSTUDY_FK, "study", ["id"], ["id"]) + + with op.batch_alter_table("study_additional_data", schema=None) as batch_op: + batch_op.drop_constraint(STUDY_ADDITIONAL_DATA_FK, type_="foreignkey") + batch_op.create_foreign_key(STUDY_ADDITIONAL_DATA_FK, "study", ["study_id"], ["id"]) + + with op.batch_alter_table("variantstudy", schema=None) as batch_op: + batch_op.drop_constraint(VARIANTSTUDY_FK, type_="foreignkey") + batch_op.create_foreign_key(VARIANTSTUDY_FK, "study", ["id"], ["id"]) + + elif dialect_name == "sqlite": + # Removing ondelete="CASCADE" to a foreign key in sqlite is not supported + pass + + else: + raise NotImplementedError(f"{dialect_name=} not implemented") diff --git a/antarest/study/model.py b/antarest/study/model.py index fe10b4f211..df36efa856 100644 --- a/antarest/study/model.py +++ b/antarest/study/model.py @@ -130,7 +130,7 @@ class StudyAdditionalData(Base): # type:ignore study_id = Column( String(36), - ForeignKey("study.id"), + ForeignKey("study.id", ondelete="CASCADE"), primary_key=True, ) author = Column(String(255), default="Unknown") @@ -230,7 +230,7 @@ class RawStudy(Study): id = Column( String(36), - ForeignKey("study.id"), + ForeignKey("study.id", ondelete="CASCADE"), primary_key=True, ) content_status = Column(Enum(StudyContentStatus)) diff --git a/antarest/study/repository.py b/antarest/study/repository.py index 3aa6e60681..7728e7068b 100644 --- a/antarest/study/repository.py +++ b/antarest/study/repository.py @@ -272,10 +272,10 @@ def get_all_raw(self, exists: t.Optional[bool] = None) -> t.Sequence[RawStudy]: studies: t.Sequence[RawStudy] = query.all() return studies - def delete(self, id: str) -> None: + def delete(self, id_: str, *ids: str) -> None: + ids = (id_,) + ids session = self.session - u: Study = session.query(Study).get(id) - session.delete(u) + session.query(Study).filter(Study.id.in_(ids)).delete(synchronize_session=False) session.commit() def update_tags(self, study: Study, new_tags: t.Sequence[str]) -> None: @@ -292,3 +292,12 @@ def update_tags(self, study: Study, new_tags: t.Sequence[str]) -> None: study.tags = [Tag(label=tag) for tag in new_labels] + existing_tags self.session.merge(study) self.session.commit() + + def list_duplicates(self) -> t.List[t.Tuple[str, str]]: + """ + Get list of duplicates as tuples (id, path). + """ + session = self.session + subquery = session.query(Study.path).group_by(Study.path).having(func.count() > 1).subquery() + query = session.query(Study.id, Study.path).filter(Study.path.in_(subquery)) + return t.cast(t.List[t.Tuple[str, str]], query.all()) diff --git a/antarest/study/service.py b/antarest/study/service.py index 910cf62027..9b22ae7638 100644 --- a/antarest/study/service.py +++ b/antarest/study/service.py @@ -1,4 +1,5 @@ import base64 +import collections import contextlib import io import json @@ -701,20 +702,16 @@ def get_input_matrix_startdate( return get_start_date(file_study, output_id, level) def remove_duplicates(self) -> None: - study_paths: t.Dict[str, t.List[str]] = {} - for study in self.repository.get_all(): - if isinstance(study, RawStudy) and not study.archived: - path = str(study.path) - if path not in study_paths: - study_paths[path] = [] - study_paths[path].append(study.id) - - for studies_with_same_path in study_paths.values(): - if len(studies_with_same_path) > 1: - logger.info(f"Found studies {studies_with_same_path} with same path, de duplicating") - for study_name in studies_with_same_path[1:]: - logger.info(f"Removing study {study_name}") - self.repository.delete(study_name) + duplicates = self.repository.list_duplicates() + ids: t.List[str] = [] + # ids with same path + duplicates_by_path = collections.defaultdict(list) + for study_id, path in duplicates: + duplicates_by_path[path].append(study_id) + for path, study_ids in duplicates_by_path.items(): + ids.extend(study_ids[1:]) + if ids: # Check if ids is not empty + self.repository.delete(*ids) def sync_studies_on_disk(self, folders: t.List[StudyFolder], directory: t.Optional[Path] = None) -> None: """ diff --git a/antarest/study/storage/variantstudy/model/dbmodel.py b/antarest/study/storage/variantstudy/model/dbmodel.py index bbe264f89f..9272eb797f 100644 --- a/antarest/study/storage/variantstudy/model/dbmodel.py +++ b/antarest/study/storage/variantstudy/model/dbmodel.py @@ -77,7 +77,7 @@ class VariantStudy(Study): id: str = Column( String(36), - ForeignKey("study.id"), + ForeignKey("study.id", ondelete="CASCADE"), primary_key=True, ) generation_task: t.Optional[str] = Column(String(), nullable=True) diff --git a/tests/storage/test_service.py b/tests/storage/test_service.py index 12f61e6489..fa7ed5c62d 100644 --- a/tests/storage/test_service.py +++ b/tests/storage/test_service.py @@ -350,18 +350,30 @@ def test_partial_sync_studies_from_disk() -> None: ) -@pytest.mark.unit_test -def test_remove_duplicate() -> None: - ma = RawStudy(id="a", path="a") - mb = RawStudy(id="b", path="a") +@with_db_context +def test_remove_duplicate(db_session: Session) -> None: + with db_session: + db_session.add(RawStudy(id="a", path="/path/to/a")) + db_session.add(RawStudy(id="b", path="/path/to/a")) + db_session.add(RawStudy(id="c", path="/path/to/c")) + db_session.commit() + study_count = db_session.query(RawStudy).filter(RawStudy.path == "/path/to/a").count() + assert study_count == 2 # there are 2 studies with same path before removing duplicates - repository = Mock() - repository.get_all.return_value = [ma, mb] - config = Config(storage=StorageConfig(workspaces={DEFAULT_WORKSPACE_NAME: WorkspaceConfig()})) - service = build_study_service(Mock(), repository, config) + with db_session: + repository = StudyMetadataRepository(Mock(), db_session) + config = Config(storage=StorageConfig(workspaces={DEFAULT_WORKSPACE_NAME: WorkspaceConfig()})) + service = build_study_service(Mock(), repository, config) + service.remove_duplicates() - service.remove_duplicates() - repository.delete.assert_called_once_with(mb.id) + # example with 1 duplicate with same path + with db_session: + study_count = db_session.query(RawStudy).filter(RawStudy.path == "/path/to/a").count() + assert study_count == 1 + # example with no duplicates with same path + with db_session: + study_count = db_session.query(RawStudy).filter(RawStudy.path == "/path/to/c").count() + assert study_count == 1 # noinspection PyArgumentList From 1ce2bebd681cda5ad25d1a49e3d37933e427269e Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Wed, 14 Feb 2024 23:01:45 +0100 Subject: [PATCH 027/248] refactor(studies-db): change signature of `get` method in `StudyMetadataRepository` class Replace `id` parameter with `study_id`. --- antarest/study/repository.py | 6 ++-- tests/storage/repository/test_study.py | 42 ++++++++++++++------------ 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/antarest/study/repository.py b/antarest/study/repository.py index 7728e7068b..9d6c9317fb 100644 --- a/antarest/study/repository.py +++ b/antarest/study/repository.py @@ -138,7 +138,7 @@ def save( def refresh(self, metadata: Study) -> None: self.session.refresh(metadata) - def get(self, id: str) -> t.Optional[Study]: + def get(self, study_id: str) -> t.Optional[Study]: """Get the study by ID or return `None` if not found in database.""" # todo: I think we should use a `entity = with_polymorphic(Study, "*")` # to make sure RawStudy and VariantStudy fields are also fetched. @@ -146,13 +146,11 @@ def get(self, id: str) -> t.Optional[Study]: # When we fetch a study, we also need to fetch the associated owner and groups # to check the permissions of the current user efficiently. study: Study = ( - # fmt: off self.session.query(Study) .options(joinedload(Study.owner)) .options(joinedload(Study.groups)) .options(joinedload(Study.tags)) - .get(id) - # fmt: on + .get(study_id) ) return study diff --git a/tests/storage/repository/test_study.py b/tests/storage/repository/test_study.py index f865ab613a..bb4ea795e3 100644 --- a/tests/storage/repository/test_study.py +++ b/tests/storage/repository/test_study.py @@ -1,22 +1,24 @@ from datetime import datetime +from sqlalchemy.orm import Session # type: ignore + from antarest.core.cache.business.local_chache import LocalCache from antarest.core.model import PublicMode from antarest.login.model import Group, User from antarest.study.model import DEFAULT_WORKSPACE_NAME, RawStudy, Study, StudyContentStatus from antarest.study.repository import StudyMetadataRepository from antarest.study.storage.variantstudy.model.dbmodel import VariantStudy -from tests.helpers import with_db_context -@with_db_context -def test_lifecycle() -> None: - user = User(id=0, name="admin") +def test_lifecycle(db_session: Session) -> None: + repo = StudyMetadataRepository(LocalCache(), session=db_session) + + user = User(id=1, name="admin") group = Group(id="my-group", name="group") - repo = StudyMetadataRepository(LocalCache()) + a = Study( name="a", - version="42", + version="820", author="John Smith", created_at=datetime.utcnow(), updated_at=datetime.utcnow(), @@ -26,7 +28,7 @@ def test_lifecycle() -> None: ) b = RawStudy( name="b", - version="43", + version="830", author="Morpheus", created_at=datetime.utcnow(), updated_at=datetime.utcnow(), @@ -36,7 +38,7 @@ def test_lifecycle() -> None: ) c = RawStudy( name="c", - version="43", + version="830", author="Trinity", created_at=datetime.utcnow(), updated_at=datetime.utcnow(), @@ -47,7 +49,7 @@ def test_lifecycle() -> None: ) d = VariantStudy( name="d", - version="43", + version="830", author="Mr. Anderson", created_at=datetime.utcnow(), updated_at=datetime.utcnow(), @@ -57,30 +59,32 @@ def test_lifecycle() -> None: ) a = repo.save(a) - b = repo.save(b) + a_id = a.id + + repo.save(b) repo.save(c) repo.save(d) - assert b.id - c = repo.one(a.id) - assert a == c + + c = repo.one(a_id) + assert a_id == c.id assert len(repo.get_all()) == 4 assert len(repo.get_all_raw(exists=True)) == 1 assert len(repo.get_all_raw(exists=False)) == 1 assert len(repo.get_all_raw()) == 2 - repo.delete(a.id) - assert repo.get(a.id) is None + repo.delete(a_id) + assert repo.get(a_id) is None + +def test_study_inheritance(db_session: Session) -> None: + repo = StudyMetadataRepository(LocalCache(), session=db_session) -@with_db_context -def test_study_inheritance() -> None: user = User(id=0, name="admin") group = Group(id="my-group", name="group") - repo = StudyMetadataRepository(LocalCache()) a = RawStudy( name="a", - version="42", + version="820", author="John Smith", created_at=datetime.utcnow(), updated_at=datetime.utcnow(), From 403087e728b8ba7e3efafccab98a6e90b2d16f7e Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Wed, 14 Feb 2024 23:01:55 +0100 Subject: [PATCH 028/248] fix(studies-db): correct the many-to-many relationship between `Study` and `Group` --- antarest/study/model.py | 45 +++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/antarest/study/model.py b/antarest/study/model.py index df36efa856..5079198296 100644 --- a/antarest/study/model.py +++ b/antarest/study/model.py @@ -16,7 +16,6 @@ Integer, PrimaryKeyConstraint, String, - Table, ) from sqlalchemy.orm import relationship # type: ignore @@ -50,12 +49,31 @@ NEW_DEFAULT_STUDY_VERSION: str = "860" -groups_metadata = Table( - "group_metadata", - Base.metadata, - Column("group_id", String(36), ForeignKey("groups.id")), - Column("study_id", String(36), ForeignKey("study.id")), -) + +class StudyGroup(Base): # type:ignore + """ + A table to manage the many-to-many relationship between `Study` and `Group` + + Attributes: + study_id: The ID of the study associated with the group. + group_id: The IS of the group associated with the study. + """ + + __tablename__ = "group_metadata" + __table_args__ = (PrimaryKeyConstraint("study_id", "group_id"),) + + group_id: str = Column(String(36), ForeignKey("groups.id", ondelete="CASCADE"), index=True, nullable=False) + study_id: str = Column(String(36), ForeignKey("study.id", ondelete="CASCADE"), index=True, nullable=False) + + def __str__(self) -> str: # pragma: no cover + cls_name = self.__class__.__name__ + return f"[{cls_name}] study_id={self.study_id}, group={self.group_id}" + + def __repr__(self) -> str: # pragma: no cover + cls_name = self.__class__.__name__ + study_id = self.study_id + group_id = self.group_id + return f"{cls_name}({study_id=}, {group_id=})" class StudyTag(Base): # type:ignore @@ -63,8 +81,8 @@ class StudyTag(Base): # type:ignore A table to manage the many-to-many relationship between `Study` and `Tag` Attributes: - study_id (str): The ID of the study associated with the tag. - tag_label (str): The label of the tag associated with the study. + study_id: The ID of the study associated with the tag. + tag_label: The label of the tag associated with the study. """ __tablename__ = "study_tag" @@ -74,7 +92,8 @@ class StudyTag(Base): # type:ignore tag_label: str = Column(String(40), ForeignKey("tag.label", ondelete="CASCADE"), index=True, nullable=False) def __str__(self) -> str: # pragma: no cover - return f"[StudyTag] study_id={self.study_id}, tag={self.tag}" + cls_name = self.__class__.__name__ + return f"[{cls_name}] study_id={self.study_id}, tag={self.tag}" def __repr__(self) -> str: # pragma: no cover cls_name = self.__class__.__name__ @@ -90,8 +109,8 @@ class Tag(Base): # type:ignore This class is used to store tags associated with studies. Attributes: - label (str): The label of the tag. - color (str): The color code associated with the tag. + label: The label of the tag. + color: The color code associated with the tag. """ __tablename__ = "tag" @@ -174,7 +193,7 @@ class Study(Base): # type: ignore tags: t.List[Tag] = relationship(Tag, secondary=StudyTag.__table__, back_populates="studies") owner = relationship(Identity, uselist=False) - groups = relationship(Group, secondary=lambda: groups_metadata, cascade="") + groups = relationship(Group, secondary=StudyGroup.__table__, cascade="") additional_data = relationship( StudyAdditionalData, uselist=False, From 7c3c5cde824875b48c9c2cf019f826a3974ec09b Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Wed, 14 Feb 2024 23:21:03 +0100 Subject: [PATCH 029/248] fix(db): add a migration script to correct the many-to-many relationship between `Study` and `Group` --- ...fd73601a9075_add_delete_cascade_studies.py | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/alembic/versions/fd73601a9075_add_delete_cascade_studies.py b/alembic/versions/fd73601a9075_add_delete_cascade_studies.py index ac063fe516..3f9f42c684 100644 --- a/alembic/versions/fd73601a9075_add_delete_cascade_studies.py +++ b/alembic/versions/fd73601a9075_add_delete_cascade_studies.py @@ -5,11 +5,12 @@ Revises: 3c70366b10ea Create Date: 2024-02-12 17:27:37.314443 """ +import sqlalchemy as sa # type: ignore from alembic import op # revision identifiers, used by Alembic. revision = "fd73601a9075" -down_revision = "3c70366b10ea" +down_revision = "dae93f1d9110" branch_labels = None depends_on = None @@ -25,6 +26,8 @@ def upgrade() -> None: dialect_name: str = op.get_context().dialect.name + + # SQLite doesn't support dropping foreign keys, so we need to ignore it here if dialect_name == "postgresql": with op.batch_alter_table("rawstudy", schema=None) as batch_op: batch_op.drop_constraint(RAWSTUDY_FK, type_="foreignkey") @@ -38,16 +41,25 @@ def upgrade() -> None: batch_op.drop_constraint(VARIANTSTUDY_FK, type_="foreignkey") batch_op.create_foreign_key(VARIANTSTUDY_FK, "study", ["id"], ["id"], ondelete="CASCADE") - elif dialect_name == "sqlite": - # Adding ondelete="CASCADE" to a foreign key in sqlite is not supported - pass - - else: - raise NotImplementedError(f"{dialect_name=} not implemented") + with op.batch_alter_table("group_metadata", schema=None) as batch_op: + batch_op.alter_column("group_id", existing_type=sa.VARCHAR(length=36), nullable=False) + batch_op.alter_column("study_id", existing_type=sa.VARCHAR(length=36), nullable=False) + batch_op.create_index(batch_op.f("ix_group_metadata_group_id"), ["group_id"], unique=False) + batch_op.create_index(batch_op.f("ix_group_metadata_study_id"), ["study_id"], unique=False) + if dialect_name == "postgresql": + batch_op.drop_constraint("group_metadata_group_id_fkey", type_="foreignkey") + batch_op.drop_constraint("group_metadata_study_id_fkey", type_="foreignkey") + batch_op.create_foreign_key( + "group_metadata_group_id_fkey", "groups", ["group_id"], ["id"], ondelete="CASCADE" + ) + batch_op.create_foreign_key( + "group_metadata_study_id_fkey", "study", ["study_id"], ["id"], ondelete="CASCADE" + ) def downgrade() -> None: dialect_name: str = op.get_context().dialect.name + # SQLite doesn't support dropping foreign keys, so we need to ignore it here if dialect_name == "postgresql": with op.batch_alter_table("rawstudy", schema=None) as batch_op: batch_op.drop_constraint(RAWSTUDY_FK, type_="foreignkey") @@ -61,9 +73,14 @@ def downgrade() -> None: batch_op.drop_constraint(VARIANTSTUDY_FK, type_="foreignkey") batch_op.create_foreign_key(VARIANTSTUDY_FK, "study", ["id"], ["id"]) - elif dialect_name == "sqlite": - # Removing ondelete="CASCADE" to a foreign key in sqlite is not supported - pass - - else: - raise NotImplementedError(f"{dialect_name=} not implemented") + with op.batch_alter_table("group_metadata", schema=None) as batch_op: + # SQLite doesn't support dropping foreign keys, so we need to ignore it here + if dialect_name == "postgresql": + batch_op.drop_constraint("group_metadata_study_id_fkey", type_="foreignkey") + batch_op.drop_constraint("group_metadata_group_id_fkey", type_="foreignkey") + batch_op.create_foreign_key("group_metadata_study_id_fkey", "study", ["study_id"], ["id"]) + batch_op.create_foreign_key("group_metadata_group_id_fkey", "groups", ["group_id"], ["id"]) + batch_op.drop_index(batch_op.f("ix_group_metadata_study_id")) + batch_op.drop_index(batch_op.f("ix_group_metadata_group_id")) + batch_op.alter_column("study_id", existing_type=sa.VARCHAR(length=36), nullable=True) + batch_op.alter_column("group_id", existing_type=sa.VARCHAR(length=36), nullable=True) From d0370cd8a84b148f65bf8a1602cc5324ca67f1ac Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 16 Feb 2024 09:35:40 +0100 Subject: [PATCH 030/248] test: correct issue with test_synthesis.py --- tests/integration/studies_blueprint/test_synthesis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/studies_blueprint/test_synthesis.py b/tests/integration/studies_blueprint/test_synthesis.py index 70f5f0c907..9afd66be9b 100644 --- a/tests/integration/studies_blueprint/test_synthesis.py +++ b/tests/integration/studies_blueprint/test_synthesis.py @@ -108,4 +108,4 @@ def test_variant_study( ) assert res.status_code == 200, res.json() duration = time.time() - start - assert 0 <= duration <= 0.1, f"Duration is {duration} seconds" + assert 0 <= duration <= 0.2, f"Duration is {duration} seconds" From 2f1e6d91020d399dfec12e3cc57642b34a05b584 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Wed, 21 Feb 2024 16:40:59 +0100 Subject: [PATCH 031/248] test(study-db): add unit test to delete studies with additional data --- tests/storage/repository/test_study.py | 46 +++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/tests/storage/repository/test_study.py b/tests/storage/repository/test_study.py index bb4ea795e3..5535f4d074 100644 --- a/tests/storage/repository/test_study.py +++ b/tests/storage/repository/test_study.py @@ -1,3 +1,4 @@ +import json from datetime import datetime from sqlalchemy.orm import Session # type: ignore @@ -5,7 +6,7 @@ from antarest.core.cache.business.local_chache import LocalCache from antarest.core.model import PublicMode from antarest.login.model import Group, User -from antarest.study.model import DEFAULT_WORKSPACE_NAME, RawStudy, Study, StudyContentStatus +from antarest.study.model import DEFAULT_WORKSPACE_NAME, RawStudy, Study, StudyAdditionalData, StudyContentStatus from antarest.study.repository import StudyMetadataRepository from antarest.study.storage.variantstudy.model.dbmodel import VariantStudy @@ -77,6 +78,49 @@ def test_lifecycle(db_session: Session) -> None: assert repo.get(a_id) is None +def test_study__additional_data(db_session: Session) -> None: + repo = StudyMetadataRepository(LocalCache(), session=db_session) + + user = User(id=0, name="admin") + group = Group(id="my-group", name="group") + + patch = {"foo": "bar"} + a = RawStudy( + name="a", + version="820", + author="John Smith", + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + public_mode=PublicMode.FULL, + owner=user, + groups=[group], + workspace=DEFAULT_WORKSPACE_NAME, + path="study", + content_status=StudyContentStatus.WARNING, + additional_data=StudyAdditionalData(author="John Smith", horizon="2024-2050", patch=json.dumps(patch)), + ) + + repo.save(a) + a_id = a.id + + # Check that the additional data is correctly saved + additional_data = repo.get_additional_data(a_id) + assert additional_data.author == "John Smith" + assert additional_data.horizon == "2024-2050" + assert json.loads(additional_data.patch) == patch + + # Check that the additional data is correctly updated + new_patch = {"foo": "baz"} + a.additional_data.patch = json.dumps(new_patch) + repo.save(a) + additional_data = repo.get_additional_data(a_id) + assert json.loads(additional_data.patch) == new_patch + + # Check that the additional data is correctly deleted when the study is deleted + repo.delete(a_id) + assert repo.get_additional_data(a_id) is None + + def test_study_inheritance(db_session: Session) -> None: repo = StudyMetadataRepository(LocalCache(), session=db_session) From 8b0e5f3cb9a07d57beff3185e1ce3380d5a365ef Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Wed, 21 Feb 2024 17:39:17 +0100 Subject: [PATCH 032/248] fix(variant-db): add cascade delete constraints on foreign keys --- antarest/study/storage/variantstudy/model/dbmodel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/antarest/study/storage/variantstudy/model/dbmodel.py b/antarest/study/storage/variantstudy/model/dbmodel.py index 9272eb797f..f4a99d74e9 100644 --- a/antarest/study/storage/variantstudy/model/dbmodel.py +++ b/antarest/study/storage/variantstudy/model/dbmodel.py @@ -21,7 +21,7 @@ class VariantStudySnapshot(Base): # type: ignore id: str = Column( String(36), - ForeignKey("variantstudy.id"), + ForeignKey("variantstudy.id", ondelete="CASCADE"), primary_key=True, ) created_at: datetime.date = Column(DateTime) @@ -48,7 +48,7 @@ class CommandBlock(Base): # type: ignore default=lambda: str(uuid.uuid4()), unique=True, ) - study_id: str = Column(String(36), ForeignKey("variantstudy.id")) + study_id: str = Column(String(36), ForeignKey("variantstudy.id", ondelete="CASCADE")) index: int = Column(Integer) command: str = Column(String(255)) version: int = Column(Integer) From fd6a21f78bd84caa5309db04163e5c0eb317f264 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Wed, 21 Feb 2024 17:40:42 +0100 Subject: [PATCH 033/248] fix(variant-db): add a migration script to fix foreign key constraints --- ...4861_add_delete_cascade_variant_studies.py | 51 +++++++++++++++++++ scripts/rollback.sh | 2 +- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/c0c4aaf84861_add_delete_cascade_variant_studies.py diff --git a/alembic/versions/c0c4aaf84861_add_delete_cascade_variant_studies.py b/alembic/versions/c0c4aaf84861_add_delete_cascade_variant_studies.py new file mode 100644 index 0000000000..cc1100eeba --- /dev/null +++ b/alembic/versions/c0c4aaf84861_add_delete_cascade_variant_studies.py @@ -0,0 +1,51 @@ +""" +Add delete cascade constraint to variant study foreign keys + +Revision ID: c0c4aaf84861 +Revises: fd73601a9075 +Create Date: 2024-02-21 17:29:48.736664 +""" +from alembic import op # type: ignore + +# revision identifiers, used by Alembic. +revision = "c0c4aaf84861" +down_revision = "fd73601a9075" +branch_labels = None +depends_on = None + +COMMAND_BLOCK_FK = "commandblock_study_id_fkey" +SNAPSHOT_FK = "variant_study_snapshot_id_fkey" + + +def upgrade() -> None: + dialect_name: str = op.get_context().dialect.name + + # SQLite doesn't support dropping foreign keys, so we need to ignore it here + if dialect_name == "postgresql": + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("commandblock", schema=None) as batch_op: + batch_op.drop_constraint(COMMAND_BLOCK_FK, type_="foreignkey") + batch_op.create_foreign_key(COMMAND_BLOCK_FK, "variantstudy", ["study_id"], ["id"], ondelete="CASCADE") + + with op.batch_alter_table("variant_study_snapshot", schema=None) as batch_op: + batch_op.drop_constraint(SNAPSHOT_FK, type_="foreignkey") + batch_op.create_foreign_key(SNAPSHOT_FK, "variantstudy", ["id"], ["id"], ondelete="CASCADE") + + # ### end Alembic commands ### + + +def downgrade() -> None: + dialect_name: str = op.get_context().dialect.name + + # SQLite doesn't support dropping foreign keys, so we need to ignore it here + if dialect_name == "postgresql": + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("variant_study_snapshot", schema=None) as batch_op: + batch_op.drop_constraint(SNAPSHOT_FK, type_="foreignkey") + batch_op.create_foreign_key(SNAPSHOT_FK, "variantstudy", ["id"], ["id"]) + + with op.batch_alter_table("commandblock", schema=None) as batch_op: + batch_op.drop_constraint(COMMAND_BLOCK_FK, type_="foreignkey") + batch_op.create_foreign_key(COMMAND_BLOCK_FK, "variantstudy", ["study_id"], ["id"]) + + # ### end Alembic commands ### diff --git a/scripts/rollback.sh b/scripts/rollback.sh index 46d04a7966..974ca59422 100755 --- a/scripts/rollback.sh +++ b/scripts/rollback.sh @@ -12,5 +12,5 @@ CUR_DIR=$(cd "$(dirname "$0")" && pwd) BASE_DIR=$(dirname "$CUR_DIR") cd "$BASE_DIR" -alembic downgrade 3c70366b10ea +alembic downgrade fd73601a9075 cd - From 07e2d901a5adbc93c15d43c357187ddbdcae67f9 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 16 Feb 2024 17:37:53 +0100 Subject: [PATCH 034/248] feat(tags): handle case-insensitive tags - Change the search and update methods in `StudyMetadataRepository` class to handle case-insensitive tags. - Update the Alembic migration script to handle case-insensitive tags. --- ...populate_tag_and_study_tag_tables_with_.py | 47 +++++++++++++------ antarest/study/repository.py | 14 ++++-- .../studies_blueprint/test_get_studies.py | 15 +++--- tests/study/test_repository.py | 3 +- 4 files changed, 52 insertions(+), 27 deletions(-) diff --git a/alembic/versions/dae93f1d9110_populate_tag_and_study_tag_tables_with_.py b/alembic/versions/dae93f1d9110_populate_tag_and_study_tag_tables_with_.py index c6c29d3716..6fbb060115 100644 --- a/alembic/versions/dae93f1d9110_populate_tag_and_study_tag_tables_with_.py +++ b/alembic/versions/dae93f1d9110_populate_tag_and_study_tag_tables_with_.py @@ -9,6 +9,7 @@ import itertools import json import secrets +import typing as t import sqlalchemy as sa # type: ignore from alembic import op @@ -23,6 +24,22 @@ depends_on = None +def _avoid_duplicates(tags: t.Iterable[str]) -> t.Sequence[str]: + """Avoid duplicate tags (case insensitive)""" + + upper_tags = {tag.upper(): tag for tag in tags} + return list(upper_tags.values()) + + +def _load_patch_obj(patch: t.Optional[str]) -> t.MutableMapping[str, t.Any]: + """Load the patch object from the `patch` field in the `study_additional_data` table.""" + + obj: t.MutableMapping[str, t.Any] = json.loads(patch or "{}") + obj["study"] = obj.get("study") or {} + obj["study"]["tags"] = _avoid_duplicates(obj["study"].get("tags") or []) + return obj + + def upgrade() -> None: """ Populate `tag` and `study_tag` tables from `patch` field in `study_additional_data` table @@ -39,27 +56,31 @@ def upgrade() -> None: connexion: Connection = op.get_bind() # retrieve the tags and the study-tag pairs from the db - study_tags = connexion.execute("SELECT study_id,patch FROM study_additional_data") - tags_by_ids = {} + study_tags = connexion.execute("SELECT study_id, patch FROM study_additional_data") + tags_by_ids: t.MutableMapping[str, t.Set[str]] = {} for study_id, patch in study_tags: - obj = json.loads(patch or "{}") - study = obj.get("study") or {} - tags = frozenset(study.get("tags") or ()) - tags_by_ids[study_id] = tags + obj = _load_patch_obj(patch) + tags_by_ids[study_id] = obj["study"]["tags"] # delete rows in tables `tag` and `study_tag` connexion.execute("DELETE FROM study_tag") connexion.execute("DELETE FROM tag") # insert the tags in the `tag` table - labels = set(itertools.chain.from_iterable(tags_by_ids.values())) - bulk_tags = [{"label": label, "color": secrets.choice(COLOR_NAMES)} for label in labels] + all_labels = {lbl.upper(): lbl for lbl in itertools.chain.from_iterable(tags_by_ids.values())} + bulk_tags = [{"label": label, "color": secrets.choice(COLOR_NAMES)} for label in all_labels.values()] if bulk_tags: sql = sa.text("INSERT INTO tag (label, color) VALUES (:label, :color)") connexion.execute(sql, *bulk_tags) # Create relationships between studies and tags in the `study_tag` table - bulk_study_tags = [{"study_id": id_, "tag_label": lbl} for id_, tags in tags_by_ids.items() for lbl in tags] + bulk_study_tags = [ + # fmt: off + {"study_id": id_, "tag_label": all_labels[lbl.upper()]} + for id_, tags in tags_by_ids.items() + for lbl in tags + # fmt: on + ] if bulk_study_tags: sql = sa.text("INSERT INTO study_tag (study_id, tag_label) VALUES (:study_id, :tag_label)") connexion.execute(sql, *bulk_study_tags) @@ -78,7 +99,7 @@ def downgrade() -> None: connexion: Connection = op.get_bind() # Creating the `tags_by_ids` mapping from data in the `study_tags` table - tags_by_ids = collections.defaultdict(set) + tags_by_ids: t.MutableMapping[str, t.Set[str]] = collections.defaultdict(set) study_tags = connexion.execute("SELECT study_id, tag_label FROM study_tag") for study_id, tag_label in study_tags: tags_by_ids[study_id].add(tag_label) @@ -87,10 +108,8 @@ def downgrade() -> None: objects_by_ids = {} study_tags = connexion.execute("SELECT study_id, patch FROM study_additional_data") for study_id, patch in study_tags: - obj = json.loads(patch or "{}") - obj["study"] = obj.get("study") or {} - obj["study"]["tags"] = obj["study"].get("tags") or [] - obj["study"]["tags"] = sorted(tags_by_ids[study_id] | set(obj["study"]["tags"])) + obj = _load_patch_obj(patch) + obj["study"]["tags"] = _avoid_duplicates(tags_by_ids[study_id] | set(obj["study"]["tags"])) objects_by_ids[study_id] = obj # Updating objects in the `study_additional_data` table diff --git a/antarest/study/repository.py b/antarest/study/repository.py index 9d6c9317fb..1a22c64e60 100644 --- a/antarest/study/repository.py +++ b/antarest/study/repository.py @@ -222,7 +222,8 @@ def get_all( if study_filter.groups: q = q.join(entity.groups).filter(Group.id.in_(study_filter.groups)) if study_filter.tags: - q = q.join(entity.tags).filter(Tag.label.in_(study_filter.tags)) + upper_tags = [tag.upper() for tag in study_filter.tags] + q = q.join(entity.tags).filter(func.upper(Tag.label).in_(upper_tags)) if study_filter.archived is not None: q = q.filter(entity.archived == study_filter.archived) if study_filter.name: @@ -279,15 +280,18 @@ def delete(self, id_: str, *ids: str) -> None: def update_tags(self, study: Study, new_tags: t.Sequence[str]) -> None: """ Updates the tags associated with a given study in the database, - replacing existing tags with new ones. + replacing existing tags with new ones (case-insensitive). Args: study: The pre-existing study to be updated with the new tags. new_tags: The new tags to be associated with the input study in the database. """ - existing_tags = self.session.query(Tag).filter(Tag.label.in_(new_tags)).all() - new_labels = set(new_tags) - set([tag.label for tag in existing_tags]) - study.tags = [Tag(label=tag) for tag in new_labels] + existing_tags + new_upper_tags = {tag.upper(): tag for tag in new_tags} + existing_tags = self.session.query(Tag).filter(func.upper(Tag.label).in_(new_upper_tags)).all() + for tag in existing_tags: + if tag.label.upper() in new_upper_tags: + new_upper_tags.pop(tag.label.upper()) + study.tags = [Tag(label=tag) for tag in new_upper_tags.values()] + existing_tags self.session.merge(study) self.session.commit() diff --git a/tests/integration/studies_blueprint/test_get_studies.py b/tests/integration/studies_blueprint/test_get_studies.py index 54292345f5..579ad2dfe7 100644 --- a/tests/integration/studies_blueprint/test_get_studies.py +++ b/tests/integration/studies_blueprint/test_get_studies.py @@ -319,7 +319,7 @@ def test_study_listing( task = wait_task_completion(client, admin_access_token, archiving_study_task_id) assert task.status == TaskStatus.COMPLETED, task - # create a raw study version 840 to be tagged with `winter_transition` + # create a raw study version 840 to be tagged with `Winter_Transition` res = client.post( STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, @@ -330,7 +330,7 @@ def test_study_listing( res = client.put( f"{STUDIES_URL}/{tagged_raw_840_id}", headers={"Authorization": f"Bearer {admin_access_token}"}, - json={"tags": ["winter_transition"]}, + json={"tags": ["Winter_Transition"]}, ) assert res.status_code in CREATE_STATUS_CODES, res.json() res = client.get( @@ -341,7 +341,7 @@ def test_study_listing( assert res.status_code == LIST_STATUS_CODE, res.json() study_map: t.Dict[str, t.Dict[str, t.Any]] = res.json() assert len(study_map) == 1 - assert set(study_map[tagged_raw_840_id]["tags"]) == {"winter_transition"} + assert set(study_map[tagged_raw_840_id]["tags"]) == {"Winter_Transition"} # create a raw study version 850 to be tagged with `decennial` res = client.post( @@ -391,7 +391,8 @@ def test_study_listing( assert len(study_map) == 1 assert set(study_map[tagged_variant_840_id]["tags"]) == {"decennial"} - # create a variant study version 850 to be tagged with `winter_transition` + # create a variant study version 850 to be tagged with `winter_transition`. + # also test that the tag label is case-insensitive. res = client.post( f"{STUDIES_URL}/{tagged_raw_850_id}/variants", headers={"Authorization": f"Bearer {admin_access_token}"}, @@ -402,7 +403,7 @@ def test_study_listing( res = client.put( f"{STUDIES_URL}/{tagged_variant_850_id}", headers={"Authorization": f"Bearer {admin_access_token}"}, - json={"tags": ["winter_transition"]}, + json={"tags": ["winter_transition"]}, # note the tag label is in lower case ) assert res.status_code in CREATE_STATUS_CODES, res.json() res = client.get( @@ -413,7 +414,7 @@ def test_study_listing( assert res.status_code == LIST_STATUS_CODE, res.json() study_map = res.json() assert len(study_map) == 1 - assert set(study_map[tagged_variant_850_id]["tags"]) == {"winter_transition"} + assert set(study_map[tagged_variant_850_id]["tags"]) == {"Winter_Transition"} # ========================== # 2. Filtering testing @@ -670,7 +671,7 @@ def test_study_listing( res = client.get( STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}, - params={"tags": "decennial"}, + params={"tags": "DECENNIAL"}, ) assert res.status_code == LIST_STATUS_CODE, res.json() study_map = res.json() diff --git a/tests/study/test_repository.py b/tests/study/test_repository.py index d30c051a6a..a4c548b171 100644 --- a/tests/study/test_repository.py +++ b/tests/study/test_repository.py @@ -631,7 +631,7 @@ def test_repository_get_all__study_tags_filter( test_tag_1 = Tag(label="hidden-tag") test_tag_2 = Tag(label="decennial") - test_tag_3 = Tag(label="winter_transition") + test_tag_3 = Tag(label="Winter_Transition") # note the different case study_1 = VariantStudy(id=1, tags=[test_tag_1]) study_2 = VariantStudy(id=2, tags=[test_tag_2]) @@ -655,6 +655,7 @@ def test_repository_get_all__study_tags_filter( _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] _ = [s.tags for s in all_studies] + assert len(db_recorder.sql_statements) == 1, str(db_recorder) if expected_ids is not None: From a87af8ffcc2dc8f96711e8b736a926d947ff6fa6 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 16 Feb 2024 18:23:27 +0100 Subject: [PATCH 035/248] feat(tags): remove orphan tags on update --- antarest/study/repository.py | 4 ++++ tests/study/test_repository.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/antarest/study/repository.py b/antarest/study/repository.py index 1a22c64e60..b4e2059b73 100644 --- a/antarest/study/repository.py +++ b/antarest/study/repository.py @@ -294,6 +294,10 @@ def update_tags(self, study: Study, new_tags: t.Sequence[str]) -> None: study.tags = [Tag(label=tag) for tag in new_upper_tags.values()] + existing_tags self.session.merge(study) self.session.commit() + # Delete any tag that is not associated with any study. + # Note: If tags are to be associated with objects other than Study, this code must be updated. + self.session.query(Tag).filter(~Tag.studies.any()).delete(synchronize_session=False) # type: ignore + self.session.commit() def list_duplicates(self) -> t.List[t.Tuple[str, str]]: """ diff --git a/tests/study/test_repository.py b/tests/study/test_repository.py index a4c548b171..0a6063fac5 100644 --- a/tests/study/test_repository.py +++ b/tests/study/test_repository.py @@ -660,3 +660,36 @@ def test_repository_get_all__study_tags_filter( if expected_ids is not None: assert {s.id for s in all_studies} == expected_ids + + +def test_update_tags( + db_session: Session, +) -> None: + icache: Mock = Mock(spec=ICache) + repository = StudyMetadataRepository(cache_service=icache, session=db_session) + + study_id = 1 + study = RawStudy(id=study_id, tags=[]) + db_session.add(study) + db_session.commit() + + # use the db recorder to check that: + # 1- finding existing tags requires 1 query + # 2- updating the study tags requires 4 queries (2 selects, 2 inserts) + # 3- deleting orphan tags requires 1 query + with DBStatementRecorder(db_session.bind) as db_recorder: + repository.update_tags(study, ["Tag1", "Tag2"]) + assert len(db_recorder.sql_statements) == 6, str(db_recorder) + + # Check that when we change the tags to ["TAG1", "Tag3"], + # "Tag1" is preserved, "Tag2" is deleted and "Tag3" is created + # 1- finding existing tags requires 1 query + # 2- updating the study tags requires 4 queries (2 selects, 2 inserts, 1 delete) + # 3- deleting orphan tags requires 1 query + with DBStatementRecorder(db_session.bind) as db_recorder: + repository.update_tags(study, ["TAG1", "Tag3"]) + assert len(db_recorder.sql_statements) == 7, str(db_recorder) + + # Check that only "Tag1" and "Tag3" are present in the database + tags = db_session.query(Tag).all() + assert {tag.label for tag in tags} == {"Tag1", "Tag3"} From bdc29cd679c316012a9ef8bcda483082eb09b6f3 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 16 Feb 2024 18:24:07 +0100 Subject: [PATCH 036/248] feat(ui-tags): handle case-insensitive tags in filtering --- webapp/src/utils/studiesUtils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/webapp/src/utils/studiesUtils.ts b/webapp/src/utils/studiesUtils.ts index 1fa14e7698..4072e09c51 100644 --- a/webapp/src/utils/studiesUtils.ts +++ b/webapp/src/utils/studiesUtils.ts @@ -64,7 +64,9 @@ const tagsPredicate = R.curry( if (!study.tags || study.tags.length === 0) { return false; } - return R.intersection(study.tags, tags).length > 0; + const upperCaseTags = tags.map((tag) => tag.toUpperCase()); + const upperCaseStudyTags = study.tags.map((tag) => tag.toUpperCase()); + return R.intersection(upperCaseStudyTags, upperCaseTags).length > 0; }, ); From ab7502f2d29dac2afd2451f09a15dad005814c2b Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Mon, 19 Feb 2024 20:46:39 +0100 Subject: [PATCH 037/248] feat(api-tags): normalize whitespaces around tags and check string length --- antarest/study/model.py | 15 ++- .../studies_blueprint/test_update_tags.py | 95 +++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 tests/integration/studies_blueprint/test_update_tags.py diff --git a/antarest/study/model.py b/antarest/study/model.py index 5079198296..a102f327ac 100644 --- a/antarest/study/model.py +++ b/antarest/study/model.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta from pathlib import Path -from pydantic import BaseModel +from pydantic import BaseModel, validator from sqlalchemy import ( # type: ignore Boolean, Column, @@ -351,7 +351,18 @@ class StudyMetadataPatchDTO(BaseModel): scenario: t.Optional[str] = None status: t.Optional[str] = None doc: t.Optional[str] = None - tags: t.List[str] = [] + tags: t.Sequence[str] = () + + @validator("tags", each_item=True) + def _normalize_tags(cls, v: str) -> str: + """Remove leading and trailing whitespaces, and replace consecutive whitespaces by a single one.""" + tag = " ".join(v.split()) + if not tag: + raise ValueError("Tag cannot be empty") + elif len(tag) > 40: + raise ValueError(f"Tag is too long: {tag!r}") + else: + return tag class StudySimSettingsDTO(BaseModel): diff --git a/tests/integration/studies_blueprint/test_update_tags.py b/tests/integration/studies_blueprint/test_update_tags.py new file mode 100644 index 0000000000..a65ece2f11 --- /dev/null +++ b/tests/integration/studies_blueprint/test_update_tags.py @@ -0,0 +1,95 @@ +from starlette.testclient import TestClient + + +class TestupdateStudyMetadata: + """ + Test the study tags update through the `update_study_metadata` API endpoint. + """ + + def test_update_tags( + self, + client: TestClient, + user_access_token: str, + study_id: str, + ) -> None: + """ + This test verifies that we can update the tags of a study. + It also tests the tags normalization. + """ + + # Classic usage: set some tags to a study + study_tags = ["Tag1", "Tag2"] + res = client.put( + f"/v1/studies/{study_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={"tags": study_tags}, + ) + assert res.status_code == 200, res.json() + actual = res.json() + assert set(actual["tags"]) == set(study_tags) + + # Update the tags with already existing tags (case-insensitive): + # - "Tag1" is preserved, but with the same case as the existing one. + # - "Tag2" is replaced by "Tag3". + study_tags = ["tag1", "Tag3"] + res = client.put( + f"/v1/studies/{study_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={"tags": study_tags}, + ) + assert res.status_code == 200, res.json() + actual = res.json() + assert set(actual["tags"]) != set(study_tags) # not the same case + assert set(tag.upper() for tag in actual["tags"]) == {"TAG1", "TAG3"} + + # String normalization: whitespaces are stripped and + # consecutive whitespaces are replaced by a single one. + study_tags = [" \xa0Foo \t Bar \n ", " \t Baz\xa0\xa0"] + res = client.put( + f"/v1/studies/{study_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={"tags": study_tags}, + ) + assert res.status_code == 200, res.json() + actual = res.json() + assert set(actual["tags"]) == {"Foo Bar", "Baz"} + + # We can have symbols in the tags + study_tags = ["Foo-Bar", ":Baz%"] + res = client.put( + f"/v1/studies/{study_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={"tags": study_tags}, + ) + assert res.status_code == 200, res.json() + actual = res.json() + assert set(actual["tags"]) == {"Foo-Bar", ":Baz%"} + + def test_update_tags__invalid_tags( + self, + client: TestClient, + user_access_token: str, + study_id: str, + ) -> None: + # We cannot have empty tags + study_tags = [""] + res = client.put( + f"/v1/studies/{study_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={"tags": study_tags}, + ) + assert res.status_code == 422, res.json() + description = res.json()["description"] + assert "Tag cannot be empty" in description + + # We cannot have tags longer than 40 characters + study_tags = ["very long tags, very long tags, very long tags"] + assert len(study_tags[0]) > 40 + res = client.put( + f"/v1/studies/{study_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={"tags": study_tags}, + ) + assert res.status_code == 422, res.json() + description = res.json()["description"] + assert "Tag is too long" in description From dc252a11360a16d3bd3c1a9470f1b589c79d329c Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Thu, 22 Feb 2024 18:03:37 +0100 Subject: [PATCH 038/248] feat(study-search): improve access to session in `update_tags` --- antarest/study/repository.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/antarest/study/repository.py b/antarest/study/repository.py index b4e2059b73..25cdd77dd7 100644 --- a/antarest/study/repository.py +++ b/antarest/study/repository.py @@ -287,17 +287,18 @@ def update_tags(self, study: Study, new_tags: t.Sequence[str]) -> None: new_tags: The new tags to be associated with the input study in the database. """ new_upper_tags = {tag.upper(): tag for tag in new_tags} - existing_tags = self.session.query(Tag).filter(func.upper(Tag.label).in_(new_upper_tags)).all() + session = self.session + existing_tags = session.query(Tag).filter(func.upper(Tag.label).in_(new_upper_tags)).all() for tag in existing_tags: if tag.label.upper() in new_upper_tags: new_upper_tags.pop(tag.label.upper()) study.tags = [Tag(label=tag) for tag in new_upper_tags.values()] + existing_tags - self.session.merge(study) - self.session.commit() + session.merge(study) + session.commit() # Delete any tag that is not associated with any study. # Note: If tags are to be associated with objects other than Study, this code must be updated. - self.session.query(Tag).filter(~Tag.studies.any()).delete(synchronize_session=False) # type: ignore - self.session.commit() + session.query(Tag).filter(~Tag.studies.any()).delete(synchronize_session=False) # type: ignore + session.commit() def list_duplicates(self) -> t.List[t.Tuple[str, str]]: """ From e8a0d6ffc59aa3ff40664080d99362e188cf48eb Mon Sep 17 00:00:00 2001 From: mabw-rte <41002227+mabw-rte@users.noreply.github.com> Date: Fri, 23 Feb 2024 11:35:49 +0100 Subject: [PATCH 039/248] feat(permission-db): check user permission through the db query (#1931) Context: Additionally to ANT-1107 related tags db management, another problem of permission filtering is blocking the pagination from working properly. Issue: The permission to read studies in the search engine function is performed after the db query. This is creating a problem for our pagination process. Solution: Pass on the permission status directly in queries to the db. --- antarest/launcher/service.py | 8 +- antarest/study/repository.py | 142 ++++- antarest/study/service.py | 39 +- .../study/storage/auto_archive_service.py | 7 +- antarest/study/storage/rawstudy/watcher.py | 1 + antarest/study/web/studies_blueprint.py | 9 +- .../studies_blueprint/test_get_studies.py | 599 ++++++++++++++++++ tests/storage/repository/test_study.py | 4 +- tests/storage/test_service.py | 23 +- tests/study/test_repository.py | 373 ++++++++++- 10 files changed, 1111 insertions(+), 94 deletions(-) diff --git a/antarest/launcher/service.py b/antarest/launcher/service.py index 4c4ea9aa15..3165df48c7 100644 --- a/antarest/launcher/service.py +++ b/antarest/launcher/service.py @@ -40,7 +40,7 @@ from antarest.launcher.repository import JobResultRepository from antarest.launcher.ssh_client import calculates_slurm_load from antarest.launcher.ssh_config import SSHConfigDTO -from antarest.study.repository import StudyFilter +from antarest.study.repository import AccessPermissions, StudyFilter from antarest.study.service import StudyService from antarest.study.storage.utils import assert_permission, extract_output_name, find_single_output_path @@ -312,7 +312,11 @@ def _filter_from_user_permission(self, job_results: List[JobResult], user: Optio if study_ids: studies = { study.id: study - for study in self.study_service.repository.get_all(study_filter=StudyFilter(study_ids=study_ids)) + for study in self.study_service.repository.get_all( + study_filter=StudyFilter( + study_ids=study_ids, access_permissions=AccessPermissions.from_params(user) + ) + ) } else: studies = {} diff --git a/antarest/study/repository.py b/antarest/study/repository.py index 25cdd77dd7..6e89d33d5a 100644 --- a/antarest/study/repository.py +++ b/antarest/study/repository.py @@ -3,10 +3,13 @@ import typing as t from pydantic import BaseModel, NonNegativeInt -from sqlalchemy import func, not_, or_ # type: ignore -from sqlalchemy.orm import Session, joinedload, with_polymorphic # type: ignore +from sqlalchemy import and_, func, not_, or_, sql # type: ignore +from sqlalchemy.orm import Query, Session, joinedload, with_polymorphic # type: ignore from antarest.core.interfaces.cache import ICache +from antarest.core.jwt import JWTUser +from antarest.core.model import PublicMode +from antarest.core.requests import RequestParameters from antarest.core.utils.fastapi_sqlalchemy import db from antarest.login.model import Group from antarest.study.model import DEFAULT_WORKSPACE_NAME, RawStudy, Study, StudyAdditionalData, Tag @@ -34,6 +37,43 @@ def escape_like(string: str, escape_char: str = "\\") -> str: return string.replace(escape_char, escape_char * 2).replace("%", escape_char + "%").replace("_", escape_char + "_") +class AccessPermissions(BaseModel, frozen=True, extra="forbid"): + """ + This class object is build to pass on the user identity and its associated groups information + into the listing function get_all below + """ + + is_admin: bool = False + user_id: t.Optional[int] = None + user_groups: t.Sequence[str] = () + + @classmethod + def from_params(cls, params: t.Union[RequestParameters, JWTUser]) -> "AccessPermissions": + """ + This function makes it easier to pass on user ids and groups into the repository filtering function by + extracting the associated `AccessPermissions` object. + + Args: + params: `RequestParameters` or `JWTUser` holding user ids and groups + + Returns: `AccessPermissions` + + """ + if isinstance(params, RequestParameters): + user = params.user + else: + user = params + + if user: + return cls( + is_admin=user.is_site_admin() or user.is_admin_token(), + user_id=user.id, + user_groups=[group.id for group in user.groups], + ) + else: + return cls() + + class StudyFilter(BaseModel, frozen=True, extra="forbid"): """Study filter class gathering the main filtering parameters @@ -50,6 +90,7 @@ class StudyFilter(BaseModel, frozen=True, extra="forbid"): exists: if raw study missing workspace: optional workspace of the study folder: optional folder prefix of the study + access_permissions: query user ID, groups and admins status """ name: str = "" @@ -64,6 +105,7 @@ class StudyFilter(BaseModel, frozen=True, extra="forbid"): exists: t.Optional[bool] = None workspace: str = "" folder: str = "" + access_permissions: AccessPermissions = AccessPermissions() class StudySortBy(str, enum.Enum): @@ -198,6 +240,63 @@ def get_all( # efficiently (see: `AbstractStorageService.get_study_information`) entity = with_polymorphic(Study, "*") + q = self._search_studies(study_filter) + + # sorting + if sort_by: + if sort_by == StudySortBy.DATE_DESC: + q = q.order_by(entity.created_at.desc()) + elif sort_by == StudySortBy.DATE_ASC: + q = q.order_by(entity.created_at.asc()) + elif sort_by == StudySortBy.NAME_DESC: + q = q.order_by(func.upper(entity.name).desc()) + elif sort_by == StudySortBy.NAME_ASC: + q = q.order_by(func.upper(entity.name).asc()) + else: + raise NotImplementedError(sort_by) + + # pagination + if pagination.page_nb or pagination.page_size: + q = q.offset(pagination.page_nb * pagination.page_size).limit(pagination.page_size) + + studies: t.Sequence[Study] = q.all() + return studies + + def count_studies(self, study_filter: StudyFilter = StudyFilter()) -> int: + """ + Count all studies matching with specified filters. + + Args: + study_filter: composed of all filtering criteria. + + Returns: + Integer, corresponding to total number of studies matching with specified filters. + """ + q = self._search_studies(study_filter) + + total: int = q.count() + + return total + + def _search_studies( + self, + study_filter: StudyFilter, + ) -> Query: + """ + Build a `SQL Query` based on specified filters. + + Args: + study_filter: composed of all filtering criteria. + + Returns: + The `Query` corresponding to specified criteria (except for permissions). + """ + # When we fetch a study, we also need to fetch the associated owner and groups + # to check the permissions of the current user efficiently. + # We also need to fetch the additional data to display the study information + # efficiently (see: `AbstractStorageService.get_study_information`) + entity = with_polymorphic(Study, "*") + # noinspection PyTypeChecker q = self.session.query(entity) if study_filter.exists is not None: @@ -216,11 +315,9 @@ def get_all( q = q.filter(entity.type == "rawstudy") q = q.filter(RawStudy.workspace != DEFAULT_WORKSPACE_NAME) if study_filter.study_ids: - q = q.filter(entity.id.in_(study_filter.study_ids)) + q = q.filter(entity.id.in_(study_filter.study_ids)) if study_filter.study_ids else q if study_filter.users: q = q.filter(entity.owner_id.in_(study_filter.users)) - if study_filter.groups: - q = q.join(entity.groups).filter(Group.id.in_(study_filter.groups)) if study_filter.tags: upper_tags = [tag.upper() for tag in study_filter.tags] q = q.join(entity.tags).filter(func.upper(Tag.label).in_(upper_tags)) @@ -242,24 +339,27 @@ def get_all( if study_filter.versions: q = q.filter(entity.version.in_(study_filter.versions)) - if sort_by: - if sort_by == StudySortBy.DATE_DESC: - q = q.order_by(entity.created_at.desc()) - elif sort_by == StudySortBy.DATE_ASC: - q = q.order_by(entity.created_at.asc()) - elif sort_by == StudySortBy.NAME_DESC: - q = q.order_by(func.upper(entity.name).desc()) - elif sort_by == StudySortBy.NAME_ASC: - q = q.order_by(func.upper(entity.name).asc()) + # permissions + groups filtering + if not study_filter.access_permissions.is_admin and study_filter.access_permissions.user_id is not None: + condition_1 = entity.public_mode != PublicMode.NONE + condition_2 = entity.owner_id == study_filter.access_permissions.user_id + q1 = q.join(entity.groups).filter(Group.id.in_(study_filter.access_permissions.user_groups)) + if study_filter.groups: + q2 = q.join(entity.groups).filter(Group.id.in_(study_filter.groups)) + q2 = q1.intersect(q2) + q = q2.union( + q.join(entity.groups).filter(and_(or_(condition_1, condition_2), Group.id.in_(study_filter.groups))) + ) else: - raise NotImplementedError(sort_by) - - # pagination - if pagination.page_nb or pagination.page_size: - q = q.offset(pagination.page_nb * pagination.page_size).limit(pagination.page_size) + q = q1.union(q.filter(or_(condition_1, condition_2))) + elif not study_filter.access_permissions.is_admin and study_filter.access_permissions.user_id is None: + # return empty result + # noinspection PyTypeChecker + q = self.session.query(entity).filter(sql.false()) + elif study_filter.groups: + q = q.join(entity.groups).filter(Group.id.in_(study_filter.groups)) - studies: t.Sequence[Study] = q.all() - return studies + return q def get_all_raw(self, exists: t.Optional[bool] = None) -> t.Sequence[RawStudy]: query = self.session.query(RawStudy) diff --git a/antarest/study/service.py b/antarest/study/service.py index 9b22ae7638..81af28a473 100644 --- a/antarest/study/service.py +++ b/antarest/study/service.py @@ -96,7 +96,13 @@ StudyMetadataPatchDTO, StudySimResultDTO, ) -from antarest.study.repository import StudyFilter, StudyMetadataRepository, StudyPagination, StudySortBy +from antarest.study.repository import ( + AccessPermissions, + StudyFilter, + StudyMetadataRepository, + StudyPagination, + StudySortBy, +) from antarest.study.storage.matrix_profile import adjust_matrix_columns_index from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfigDTO from antarest.study.storage.rawstudy.model.filesystem.folder_node import ChildNotFoundError @@ -445,7 +451,6 @@ def edit_comments( def get_studies_information( self, - params: RequestParameters, study_filter: StudyFilter, sort_by: t.Optional[StudySortBy] = None, pagination: StudyPagination = StudyPagination(), @@ -453,7 +458,6 @@ def get_studies_information( """ Get information for matching studies of a search query. Args: - params: request parameters study_filter: filtering parameters sort_by: how to sort the db query results pagination: set offset and limit for db query @@ -472,18 +476,7 @@ def get_studies_information( study_metadata = self._try_get_studies_information(study) if study_metadata is not None: studies[study_metadata.id] = study_metadata - return { - s.id: s - for s in filter( - lambda study_dto: assert_permission( - params.user, - study_dto, - StudyPermissionType.READ, - raising=False, - ), - studies.values(), - ) - } + return studies def _try_get_studies_information(self, study: Study) -> t.Optional[StudyMetadataDTO]: try: @@ -2139,10 +2132,24 @@ def update_matrix( raise BadEditInstructionException(str(exc)) from exc def check_and_update_all_study_versions_in_database(self, params: RequestParameters) -> None: + """ + This function updates studies version on the db. + + **Warnings: Only users with Admins rights should be able to run this function.** + + Args: + params: Request parameters holding user ID and groups + + Raises: + UserHasNotPermissionError: if params user is not admin. + + """ if params.user and not params.user.is_site_admin(): logger.error(f"User {params.user.id} is not site admin") raise UserHasNotPermissionError() - studies = self.repository.get_all(study_filter=StudyFilter(managed=False)) + studies = self.repository.get_all( + study_filter=StudyFilter(managed=False, access_permissions=AccessPermissions.from_params(params)) + ) for study in studies: storage = self.storage_service.raw_study_service diff --git a/antarest/study/storage/auto_archive_service.py b/antarest/study/storage/auto_archive_service.py index 911b715f2d..a1eafc40a3 100644 --- a/antarest/study/storage/auto_archive_service.py +++ b/antarest/study/storage/auto_archive_service.py @@ -10,7 +10,7 @@ from antarest.core.requests import RequestParameters from antarest.core.utils.fastapi_sqlalchemy import db from antarest.study.model import RawStudy, Study -from antarest.study.repository import StudyFilter +from antarest.study.repository import AccessPermissions, StudyFilter from antarest.study.service import StudyService from antarest.study.storage.variantstudy.model.dbmodel import VariantStudy @@ -28,7 +28,10 @@ def __init__(self, study_service: StudyService, config: Config): def _try_archive_studies(self) -> None: old_date = datetime.datetime.utcnow() - datetime.timedelta(days=self.config.storage.auto_archive_threshold_days) with db(): - studies: t.Sequence[Study] = self.study_service.repository.get_all(study_filter=StudyFilter(managed=True)) + # in this part full `Read` rights over studies are granted to this function + studies: t.Sequence[Study] = self.study_service.repository.get_all( + study_filter=StudyFilter(managed=True, access_permissions=AccessPermissions(is_admin=True)) + ) # list of study IDs and boolean indicating if it's a raw study (True) or a variant (False) study_ids_to_archive = [ (study.id, isinstance(study, RawStudy)) diff --git a/antarest/study/storage/rawstudy/watcher.py b/antarest/study/storage/rawstudy/watcher.py index d2b5c9883c..8f593ce0d6 100644 --- a/antarest/study/storage/rawstudy/watcher.py +++ b/antarest/study/storage/rawstudy/watcher.py @@ -94,6 +94,7 @@ def _loop(self) -> None: "Removing duplicates, this is a temporary fix that should be removed when previous duplicates are removed" ) with db(): + # in this part full `Read` rights over studies are granted to this function self.study_service.remove_duplicates() except Exception as e: logger.error("Unexpected error when removing duplicates", exc_info=e) diff --git a/antarest/study/web/studies_blueprint.py b/antarest/study/web/studies_blueprint.py index beeecd65c5..9007ed2894 100644 --- a/antarest/study/web/studies_blueprint.py +++ b/antarest/study/web/studies_blueprint.py @@ -15,7 +15,7 @@ from antarest.core.filetransfer.service import FileTransferManager from antarest.core.jwt import JWTUser from antarest.core.model import PublicMode -from antarest.core.requests import RequestParameters +from antarest.core.requests import RequestParameters, UserHasNotPermissionError from antarest.core.utils.utils import BadArchiveContent, sanitize_uuid from antarest.core.utils.web import APITag from antarest.login.auth import Auth @@ -28,7 +28,7 @@ StudyMetadataPatchDTO, StudySimResultDTO, ) -from antarest.study.repository import StudyFilter, StudyPagination, StudySortBy +from antarest.study.repository import AccessPermissions, StudyFilter, StudyPagination, StudySortBy from antarest.study.service import StudyService from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfigDTO @@ -144,6 +144,9 @@ def get_studies( user_list = [int(v) for v in _split_comma_separated_values(users)] + if not params.user: + raise UserHasNotPermissionError("FAIL permission: user is not logged") + study_filter = StudyFilter( name=name, managed=managed, @@ -157,10 +160,10 @@ def get_studies( exists=exists, workspace=workspace, folder=folder, + access_permissions=AccessPermissions.from_params(params), ) matching_studies = study_service.get_studies_information( - params=params, study_filter=study_filter, sort_by=sort_by, pagination=StudyPagination(page_nb=page_nb, page_size=page_size), diff --git a/tests/integration/studies_blueprint/test_get_studies.py b/tests/integration/studies_blueprint/test_get_studies.py index 579ad2dfe7..48dadaf829 100644 --- a/tests/integration/studies_blueprint/test_get_studies.py +++ b/tests/integration/studies_blueprint/test_get_studies.py @@ -11,6 +11,7 @@ from starlette.testclient import TestClient from antarest.core.model import PublicMode +from antarest.core.roles import RoleType from antarest.core.tasks.model import TaskStatus from tests.integration.assets import ASSETS_DIR from tests.integration.utils import wait_task_completion @@ -802,6 +803,604 @@ def test_study_listing( values = list(study_map.values()) assert values == sorted(values, key=lambda x: x["created"], reverse=True) + def test_get_studies__access_permissions(self, client: TestClient, admin_access_token: str) -> None: + """ + Test the access permissions for the `GET /studies` endpoint. + + Args: + client: client App fixture to perform the requests + admin_access_token: fixture to get the admin access token + + Returns: + + """ + ########################## + # 1. Database initialization + ########################## + + users = {"user_1": "pass_1", "user_2": "pass_2", "user_3": "pass_3"} + users_tokens = {} + users_ids = {} + groups = {"group_1", "group_2", "group_3"} + groups_ids = {} + user_groups_mapping = {"user_1": ["group_2"], "user_2": ["group_1"], "user_3": []} + + # create users + for user, password in users.items(): + res = client.post( + "/v1/users", + headers={"Authorization": f"Bearer {admin_access_token}"}, + json={"name": user, "password": password}, + ) + res.raise_for_status() + users_ids[user] = res.json().get("id") + + # create groups + for group in groups: + res = client.post( + "/v1/groups", + headers={"Authorization": f"Bearer {admin_access_token}"}, + json={"name": group}, + ) + res.raise_for_status() + groups_ids[group] = res.json().get("id") + + # associate users to groups + for user, groups in user_groups_mapping.items(): + user_id = users_ids[user] + for group in groups: + group_id = groups_ids[group] + res = client.post( + "/v1/roles", + headers={"Authorization": f"Bearer {admin_access_token}"}, + json={"identity_id": user_id, "group_id": group_id, "type": RoleType.READER.value}, + ) + res.raise_for_status() + + # login users + for user, password in users.items(): + res = client.post( + "/v1/login", + json={"username": user, "password": password}, + ) + res.raise_for_status() + assert res.json().get("user") == users_ids[user] + users_tokens[user] = res.json().get("access_token") + + # studies creation + studies_ids_mapping = {} + + # create variant studies for user_1 and user_2 that are part of some groups + # studies that have owner and groups + for study, study_info in { + "study_1": {"owner": "user_1", "groups": ["group_1"]}, + "study_2": {"owner": "user_1", "groups": ["group_2"]}, + "study_4": {"owner": "user_2", "groups": ["group_1"]}, + "study_5": {"owner": "user_2", "groups": ["group_2"]}, + "study_7": {"owner": "user_1", "groups": ["group_1", "group_2"]}, + "study_8": {"owner": "user_2", "groups": ["group_1", "group_2"]}, + }.items(): + res = client.post( + STUDIES_URL, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": f"dummy_{study}"}, + ) + assert res.status_code in CREATE_STATUS_CODES, res.json() + study_id = res.json() + res = client.post( + f"{STUDIES_URL}/{study_id}/variants", + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": study}, + ) + assert res.status_code in CREATE_STATUS_CODES, res.json() + study_id = res.json() + studies_ids_mapping[study] = study_id + owner_id = users_ids[study_info.get("owner")] + res = client.put( + f"{STUDIES_URL}/{study_id}/owner/{owner_id}", + headers={"Authorization": f"Bearer {admin_access_token}"}, + ) + assert res.status_code == 200, res.json() + for group in study_info.get("groups"): + group_id = groups_ids[group] + res = client.put( + f"{STUDIES_URL}/{study_id}/groups/{group_id}", + headers={"Authorization": f"Bearer {admin_access_token}"}, + ) + assert res.status_code == 200, res.json() + # studies that have owner but no groups + for study, study_info in { + "study_10": {"owner": "user_1"}, + "study_11": {"owner": "user_2"}, + }.items(): + res = client.post( + STUDIES_URL, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": f"dummy_{study}"}, + ) + assert res.status_code in CREATE_STATUS_CODES, res.json() + study_id = res.json() + res = client.post( + f"{STUDIES_URL}/{study_id}/variants", + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": study}, + ) + assert res.status_code in CREATE_STATUS_CODES, res.json() + study_id = res.json() + studies_ids_mapping[study] = study_id + owner_id = users_ids[study_info.get("owner")] + res = client.put( + f"{STUDIES_URL}/{study_id}/owner/{owner_id}", + headers={"Authorization": f"Bearer {admin_access_token}"}, + ) + assert res.status_code == 200, res.json() + # studies that have groups but no owner + for study, study_info in { + "study_3": {"groups": ["group_1"]}, + "study_6": {"groups": ["group_2"]}, + "study_9": {"groups": ["group_1", "group_2"]}, + }.items(): + res = client.post( + STUDIES_URL, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": f"dummy_{study}"}, + ) + assert res.status_code in CREATE_STATUS_CODES, res.json() + study_id = res.json() + res = client.post( + f"{STUDIES_URL}/{study_id}/variants", + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": study}, + ) + assert res.status_code in CREATE_STATUS_CODES, res.json() + study_id = res.json() + studies_ids_mapping[study] = study_id + for group in study_info.get("groups"): + group_id = groups_ids[group] + res = client.put( + f"{STUDIES_URL}/{study_id}/groups/{group_id}", + headers={"Authorization": f"Bearer {admin_access_token}"}, + ) + assert res.status_code == 200, res.json() + + # create variant studies with neither owner nor groups + for study, study_info in { + "study_12": {"public_mode": None}, + "study_13": {"public_mode": PublicMode.READ.value}, + "study_14": {"public_mode": PublicMode.EDIT.value}, + "study_15": {"public_mode": PublicMode.EXECUTE.value}, + "study_16": {"public_mode": PublicMode.FULL.value}, + }.items(): + res = client.post( + STUDIES_URL, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": f"dummy_{study}"}, + ) + assert res.status_code in CREATE_STATUS_CODES, res.json() + study_id = res.json() + res = client.post( + f"{STUDIES_URL}/{study_id}/variants", + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": study}, + ) + assert res.status_code in CREATE_STATUS_CODES, res.json() + study_id = res.json() + studies_ids_mapping[study] = study_id + public_mode = study_info.get("public_mode") + if public_mode: + res = client.put( + f"{STUDIES_URL}/{study_id}/public_mode/{public_mode}", + headers={"Authorization": f"Bearer {admin_access_token}"}, + ) + assert res.status_code == 200, res.json() + + # create raw studies for user_1 and user_2 that are part of some groups + # studies that have owner and groups + for study, study_info in { + "study_17": {"owner": "user_1", "groups": ["group_1"]}, + "study_18": {"owner": "user_1", "groups": ["group_2"]}, + "study_20": {"owner": "user_2", "groups": ["group_1"]}, + "study_21": {"owner": "user_2", "groups": ["group_2"]}, + "study_23": {"owner": "user_1", "groups": ["group_1", "group_2"]}, + "study_24": {"owner": "user_2", "groups": ["group_1", "group_2"]}, + }.items(): + res = client.post( + STUDIES_URL, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": study}, + ) + assert res.status_code in CREATE_STATUS_CODES, res.json() + study_id = res.json() + studies_ids_mapping[study] = study_id + owner = users_ids[study_info.get("owner")] + res = client.put( + f"{STUDIES_URL}/{study_id}/owner/{owner}", + headers={"Authorization": f"Bearer {admin_access_token}"}, + ) + assert res.status_code == 200, res.json() + for group in study_info.get("groups"): + group_id = groups_ids[group] + res = client.put( + f"{STUDIES_URL}/{study_id}/groups/{group_id}", + headers={"Authorization": f"Bearer {admin_access_token}"}, + ) + assert res.status_code == 200, res.json() + # studies that have owner but no groups + for study, study_info in { + "study_26": {"owner": "user_1"}, + "study_27": {"owner": "user_2"}, + }.items(): + res = client.post( + STUDIES_URL, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": study}, + ) + assert res.status_code in CREATE_STATUS_CODES, res.json() + study_id = res.json() + studies_ids_mapping[study] = study_id + owner_id = users_ids[study_info.get("owner")] + res = client.put( + f"{STUDIES_URL}/{study_id}/owner/{owner_id}", + headers={"Authorization": f"Bearer {admin_access_token}"}, + ) + assert res.status_code == 200, res.json() + # studies that have groups but no owner + for study, study_info in { + "study_19": {"groups": ["group_1"]}, + "study_22": {"groups": ["group_2"]}, + "study_25": {"groups": ["group_1", "group_2"]}, + }.items(): + res = client.post( + STUDIES_URL, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": study}, + ) + assert res.status_code in CREATE_STATUS_CODES, res.json() + study_id = res.json() + studies_ids_mapping[study] = study_id + for group in study_info.get("groups"): + group_id = groups_ids[group] + res = client.put( + f"{STUDIES_URL}/{study_id}/groups/{group_id}", + headers={"Authorization": f"Bearer {admin_access_token}"}, + ) + assert res.status_code == 200, res.json() + + # create raw studies with neither owner nor groups + for study, study_info in { + "study_28": {"public_mode": None}, + "study_29": {"public_mode": PublicMode.READ.value}, + "study_30": {"public_mode": PublicMode.EDIT.value}, + "study_31": {"public_mode": PublicMode.EXECUTE.value}, + "study_32": {"public_mode": PublicMode.FULL.value}, + }.items(): + res = client.post( + STUDIES_URL, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": study}, + ) + assert res.status_code in CREATE_STATUS_CODES, res.json() + study_id = res.json() + studies_ids_mapping[study] = study_id + public_mode = study_info.get("public_mode") + if public_mode: + res = client.put( + f"{STUDIES_URL}/{study_id}/public_mode/{public_mode}", + headers={"Authorization": f"Bearer {admin_access_token}"}, + ) + assert res.status_code == 200, res.json() + + # create studies for user_3 that is not part of any group + # variant studies + for study, study_info in { + "study_33": {"groups": ["group_1"]}, + "study_35": {"groups": []}, + }.items(): + res = client.post( + STUDIES_URL, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": f"dummy_{study}"}, + ) + assert res.status_code in CREATE_STATUS_CODES, res.json() + study_id = res.json() + res = client.post( + f"{STUDIES_URL}/{study_id}/variants", + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": study}, + ) + assert res.status_code in CREATE_STATUS_CODES, res.json() + study_id = res.json() + studies_ids_mapping[study] = study_id + owner_id = users_ids["user_3"] + res = client.put( + f"{STUDIES_URL}/{study_id}/owner/{owner_id}", + headers={"Authorization": f"Bearer {admin_access_token}"}, + ) + assert res.status_code == 200, res.json() + for group in study_info.get("groups", []): + group_id = groups_ids[group] + res = client.put( + f"{STUDIES_URL}/{study_id}/groups/{group_id}", + headers={"Authorization": f"Bearer {admin_access_token}"}, + ) + assert res.status_code == 200, res.json() + # raw studies + for study, study_info in { + "study_34": {"groups": ["group_2"]}, + "study_36": {"groups": []}, + }.items(): + res = client.post( + STUDIES_URL, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": study}, + ) + assert res.status_code in CREATE_STATUS_CODES, res.json() + study_id = res.json() + studies_ids_mapping[study] = study_id + owner_id = users_ids["user_3"] + res = client.put( + f"{STUDIES_URL}/{study_id}/owner/{owner_id}", + headers={"Authorization": f"Bearer {admin_access_token}"}, + ) + assert res.status_code == 200, res.json() + for group in study_info.get("groups"): + group_id = groups_ids[group] + res = client.put( + f"{STUDIES_URL}/{study_id}/groups/{group_id}", + headers={"Authorization": f"Bearer {admin_access_token}"}, + ) + assert res.status_code == 200, res.json() + + # create studies for group_3 that has no user + res = client.post( + STUDIES_URL, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": "dummy_study_37"}, + ) + assert res.status_code in CREATE_STATUS_CODES, res.json() + study_id = res.json() + res = client.post( + f"{STUDIES_URL}/{study_id}/variants", + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": "study_37"}, + ) + assert res.status_code in CREATE_STATUS_CODES, res.json() + study_id = res.json() + group_3_id = groups_ids["group_3"] + res = client.put( + f"{STUDIES_URL}/{study_id}/groups/{group_3_id}", + headers={"Authorization": f"Bearer {admin_access_token}"}, + ) + assert res.status_code == 200, res.json() + studies_ids_mapping["study_37"] = study_id + res = client.post( + STUDIES_URL, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"name": "study_38"}, + ) + assert res.status_code in CREATE_STATUS_CODES, res.json() + study_id = res.json() + res = client.put( + f"{STUDIES_URL}/{study_id}/groups/{group_3_id}", + headers={"Authorization": f"Bearer {admin_access_token}"}, + ) + assert res.status_code == 200, res.json() + studies_ids_mapping["study_38"] = study_id + + # verify the studies creation was done correctly and that admin has access to all studies + all_studies = set(studies_ids_mapping.values()) + studies_target_info = { + "study_1": { + "type": "variantstudy", + "owner": "user_1", + "groups": ["group_1"], + "public_mode": PublicMode.NONE, + }, + "study_2": { + "type": "variantstudy", + "owner": "user_1", + "groups": ["group_2"], + "public_mode": PublicMode.NONE, + }, + "study_3": {"type": "variantstudy", "owner": None, "groups": ["group_1"], "public_mode": PublicMode.NONE}, + "study_4": { + "type": "variantstudy", + "owner": "user_2", + "groups": ["group_1"], + "public_mode": PublicMode.NONE, + }, + "study_5": { + "type": "variantstudy", + "owner": "user_2", + "groups": ["group_2"], + "public_mode": PublicMode.NONE, + }, + "study_6": {"type": "variantstudy", "owner": None, "groups": ["group_2"], "public_mode": PublicMode.NONE}, + "study_7": { + "type": "variantstudy", + "owner": "user_1", + "groups": ["group_1", "group_2"], + "public_mode": PublicMode.NONE, + }, + "study_8": { + "type": "variantstudy", + "owner": "user_2", + "groups": ["group_1", "group_2"], + "public_mode": PublicMode.NONE, + }, + "study_9": { + "type": "variantstudy", + "owner": None, + "groups": ["group_1", "group_2"], + "public_mode": PublicMode.NONE, + }, + "study_10": {"type": "variantstudy", "owner": "user_1", "groups": None, "public_mode": PublicMode.NONE}, + "study_11": {"type": "variantstudy", "owner": "user_2", "groups": None, "public_mode": PublicMode.NONE}, + "study_12": {"type": "variantstudy", "owner": None, "groups": None, "public_mode": PublicMode.NONE}, + "study_13": {"type": "variantstudy", "owner": None, "groups": None, "public_mode": PublicMode.READ}, + "study_14": {"type": "variantstudy", "owner": None, "groups": None, "public_mode": PublicMode.EDIT}, + "study_15": {"type": "variantstudy", "owner": None, "groups": None, "public_mode": PublicMode.EXECUTE}, + "study_16": {"type": "variantstudy", "owner": None, "groups": None, "public_mode": PublicMode.FULL}, + "study_17": {"type": "rawstudy", "owner": "user_1", "groups": ["group_1"], "public_mode": PublicMode.NONE}, + "study_18": {"type": "rawstudy", "owner": "user_1", "groups": ["group_2"], "public_mode": PublicMode.NONE}, + "study_19": {"type": "rawstudy", "owner": None, "groups": ["group_1"], "public_mode": PublicMode.NONE}, + "study_20": {"type": "rawstudy", "owner": "user_2", "groups": ["group_1"], "public_mode": PublicMode.NONE}, + "study_21": {"type": "rawstudy", "owner": "user_2", "groups": ["group_2"], "public_mode": PublicMode.NONE}, + "study_22": {"type": "rawstudy", "owner": None, "groups": ["group_2"], "public_mode": PublicMode.NONE}, + "study_23": { + "type": "rawstudy", + "owner": "user_1", + "groups": ["group_1", "group_2"], + "public_mode": PublicMode.NONE, + }, + "study_24": { + "type": "rawstudy", + "owner": "user_2", + "groups": ["group_1", "group_2"], + "public_mode": PublicMode.NONE, + }, + "study_25": { + "type": "rawstudy", + "owner": None, + "groups": ["group_1", "group_2"], + "public_mode": PublicMode.NONE, + }, + "study_26": {"type": "rawstudy", "owner": "user_1", "groups": None, "public_mode": PublicMode.NONE}, + "study_27": {"type": "rawstudy", "owner": "user_2", "groups": None, "public_mode": PublicMode.NONE}, + "study_28": {"type": "rawstudy", "owner": None, "groups": None, "public_mode": PublicMode.NONE}, + "study_29": {"type": "rawstudy", "owner": None, "groups": None, "public_mode": PublicMode.READ}, + "study_30": {"type": "rawstudy", "owner": None, "groups": None, "public_mode": PublicMode.EDIT}, + "study_31": {"type": "rawstudy", "owner": None, "groups": None, "public_mode": PublicMode.EXECUTE}, + "study_32": {"type": "rawstudy", "owner": None, "groups": None, "public_mode": PublicMode.FULL}, + "study_33": { + "type": "variantstudy", + "owner": "user_3", + "groups": ["group_1"], + "public_mode": PublicMode.NONE, + }, + "study_34": {"type": "rawstudy", "owner": "user_3", "groups": ["group_2"], "public_mode": PublicMode.NONE}, + "study_35": {"type": "variantstudy", "owner": "user_3", "groups": None, "public_mode": PublicMode.NONE}, + "study_36": {"type": "rawstudy", "owner": "user_3", "groups": None, "public_mode": PublicMode.NONE}, + "study_37": {"type": "variantstudy", "owner": None, "groups": ["group_3"], "public_mode": PublicMode.NONE}, + "study_38": {"type": "rawstudy", "owner": None, "groups": ["group_3"], "public_mode": PublicMode.NONE}, + } + res = client.get(STUDIES_URL, headers={"Authorization": f"Bearer {admin_access_token}"}) + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() + assert len(all_studies) == 38 + assert not all_studies.difference(study_map) + for study, study_info in studies_target_info.items(): + study_id = studies_ids_mapping[study] + study_data = study_map[study_id] + assert study_data.get("type") == study_info.get("type") + if study_data.get("owner") and study_info.get("owner"): + assert study_data["owner"]["name"] == study_info.get("owner") + assert study_data["owner"]["id"] == users_ids[study_info.get("owner")] + else: + assert not study_info.get("owner") + assert study_data["owner"]["name"] == "admin" + if study_data.get("groups"): + expected_groups = set(study_info.get("groups")) + assert all( + (group["name"] in expected_groups) and groups_ids[group["name"]] == group["id"] + for group in study_data["groups"] + ) + else: + assert not study_info.get("groups") + assert study_data["public_mode"] == study_info.get("public_mode") + + ########################## + # 2. Tests + ########################## + + # user_1 access + requests_params_expected_studies = [ + # fmt: off + ([], {"1", "2", "5", "6", "7", "8", "9", "10", "13", "14", "15", "16", "17", + "18", "21", "22", "23", "24", "25", "26", "29", "30", "31", "32", "34"}), + (["1"], {"1", "7", "8", "9", "17", "23", "24", "25"}), + (["2"], {"2", "5", "6", "7", "8", "9", "18", "21", "22", "23", "24", "25", "34"}), + (["3"], set()), + (["1", "2"], {"1", "2", "5", "6", "7", "8", "9", "17", "18", "21", "22", "23", "24", "25", "34"}), + (["1", "3"], {"1", "7", "8", "9", "17", "23", "24", "25"}), + (["2", "3"], {"2", "5", "6", "7", "8", "9", "18", "21", "22", "23", "24", "25", "34"}), + ( + ["1", "2", "3"], + {"1", "2", "5", "6", "7", "8", "9", "17", "18", "21", "22", "23", "24", "25", "34"}, + ), + ] + for request_groups_numbers, expected_studies_numbers in requests_params_expected_studies: + request_groups_ids = [groups_ids[f"group_{group_number}"] for group_number in request_groups_numbers] + expected_studies = { + studies_ids_mapping[f"study_{study_number}"] for study_number in expected_studies_numbers + } + res = client.get( + STUDIES_URL, + headers={"Authorization": f"Bearer {users_tokens['user_1']}"}, + params={"groups": ",".join(request_groups_ids)} if request_groups_ids else {}, + ) + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() + assert not expected_studies.difference(set(study_map)) + assert not all_studies.difference(expected_studies).intersection(set(study_map)) + + # user_2 access + requests_params_expected_studies = [ + # fmt: off + ([], {"1", "3", "4", "5", "7", "8", "9", "11", "13", "14", "15", "16", "17", + "19", "20", "21", "23", "24", "25", "27", "29", "30", "31", "32", "33"}), + (["1"], {"1", "3", "4", "7", "8", "9", "17", "19", "20", "23", "24", "25", "33"}), + (["2"], {"5", "7", "8", "9", "21", "23", "24", "25"}), + (["3"], set()), + (["1", "2"], {"1", "3", "4", "5", "7", "8", "9", "17", "19", "20", "21", "23", "24", "25", "33"}), + (["1", "3"], {"1", "3", "4", "7", "8", "9", "17", "19", "20", "23", "24", "25", "33"}), + (["2", "3"], {"5", "7", "8", "9", "21", "23", "24", "25"}), + ( + ["1", "2", "3"], + {"1", "3", "4", "5", "7", "8", "9", "17", "19", "20", "21", "23", "24", "25", "33"}, + ), + ] + for request_groups_numbers, expected_studies_numbers in requests_params_expected_studies: + request_groups_ids = [groups_ids[f"group_{group_number}"] for group_number in request_groups_numbers] + expected_studies = { + studies_ids_mapping[f"study_{study_number}"] for study_number in expected_studies_numbers + } + res = client.get( + STUDIES_URL, + headers={"Authorization": f"Bearer {users_tokens['user_2']}"}, + params={"groups": ",".join(request_groups_ids)} if request_groups_ids else {}, + ) + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() + assert not expected_studies.difference(set(study_map)) + assert not all_studies.difference(expected_studies).intersection(set(study_map)) + + # user_3 access + requests_params_expected_studies = [ + ([], {"13", "14", "15", "16", "29", "30", "31", "32", "33", "34", "35", "36"}), + (["1"], {"33"}), + (["2"], {"34"}), + (["3"], set()), + (["1", "2"], {"33", "34"}), + (["1", "3"], {"33"}), + (["2", "3"], {"34"}), + (["1", "2", "3"], {"33", "34"}), + ] + for request_groups_numbers, expected_studies_numbers in requests_params_expected_studies: + request_groups_ids = [groups_ids[f"group_{group_number}"] for group_number in request_groups_numbers] + expected_studies = { + studies_ids_mapping[f"study_{study_number}"] for study_number in expected_studies_numbers + } + res = client.get( + STUDIES_URL, + headers={"Authorization": f"Bearer {users_tokens['user_3']}"}, + params={"groups": ",".join(request_groups_ids)} if request_groups_ids else {}, + ) + assert res.status_code == LIST_STATUS_CODE, res.json() + study_map = res.json() + assert not expected_studies.difference(set(study_map)) + assert not all_studies.difference(expected_studies).intersection(set(study_map)) + def test_get_studies__invalid_parameters( self, client: TestClient, diff --git a/tests/storage/repository/test_study.py b/tests/storage/repository/test_study.py index 5535f4d074..7aa5fb23cd 100644 --- a/tests/storage/repository/test_study.py +++ b/tests/storage/repository/test_study.py @@ -7,7 +7,7 @@ from antarest.core.model import PublicMode from antarest.login.model import Group, User from antarest.study.model import DEFAULT_WORKSPACE_NAME, RawStudy, Study, StudyAdditionalData, StudyContentStatus -from antarest.study.repository import StudyMetadataRepository +from antarest.study.repository import AccessPermissions, StudyFilter, StudyMetadataRepository from antarest.study.storage.variantstudy.model.dbmodel import VariantStudy @@ -69,7 +69,7 @@ def test_lifecycle(db_session: Session) -> None: c = repo.one(a_id) assert a_id == c.id - assert len(repo.get_all()) == 4 + assert len(repo.get_all(study_filter=StudyFilter(access_permissions=AccessPermissions(is_admin=True)))) == 4 assert len(repo.get_all_raw(exists=True)) == 1 assert len(repo.get_all_raw(exists=False)) == 1 assert len(repo.get_all_raw()) == 2 diff --git a/tests/storage/test_service.py b/tests/storage/test_service.py index fa7ed5c62d..c322b69672 100644 --- a/tests/storage/test_service.py +++ b/tests/storage/test_service.py @@ -44,7 +44,7 @@ TimeSerie, TimeSeriesData, ) -from antarest.study.repository import StudyFilter, StudyMetadataRepository +from antarest.study.repository import AccessPermissions, StudyFilter, StudyMetadataRepository from antarest.study.service import MAX_MISSING_STUDY_TIMEOUT, StudyService, StudyUpgraderTask, UserHasNotPermissionError from antarest.study.storage.patch_service import PatchService from antarest.study.storage.rawstudy.model.filesystem.config.model import ( @@ -172,6 +172,7 @@ def test_study_listing(db_session: Session) -> None: config = Config(storage=StorageConfig(workspaces={DEFAULT_WORKSPACE_NAME: WorkspaceConfig()})) repository = StudyMetadataRepository(cache_service=Mock(spec=ICache), session=db_session) service = build_study_service(raw_study_service, repository, config, cache_service=cache) + params: RequestParameters = RequestParameters(user=JWTUser(id=2, impersonator=2, type="users")) # retrieve studies that are not managed # use the db recorder to check that: @@ -179,10 +180,7 @@ def test_study_listing(db_session: Session) -> None: # 2- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: studies = service.get_studies_information( - study_filter=StudyFilter( - managed=False, - ), - params=RequestParameters(user=JWTUser(id=2, impersonator=2, type="users")), + study_filter=StudyFilter(managed=False, access_permissions=AccessPermissions.from_params(params)), ) assert len(db_recorder.sql_statements) == 1, str(db_recorder) @@ -196,10 +194,7 @@ def test_study_listing(db_session: Session) -> None: # 2- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: studies = service.get_studies_information( - study_filter=StudyFilter( - managed=True, - ), - params=RequestParameters(user=JWTUser(id=2, impersonator=2, type="users")), + study_filter=StudyFilter(managed=True, access_permissions=AccessPermissions.from_params(params)), ) assert len(db_recorder.sql_statements) == 1, str(db_recorder) @@ -213,10 +208,7 @@ def test_study_listing(db_session: Session) -> None: # 2- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: studies = service.get_studies_information( - study_filter=StudyFilter( - managed=None, - ), - params=RequestParameters(user=JWTUser(id=2, impersonator=2, type="users")), + study_filter=StudyFilter(managed=None, access_permissions=AccessPermissions.from_params(params)), ) assert len(db_recorder.sql_statements) == 1, str(db_recorder) @@ -230,10 +222,7 @@ def test_study_listing(db_session: Session) -> None: # 2- the `put` method of `cache` was never used with DBStatementRecorder(db_session.bind) as db_recorder: studies = service.get_studies_information( - study_filter=StudyFilter( - managed=None, - ), - params=RequestParameters(user=JWTUser(id=2, impersonator=2, type="users")), + study_filter=StudyFilter(managed=None, access_permissions=AccessPermissions.from_params(params)), ) assert len(db_recorder.sql_statements) == 1, str(db_recorder) with contextlib.suppress(AssertionError): diff --git a/tests/study/test_repository.py b/tests/study/test_repository.py index 0a6063fac5..4762cc7fed 100644 --- a/tests/study/test_repository.py +++ b/tests/study/test_repository.py @@ -6,9 +6,10 @@ from sqlalchemy.orm import Session # type: ignore from antarest.core.interfaces.cache import ICache +from antarest.core.model import PublicMode from antarest.login.model import Group, User from antarest.study.model import DEFAULT_WORKSPACE_NAME, RawStudy, Tag -from antarest.study.repository import StudyFilter, StudyMetadataRepository +from antarest.study.repository import AccessPermissions, StudyFilter, StudyMetadataRepository from antarest.study.storage.variantstudy.model.dbmodel import VariantStudy from tests.db_statement_recorder import DBStatementRecorder @@ -38,7 +39,7 @@ (False, [1, 3, 5, 7], None, {"7"}), ], ) -def test_repository_get_all__general_case( +def test_get_all__general_case( db_session: Session, managed: t.Union[bool, None], study_ids: t.Sequence[str], @@ -66,7 +67,14 @@ def test_repository_get_all__general_case( # 2- accessing studies attributes does not require additional queries to db # 3- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: - all_studies = repository.get_all(study_filter=StudyFilter(managed=managed, study_ids=study_ids, exists=exists)) + all_studies = repository.get_all( + study_filter=StudyFilter( + managed=managed, + study_ids=study_ids, + exists=exists, + access_permissions=AccessPermissions(is_admin=True), + ) + ) _ = [s.owner for s in all_studies] _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] @@ -77,7 +85,7 @@ def test_repository_get_all__general_case( assert {s.id for s in all_studies} == expected_ids -def test_repository_get_all__incompatible_case( +def test_get_all__incompatible_case( db_session: Session, ) -> None: test_workspace = "workspace1" @@ -97,7 +105,7 @@ def test_repository_get_all__incompatible_case( db_session.commit() # case 1 - study_filter = StudyFilter(managed=False, variant=True) + study_filter = StudyFilter(managed=False, variant=True, access_permissions=AccessPermissions(is_admin=True)) with DBStatementRecorder(db_session.bind) as db_recorder: all_studies = repository.get_all(study_filter=study_filter) _ = [s.owner for s in all_studies] @@ -108,7 +116,9 @@ def test_repository_get_all__incompatible_case( assert not {s.id for s in all_studies} # case 2 - study_filter = StudyFilter(workspace=test_workspace, variant=True) + study_filter = StudyFilter( + workspace=test_workspace, variant=True, access_permissions=AccessPermissions(is_admin=True) + ) with DBStatementRecorder(db_session.bind) as db_recorder: all_studies = repository.get_all(study_filter=study_filter) _ = [s.owner for s in all_studies] @@ -119,7 +129,7 @@ def test_repository_get_all__incompatible_case( assert not {s.id for s in all_studies} # case 3 - study_filter = StudyFilter(exists=False, variant=True) + study_filter = StudyFilter(exists=False, variant=True, access_permissions=AccessPermissions(is_admin=True)) with DBStatementRecorder(db_session.bind) as db_recorder: all_studies = repository.get_all(study_filter=study_filter) _ = [s.owner for s in all_studies] @@ -144,7 +154,7 @@ def test_repository_get_all__incompatible_case( ("specie-suffix", set()), ], ) -def test_repository_get_all__study_name_filter( +def test_get_all__study_name_filter( db_session: Session, name: str, expected_ids: t.Set[str], @@ -169,7 +179,9 @@ def test_repository_get_all__study_name_filter( # 2- accessing studies attributes does not require additional queries to db # 3- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: - all_studies = repository.get_all(study_filter=StudyFilter(name=name)) + all_studies = repository.get_all( + study_filter=StudyFilter(name=name, access_permissions=AccessPermissions(is_admin=True)) + ) _ = [s.owner for s in all_studies] _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] @@ -188,7 +200,7 @@ def test_repository_get_all__study_name_filter( (False, {"6", "7"}), ], ) -def test_repository_get_all__managed_study_filter( +def test_get_all__managed_study_filter( db_session: Session, managed: t.Optional[bool], expected_ids: t.Set[str], @@ -214,7 +226,9 @@ def test_repository_get_all__managed_study_filter( # 2- accessing studies attributes does not require additional queries to db # 3- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: - all_studies = repository.get_all(study_filter=StudyFilter(managed=managed)) + all_studies = repository.get_all( + study_filter=StudyFilter(managed=managed, access_permissions=AccessPermissions(is_admin=True)) + ) _ = [s.owner for s in all_studies] _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] @@ -233,7 +247,7 @@ def test_repository_get_all__managed_study_filter( (False, {"2", "4"}), ], ) -def test_repository_get_all__archived_study_filter( +def test_get_all__archived_study_filter( db_session: Session, archived: t.Optional[bool], expected_ids: t.Set[str], @@ -254,7 +268,9 @@ def test_repository_get_all__archived_study_filter( # 2- accessing studies attributes does not require additional queries to db # 3- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: - all_studies = repository.get_all(study_filter=StudyFilter(archived=archived)) + all_studies = repository.get_all( + study_filter=StudyFilter(archived=archived, access_permissions=AccessPermissions(is_admin=True)) + ) _ = [s.owner for s in all_studies] _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] @@ -273,7 +289,7 @@ def test_repository_get_all__archived_study_filter( (False, {"3", "4"}), ], ) -def test_repository_get_all__variant_study_filter( +def test_get_all__variant_study_filter( db_session: Session, variant: t.Optional[bool], expected_ids: t.Set[str], @@ -294,7 +310,9 @@ def test_repository_get_all__variant_study_filter( # 2- accessing studies attributes does not require additional queries to db # 3- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: - all_studies = repository.get_all(study_filter=StudyFilter(variant=variant)) + all_studies = repository.get_all( + study_filter=StudyFilter(variant=variant, access_permissions=AccessPermissions(is_admin=True)) + ) _ = [s.owner for s in all_studies] _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] @@ -315,7 +333,7 @@ def test_repository_get_all__variant_study_filter( (["3"], set()), ], ) -def test_repository_get_all__study_version_filter( +def test_get_all__study_version_filter( db_session: Session, versions: t.Sequence[str], expected_ids: t.Set[str], @@ -336,7 +354,9 @@ def test_repository_get_all__study_version_filter( # 2- accessing studies attributes does not require additional queries to db # 3- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: - all_studies = repository.get_all(study_filter=StudyFilter(versions=versions)) + all_studies = repository.get_all( + study_filter=StudyFilter(versions=versions, access_permissions=AccessPermissions(is_admin=True)) + ) _ = [s.owner for s in all_studies] _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] @@ -357,7 +377,7 @@ def test_repository_get_all__study_version_filter( (["3000"], set()), ], ) -def test_repository_get_all__study_users_filter( +def test_get_all__study_users_filter( db_session: Session, users: t.Sequence["int"], expected_ids: t.Set[str], @@ -384,7 +404,9 @@ def test_repository_get_all__study_users_filter( # 2- accessing studies attributes does not require additional queries to db # 3- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: - all_studies = repository.get_all(study_filter=StudyFilter(users=users)) + all_studies = repository.get_all( + study_filter=StudyFilter(users=users, access_permissions=AccessPermissions(is_admin=True)) + ) _ = [s.owner for s in all_studies] _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] @@ -405,7 +427,7 @@ def test_repository_get_all__study_users_filter( (["3000"], set()), ], ) -def test_repository_get_all__study_groups_filter( +def test_get_all__study_groups_filter( db_session: Session, groups: t.Sequence[str], expected_ids: t.Set[str], @@ -432,7 +454,9 @@ def test_repository_get_all__study_groups_filter( # 2- accessing studies attributes does not require additional queries to db # 3- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: - all_studies = repository.get_all(study_filter=StudyFilter(groups=groups)) + all_studies = repository.get_all( + study_filter=StudyFilter(groups=groups, access_permissions=AccessPermissions(is_admin=True)) + ) _ = [s.owner for s in all_studies] _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] @@ -454,7 +478,7 @@ def test_repository_get_all__study_groups_filter( (["3000"], set()), ], ) -def test_repository_get_all__study_ids_filter( +def test_get_all__study_ids_filter( db_session: Session, study_ids: t.Sequence[str], expected_ids: t.Set[str], @@ -475,7 +499,9 @@ def test_repository_get_all__study_ids_filter( # 2- accessing studies attributes does not require additional queries to db # 3- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: - all_studies = repository.get_all(study_filter=StudyFilter(study_ids=study_ids)) + all_studies = repository.get_all( + study_filter=StudyFilter(study_ids=study_ids, access_permissions=AccessPermissions(is_admin=True)) + ) _ = [s.owner for s in all_studies] _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] @@ -494,7 +520,7 @@ def test_repository_get_all__study_ids_filter( (False, {"3"}), ], ) -def test_repository_get_all__study_existence_filter( +def test_get_all__study_existence_filter( db_session: Session, exists: t.Optional[bool], expected_ids: t.Set[str], @@ -515,7 +541,9 @@ def test_repository_get_all__study_existence_filter( # 2- accessing studies attributes does not require additional queries to db # 3- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: - all_studies = repository.get_all(study_filter=StudyFilter(exists=exists)) + all_studies = repository.get_all( + study_filter=StudyFilter(exists=exists, access_permissions=AccessPermissions(is_admin=True)) + ) _ = [s.owner for s in all_studies] _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] @@ -535,7 +563,7 @@ def test_repository_get_all__study_existence_filter( ("workspace-3", set()), ], ) -def test_repository_get_all__study_workspace_filter( +def test_get_all__study_workspace_filter( db_session: Session, workspace: str, expected_ids: t.Set[str], @@ -556,7 +584,9 @@ def test_repository_get_all__study_workspace_filter( # 2- accessing studies attributes does not require additional queries to db # 3- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: - all_studies = repository.get_all(study_filter=StudyFilter(workspace=workspace)) + all_studies = repository.get_all( + study_filter=StudyFilter(workspace=workspace, access_permissions=AccessPermissions(is_admin=True)) + ) _ = [s.owner for s in all_studies] _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] @@ -578,7 +608,7 @@ def test_repository_get_all__study_workspace_filter( ("folder-1", set()), ], ) -def test_repository_get_all__study_folder_filter( +def test_get_all__study_folder_filter( db_session: Session, folder: str, expected_ids: t.Set[str], @@ -599,7 +629,9 @@ def test_repository_get_all__study_folder_filter( # 2- accessing studies attributes does not require additional queries to db # 3- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: - all_studies = repository.get_all(study_filter=StudyFilter(folder=folder)) + all_studies = repository.get_all( + study_filter=StudyFilter(folder=folder, access_permissions=AccessPermissions(is_admin=True)) + ) _ = [s.owner for s in all_studies] _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] @@ -621,7 +653,7 @@ def test_repository_get_all__study_folder_filter( (["no-study-tag"], set()), ], ) -def test_repository_get_all__study_tags_filter( +def test_get_all__study_tags_filter( db_session: Session, tags: t.Sequence[str], expected_ids: t.Set[str], @@ -650,7 +682,9 @@ def test_repository_get_all__study_tags_filter( # 2- accessing studies attributes does not require additional queries to db # 3- having an exact total of queries equals to 1 with DBStatementRecorder(db_session.bind) as db_recorder: - all_studies = repository.get_all(study_filter=StudyFilter(tags=tags)) + all_studies = repository.get_all( + study_filter=StudyFilter(tags=tags, access_permissions=AccessPermissions(is_admin=True)) + ) _ = [s.owner for s in all_studies] _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] @@ -662,6 +696,283 @@ def test_repository_get_all__study_tags_filter( assert {s.id for s in all_studies} == expected_ids +@pytest.mark.parametrize( + "user_id, study_groups, expected_ids", + [ + # fmt: off + (101, [], {"1", "2", "5", "6", "7", "8", "9", "10", "13", "14", "15", "16", "17", "18", + "21", "22", "23", "24", "25", "26", "29", "30", "31", "32", "34"}), + (101, ["101"], {"1", "7", "8", "9", "17", "23", "24", "25"}), + (101, ["102"], {"2", "5", "6", "7", "8", "9", "18", "21", "22", "23", "24", "25", "34"}), + (101, ["103"], set()), + (101, ["101", "102"], {"1", "2", "5", "6", "7", "8", "9", "17", "18", "21", "22", "23", "24", "25", "34"}), + (101, ["101", "103"], {"1", "7", "8", "9", "17", "23", "24", "25"}), + (101, ["102", "103"], {"2", "5", "6", "7", "8", "9", "18", "21", "22", "23", "24", "25", "34"}), + (101, ["101", "102", "103"], {"1", "2", "5", "6", "7", "8", "9", "17", "18", "21", "22", + "23", "24", "25", "34"}), + (102, [], {"1", "3", "4", "5", "7", "8", "9", "11", "13", "14", "15", "16", "17", "19", + "20", "21", "23", "24", "25", "27", "29", "30", "31", "32", "33"}), + (102, ["101"], {"1", "3", "4", "7", "8", "9", "17", "19", "20", "23", "24", "25", "33"}), + (102, ["102"], {"5", "7", "8", "9", "21", "23", "24", "25"}), + (102, ["103"], set()), + (102, ["101", "102"], {"1", "3", "4", "5", "7", "8", "9", "17", "19", "20", "21", "23", "24", "25", "33"}), + (102, ["101", "103"], {"1", "3", "4", "7", "8", "9", "17", "19", "20", "23", "24", "25", "33"}), + (102, ["102", "103"], {"5", "7", "8", "9", "21", "23", "24", "25"}), + (102, ["101", "102", "103"], {"1", "3", "4", "5", "7", "8", "9", "17", "19", "20", "21", + "23", "24", "25", "33"}), + (103, [], {"13", "14", "15", "16", "29", "30", "31", "32", "33", "34", "35", "36"}), + (103, ["101"], {"33"}), + (103, ["102"], {"34"}), + (103, ["103"], set()), + (103, ["101", "102"], {"33", "34"}), + (103, ["101", "103"], {"33"}), + (103, ["102", "103"], {"34"}), + (103, ["101", "102", "103"], {"33", "34"}), + (None, [], set()), + (None, ["101"], set()), + (None, ["102"], set()), + (None, ["103"], set()), + (None, ["101", "102"], set()), + (None, ["101", "103"], set()), + (None, ["102", "103"], set()), + (None, ["101", "102", "103"], set()), + # fmt: on + ], +) +def test_get_all__non_admin_permissions_filter( + db_session: Session, + user_id: t.Optional[int], + study_groups: t.Sequence[str], + expected_ids: t.Set[str], +) -> None: + icache: Mock = Mock(spec=ICache) + repository = StudyMetadataRepository(cache_service=icache, session=db_session) + + user_1 = User(id=101, name="user1") + user_2 = User(id=102, name="user2") + user_3 = User(id=103, name="user3") + + group_1 = Group(id=101, name="group1") + group_2 = Group(id=102, name="group2") + group_3 = Group(id=103, name="group3") + + user_groups_mapping = {101: [group_2.id], 102: [group_1.id], 103: []} + + # create variant studies for user_1 and user_2 that are part of some groups + study_1 = VariantStudy(id=1, owner=user_1, groups=[group_1]) + study_2 = VariantStudy(id=2, owner=user_1, groups=[group_2]) + study_3 = VariantStudy(id=3, groups=[group_1]) + study_4 = VariantStudy(id=4, owner=user_2, groups=[group_1]) + study_5 = VariantStudy(id=5, owner=user_2, groups=[group_2]) + study_6 = VariantStudy(id=6, groups=[group_2]) + study_7 = VariantStudy(id=7, owner=user_1, groups=[group_1, group_2]) + study_8 = VariantStudy(id=8, owner=user_2, groups=[group_1, group_2]) + study_9 = VariantStudy(id=9, groups=[group_1, group_2]) + study_10 = VariantStudy(id=10, owner=user_1) + study_11 = VariantStudy(id=11, owner=user_2) + + # create variant studies with neither owner nor groups + study_12 = VariantStudy(id=12) + study_13 = VariantStudy(id=13, public_mode=PublicMode.READ) + study_14 = VariantStudy(id=14, public_mode=PublicMode.EDIT) + study_15 = VariantStudy(id=15, public_mode=PublicMode.EXECUTE) + study_16 = VariantStudy(id=16, public_mode=PublicMode.FULL) + + # create raw studies for user_1 and user_2 that are part of some groups + study_17 = RawStudy(id=17, owner=user_1, groups=[group_1]) + study_18 = RawStudy(id=18, owner=user_1, groups=[group_2]) + study_19 = RawStudy(id=19, groups=[group_1]) + study_20 = RawStudy(id=20, owner=user_2, groups=[group_1]) + study_21 = RawStudy(id=21, owner=user_2, groups=[group_2]) + study_22 = RawStudy(id=22, groups=[group_2]) + study_23 = RawStudy(id=23, owner=user_1, groups=[group_1, group_2]) + study_24 = RawStudy(id=24, owner=user_2, groups=[group_1, group_2]) + study_25 = RawStudy(id=25, groups=[group_1, group_2]) + study_26 = RawStudy(id=26, owner=user_1) + study_27 = RawStudy(id=27, owner=user_2) + + # create raw studies with neither owner nor groups + study_28 = RawStudy(id=28) + study_29 = RawStudy(id=29, public_mode=PublicMode.READ) + study_30 = RawStudy(id=30, public_mode=PublicMode.EDIT) + study_31 = RawStudy(id=31, public_mode=PublicMode.EXECUTE) + study_32 = RawStudy(id=32, public_mode=PublicMode.FULL) + + # create studies for user_3 that is not part of any group + study_33 = VariantStudy(id=33, owner=user_3, groups=[group_1]) + study_34 = RawStudy(id=34, owner=user_3, groups=[group_2]) + study_35 = VariantStudy(id=35, owner=user_3) + study_36 = RawStudy(id=36, owner=user_3) + + # create studies for group_3 that has no user + study_37 = VariantStudy(id=37, groups=[group_3]) + study_38 = RawStudy(id=38, groups=[group_3]) + + db_session.add_all([user_1, user_2, user_3, group_1, group_2, group_3]) + db_session.add_all( + [ + # fmt: off + study_1, study_2, study_3, study_4, study_5, study_6, study_7, study_8, study_9, study_10, + study_11, study_12, study_13, study_14, study_15, study_16, study_17, study_18, study_19, study_20, + study_21, study_22, study_23, study_24, study_25, study_26, study_27, study_28, study_29, study_30, + study_31, study_32, study_33, study_34, study_35, study_36, study_37, study_38, + # fmt: on + ] + ) + db_session.commit() + + access_permissions = ( + AccessPermissions(user_id=user_id, user_groups=user_groups_mapping.get(user_id)) + if user_id + else AccessPermissions() + ) + study_filter = ( + StudyFilter(groups=study_groups, access_permissions=access_permissions) + if study_groups + else StudyFilter(access_permissions=access_permissions) + ) + + # use the db recorder to check that: + # 1- retrieving all studies requires only 1 query + # 2- accessing studies attributes does not require additional queries to db + # 3- having an exact total of queries equals to 1 + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all(study_filter=study_filter) + _ = [s.owner for s in all_studies] + _ = [s.groups for s in all_studies] + _ = [s.additional_data for s in all_studies] + _ = [s.tags for s in all_studies] + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + + if expected_ids is not None: + assert {s.id for s in all_studies} == expected_ids + + +@pytest.mark.parametrize( + "is_admin, study_groups, expected_ids", + [ + # fmt: off + (True, [], {str(e) for e in range(1, 39)}), + (True, ["101"], {"1", "3", "4", "7", "8", "9", "17", "19", "20", "23", "24", "25", "33"}), + (True, ["102"], {"2", "5", "6", "7", "8", "9", "18", "21", "22", "23", "24", "25", "34"}), + (True, ["103"], {"37", "38"}), + (True, ["101", "102"], {"1", "2", "3", "4", "5", "6", "7", "8", "9", "17", "18", "19", + "20", "21", "22", "23", "24", "25", "33", "34"}), + (True, ["101", "103"], {"1", "3", "4", "7", "8", "9", "17", "19", "20", "23", "24", "25", "33", "37", "38"}), + (True, ["101", "102", "103"], {"1", "2", "3", "4", "5", "6", "7", "8", "9", "17", "18", + "19", "20", "21", "22", "23", "24", "25", "33", "34", "37", "38"}), + (False, [], set()), + (False, ["101"], set()), + (False, ["102"], set()), + (False, ["103"], set()), + (False, ["101", "102"], set()), + (False, ["101", "103"], set()), + (False, ["101", "102", "103"], set()), + # fmt: on + ], +) +def test_get_all__admin_permissions_filter( + db_session: Session, + is_admin: bool, + study_groups: t.Sequence[str], + expected_ids: t.Set[str], +) -> None: + icache: Mock = Mock(spec=ICache) + repository = StudyMetadataRepository(cache_service=icache, session=db_session) + user_1 = User(id=101, name="user1") + user_2 = User(id=102, name="user2") + user_3 = User(id=103, name="user3") + + group_1 = Group(id=101, name="group1") + group_2 = Group(id=102, name="group2") + group_3 = Group(id=103, name="group3") + + # create variant studies for user_1 and user_2 that are part of some groups + study_1 = VariantStudy(id=1, owner=user_1, groups=[group_1]) + study_2 = VariantStudy(id=2, owner=user_1, groups=[group_2]) + study_3 = VariantStudy(id=3, groups=[group_1]) + study_4 = VariantStudy(id=4, owner=user_2, groups=[group_1]) + study_5 = VariantStudy(id=5, owner=user_2, groups=[group_2]) + study_6 = VariantStudy(id=6, groups=[group_2]) + study_7 = VariantStudy(id=7, owner=user_1, groups=[group_1, group_2]) + study_8 = VariantStudy(id=8, owner=user_2, groups=[group_1, group_2]) + study_9 = VariantStudy(id=9, groups=[group_1, group_2]) + study_10 = VariantStudy(id=10, owner=user_1) + study_11 = VariantStudy(id=11, owner=user_2) + + # create variant studies with neither owner nor groups + study_12 = VariantStudy(id=12) + study_13 = VariantStudy(id=13, public_mode=PublicMode.READ) + study_14 = VariantStudy(id=14, public_mode=PublicMode.EDIT) + study_15 = VariantStudy(id=15, public_mode=PublicMode.EXECUTE) + study_16 = VariantStudy(id=16, public_mode=PublicMode.FULL) + + # create raw studies for user_1 and user_2 that are part of some groups + study_17 = RawStudy(id=17, owner=user_1, groups=[group_1]) + study_18 = RawStudy(id=18, owner=user_1, groups=[group_2]) + study_19 = RawStudy(id=19, groups=[group_1]) + study_20 = RawStudy(id=20, owner=user_2, groups=[group_1]) + study_21 = RawStudy(id=21, owner=user_2, groups=[group_2]) + study_22 = RawStudy(id=22, groups=[group_2]) + study_23 = RawStudy(id=23, owner=user_1, groups=[group_1, group_2]) + study_24 = RawStudy(id=24, owner=user_2, groups=[group_1, group_2]) + study_25 = RawStudy(id=25, groups=[group_1, group_2]) + study_26 = RawStudy(id=26, owner=user_1) + study_27 = RawStudy(id=27, owner=user_2) + + # create raw studies with neither owner nor groups + study_28 = RawStudy(id=28) + study_29 = RawStudy(id=29, public_mode=PublicMode.READ) + study_30 = RawStudy(id=30, public_mode=PublicMode.EDIT) + study_31 = RawStudy(id=31, public_mode=PublicMode.EXECUTE) + study_32 = RawStudy(id=32, public_mode=PublicMode.FULL) + + # create studies for user_3 that is not part of any group + study_33 = VariantStudy(id=33, owner=user_3, groups=[group_1]) + study_34 = RawStudy(id=34, owner=user_3, groups=[group_2]) + study_35 = VariantStudy(id=35, owner=user_3) + study_36 = RawStudy(id=36, owner=user_3) + + # create studies for group_3 that has no user + study_37 = VariantStudy(id=37, groups=[group_3]) + study_38 = RawStudy(id=38, groups=[group_3]) + + db_session.add_all([user_1, user_2, user_3, group_1, group_2, group_3]) + db_session.add_all( + [ + # fmt: off + study_1, study_2, study_3, study_4, study_5, study_6, study_7, study_8, study_9, study_10, + study_11, study_12, study_13, study_14, study_15, study_16, study_17, study_18, study_19, study_20, + study_21, study_22, study_23, study_24, study_25, study_26, study_27, study_28, study_29, study_30, + study_31, study_32, study_33, study_34, study_35, study_36, study_37, study_38, + # fmt: on + ] + ) + db_session.commit() + + access_permissions = AccessPermissions(is_admin=is_admin) + + study_filter = ( + StudyFilter(groups=study_groups, access_permissions=access_permissions) + if study_groups + else StudyFilter(access_permissions=access_permissions) + ) + # use the db recorder to check that: + # 1- retrieving all studies requires only 1 query + # 2- accessing studies attributes does not require additional queries to db + # 3- having an exact total of queries equals to 1 + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all(study_filter=study_filter) + _ = [s.owner for s in all_studies] + _ = [s.groups for s in all_studies] + _ = [s.additional_data for s in all_studies] + _ = [s.tags for s in all_studies] + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + + if expected_ids is not None: + assert {s.id for s in all_studies} == expected_ids + + def test_update_tags( db_session: Session, ) -> None: From bae236ded15c953d5ebff412324991f450e1fe22 Mon Sep 17 00:00:00 2001 From: mabw-rte <41002227+mabw-rte@users.noreply.github.com> Date: Tue, 27 Feb 2024 15:33:50 +0100 Subject: [PATCH 040/248] feat(study-search): add a studies counting endpoint (#1942) Context: Related to ANT-1107 (tags-db) and ANT-1106 (permissions-db), it happens that front-end can not predict the total number of studies matching some given filtering parameters, to perform the pagination properly. Solution: Add an endpoint that return the total studies count. --- antarest/study/service.py | 16 ++ antarest/study/web/studies_blueprint.py | 102 ++++++-- .../studies_blueprint/test_get_studies.py | 66 +++++ tests/study/test_repository.py | 232 ++++++++++++++++-- 4 files changed, 371 insertions(+), 45 deletions(-) diff --git a/antarest/study/service.py b/antarest/study/service.py index 81af28a473..dc3288b4e2 100644 --- a/antarest/study/service.py +++ b/antarest/study/service.py @@ -478,6 +478,22 @@ def get_studies_information( studies[study_metadata.id] = study_metadata return studies + def count_studies( + self, + study_filter: StudyFilter, + ) -> int: + """ + Get number of matching studies. + Args: + study_filter: filtering parameters + + Returns: total number of studies matching the filtering criteria + """ + total: int = self.repository.count_studies( + study_filter=study_filter, + ) + return total + def _try_get_studies_information(self, study: Study) -> t.Optional[StudyMetadataDTO]: try: return self.storage_service.get_storage(study).get_study_information(study) diff --git a/antarest/study/web/studies_blueprint.py b/antarest/study/web/studies_blueprint.py index 9007ed2894..6565538281 100644 --- a/antarest/study/web/studies_blueprint.py +++ b/antarest/study/web/studies_blueprint.py @@ -34,6 +34,8 @@ logger = logging.getLogger(__name__) +QUERY_REGEX = r"^\s*(?:\d+\s*(?:,\s*\d+\s*)*)?$" + def _split_comma_separated_values(value: str, *, default: t.Sequence[str] = ()) -> t.Sequence[str]: """Split a comma-separated list of values into an ordered set of strings.""" @@ -76,23 +78,11 @@ def get_studies( managed: t.Optional[bool] = Query(None, description="Filter studies based on their management status."), archived: t.Optional[bool] = Query(None, description="Filter studies based on their archive status."), variant: t.Optional[bool] = Query(None, description="Filter studies based on their variant status."), - versions: str = Query( - "", - description="Comma-separated list of versions for filtering.", - regex=r"^\s*(?:\d+\s*(?:,\s*\d+\s*)*)?$", - ), - users: str = Query( - "", - description="Comma-separated list of user IDs for filtering.", - regex=r"^\s*(?:\d+\s*(?:,\s*\d+\s*)*)?$", - ), + versions: str = Query("", description="Comma-separated list of versions for filtering.", regex=QUERY_REGEX), + users: str = Query("", description="Comma-separated list of user IDs for filtering.", regex=QUERY_REGEX), groups: str = Query("", description="Comma-separated list of group IDs for filtering."), tags: str = Query("", description="Comma-separated list of tags for filtering."), - study_ids: str = Query( - "", - description="Comma-separated list of study IDs for filtering.", - alias="studyIds", - ), + study_ids: str = Query("", description="Comma-separated list of study IDs for filtering.", alias="studyIds"), exists: t.Optional[bool] = Query(None, description="Filter studies based on their existence on disk."), workspace: str = Query("", description="Filter studies based on their workspace."), folder: str = Query("", description="Filter studies based on their folder."), @@ -102,23 +92,17 @@ def get_studies( description="Sort studies based on their name (case-insensitive) or creation date.", alias="sortBy", ), - page_nb: NonNegativeInt = Query( - 0, - description="Page number (starting from 0).", - alias="pageNb", - ), + page_nb: NonNegativeInt = Query(0, description="Page number (starting from 0).", alias="pageNb"), page_size: NonNegativeInt = Query( - 0, - description="Number of studies per page (0 = no limit).", - alias="pageSize", + 0, description="Number of studies per page (0 = no limit).", alias="pageSize" ), ) -> t.Dict[str, StudyMetadataDTO]: """ Get the list of studies matching the specified criteria. Args: + - `name`: Filter studies based on their name. Case-insensitive search for studies - whose name contains the specified value. - `managed`: Filter studies based on their management status. - `archived`: Filter studies based on their archive status. - `variant`: Filter studies based on their variant status. @@ -171,6 +155,76 @@ def get_studies( return matching_studies + @bp.get( + "/studies/count", + tags=[APITag.study_management], + summary="Count Studies", + ) + def count_studies( + current_user: JWTUser = Depends(auth.get_current_user), + name: str = Query("", description="Case-insensitive: filter studies based on their name.", alias="name"), + managed: t.Optional[bool] = Query(None, description="Management status filter."), + archived: t.Optional[bool] = Query(None, description="Archive status filter."), + variant: t.Optional[bool] = Query(None, description="Variant status filter."), + versions: str = Query("", description="Comma-separated versions filter.", regex=QUERY_REGEX), + users: str = Query("", description="Comma-separated user IDs filter.", regex=QUERY_REGEX), + groups: str = Query("", description="Comma-separated group IDs filter."), + tags: str = Query("", description="Comma-separated tags filter."), + study_ids: str = Query("", description="Comma-separated study IDs filter.", alias="studyIds"), + exists: t.Optional[bool] = Query(None, description="Existence on disk filter."), + workspace: str = Query("", description="Workspace filter."), + folder: str = Query("", description="Study folder filter."), + ) -> int: + """ + Get the number of studies matching the specified criteria. + + Args: + + - `name`: Regexp to filter through studies based on their names + - `managed`: Whether to limit the selection based on management status. + - `archived`: Whether to limit the selection based on archive status. + - `variant`: Whether to limit the selection either raw or variant studies. + - `versions`: Comma-separated versions for studies to be selected. + - `users`: Comma-separated user IDs for studies to be selected. + - `groups`: Comma-separated group IDs for studies to be selected. + - `tags`: Comma-separated tags for studies to be selected. + - `studyIds`: Comma-separated IDs of studies to be selected. + - `exists`: Whether to limit the selection based on studies' existence on disk. + - `workspace`: to limit studies selection based on their workspace. + - `folder`: to limit studies selection based on their folder. + + Returns: + - An integer representing the total number of studies matching the filters above and the user permissions. + """ + + logger.info("Counting matching studies", extra={"user": current_user.id}) + params = RequestParameters(user=current_user) + + user_list = [int(v) for v in _split_comma_separated_values(users)] + + if not params.user: + raise UserHasNotPermissionError("FAIL permission: user is not logged") + + count = study_service.count_studies( + study_filter=StudyFilter( + name=name, + managed=managed, + archived=archived, + variant=variant, + versions=_split_comma_separated_values(versions), + users=user_list, + groups=_split_comma_separated_values(groups), + tags=_split_comma_separated_values(tags), + study_ids=_split_comma_separated_values(study_ids), + exists=exists, + workspace=workspace, + folder=folder, + access_permissions=AccessPermissions.from_params(params), + ), + ) + + return count + @bp.get( "/studies/{uuid}/comments", tags=[APITag.study_management], diff --git a/tests/integration/studies_blueprint/test_get_studies.py b/tests/integration/studies_blueprint/test_get_studies.py index 48dadaf829..2cff53f047 100644 --- a/tests/integration/studies_blueprint/test_get_studies.py +++ b/tests/integration/studies_blueprint/test_get_studies.py @@ -454,6 +454,15 @@ def test_study_listing( study_map = res.json() assert not all_studies.intersection(study_map) assert all(map(lambda x: pm(x) in [PublicMode.READ, PublicMode.FULL], study_map.values())) + # test pagination + res = client.get( + STUDIES_URL, + headers={"Authorization": f"Bearer {john_doe_access_token}"}, + params={"pageNb": 1, "pageSize": 2}, + ) + assert res.status_code == LIST_STATUS_CODE, res.json() + page_studies = res.json() + assert len(page_studies) == max(0, min(2, len(study_map) - 2)) # test 1.b for an admin user res = client.get( @@ -463,6 +472,31 @@ def test_study_listing( assert res.status_code == LIST_STATUS_CODE, res.json() study_map = res.json() assert not all_studies.difference(study_map) + # test pagination + res = client.get( + STUDIES_URL, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"pageNb": 1, "pageSize": 2}, + ) + assert res.status_code == LIST_STATUS_CODE, res.json() + page_studies = res.json() + assert len(page_studies) == max(0, min(len(study_map) - 2, 2)) + # test pagination concatenation + paginated_studies = {} + page_number = 0 + number_of_pages = 0 + while len(paginated_studies) < len(study_map): + res = client.get( + STUDIES_URL, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"pageNb": page_number, "pageSize": 2}, + ) + assert res.status_code == LIST_STATUS_CODE, res.json() + paginated_studies.update(res.json()) + page_number += 1 + number_of_pages += 1 + assert paginated_studies == study_map + assert number_of_pages == len(study_map) // 2 + len(study_map) % 2 # test 1.c for a user with access to select studies res = client.get( @@ -620,6 +654,15 @@ def test_study_listing( study_map = res.json() assert not all_studies.difference(studies_version_850.union(studies_version_860)).intersection(study_map) assert not studies_version_850.union(studies_version_860).difference(study_map) + # test pagination + res = client.get( + STUDIES_URL, + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"versions": "850,860", "pageNb": 1, "pageSize": 2}, + ) + assert res.status_code == LIST_STATUS_CODE, res.json() + page_studies = res.json() + assert len(page_studies) == max(0, min(len(study_map) - 2, 2)) # tests (7) for users filtering # test 7.a to get studies for one user: James Bond @@ -1318,6 +1361,7 @@ def test_get_studies__access_permissions(self, client: TestClient, admin_access_ # fmt: off ([], {"1", "2", "5", "6", "7", "8", "9", "10", "13", "14", "15", "16", "17", "18", "21", "22", "23", "24", "25", "26", "29", "30", "31", "32", "34"}), + # fmt: on (["1"], {"1", "7", "8", "9", "17", "23", "24", "25"}), (["2"], {"2", "5", "6", "7", "8", "9", "18", "21", "22", "23", "24", "25", "34"}), (["3"], set()), @@ -1343,12 +1387,23 @@ def test_get_studies__access_permissions(self, client: TestClient, admin_access_ study_map = res.json() assert not expected_studies.difference(set(study_map)) assert not all_studies.difference(expected_studies).intersection(set(study_map)) + # test pagination + res = client.get( + STUDIES_URL, + headers={"Authorization": f"Bearer {users_tokens['user_1']}"}, + params={"groups": ",".join(request_groups_ids), "pageNb": 1, "pageSize": 2} + if request_groups_ids + else {"pageNb": 1, "pageSize": 2}, + ) + assert res.status_code == LIST_STATUS_CODE, res.json() + assert len(res.json()) == max(0, min(2, len(expected_studies) - 2)) # user_2 access requests_params_expected_studies = [ # fmt: off ([], {"1", "3", "4", "5", "7", "8", "9", "11", "13", "14", "15", "16", "17", "19", "20", "21", "23", "24", "25", "27", "29", "30", "31", "32", "33"}), + # fmt: on (["1"], {"1", "3", "4", "7", "8", "9", "17", "19", "20", "23", "24", "25", "33"}), (["2"], {"5", "7", "8", "9", "21", "23", "24", "25"}), (["3"], set()), @@ -1473,3 +1528,14 @@ def test_get_studies__invalid_parameters( assert res.status_code == INVALID_PARAMS_STATUS_CODE, res.json() description = res.json()["description"] assert re.search(r"could not be parsed to a boolean", description), f"{description=}" + + +def test_studies_counting(client: TestClient, admin_access_token: str, user_access_token: str) -> None: + # test admin and non admin user studies count requests + for access_token in [admin_access_token, user_access_token]: + res = client.get(STUDIES_URL, headers={"Authorization": f"Bearer {access_token}"}) + assert res.status_code == 200, res.json() + expected_studies_count = len(res.json()) + res = client.get(STUDIES_URL + "/count", headers={"Authorization": f"Bearer {access_token}"}) + assert res.status_code == 200, res.json() + assert res.json() == expected_studies_count diff --git a/tests/study/test_repository.py b/tests/study/test_repository.py index 4762cc7fed..b698497b9c 100644 --- a/tests/study/test_repository.py +++ b/tests/study/test_repository.py @@ -9,7 +9,7 @@ from antarest.core.model import PublicMode from antarest.login.model import Group, User from antarest.study.model import DEFAULT_WORKSPACE_NAME, RawStudy, Tag -from antarest.study.repository import AccessPermissions, StudyFilter, StudyMetadataRepository +from antarest.study.repository import AccessPermissions, StudyFilter, StudyMetadataRepository, StudyPagination from antarest.study.storage.variantstudy.model.dbmodel import VariantStudy from tests.db_statement_recorder import DBStatementRecorder @@ -66,24 +66,30 @@ def test_get_all__general_case( # 1- retrieving all studies requires only 1 query # 2- accessing studies attributes does not require additional queries to db # 3- having an exact total of queries equals to 1 + study_filter = StudyFilter( + managed=managed, study_ids=study_ids, exists=exists, access_permissions=AccessPermissions(is_admin=True) + ) with DBStatementRecorder(db_session.bind) as db_recorder: - all_studies = repository.get_all( - study_filter=StudyFilter( - managed=managed, - study_ids=study_ids, - exists=exists, - access_permissions=AccessPermissions(is_admin=True), - ) - ) + all_studies = repository.get_all(study_filter=study_filter) _ = [s.owner for s in all_studies] _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] _ = [s.tags for s in all_studies] assert len(db_recorder.sql_statements) == 1, str(db_recorder) + # test that the expected studies are returned if expected_ids is not None: assert {s.id for s in all_studies} == expected_ids + # test pagination + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all( + study_filter=study_filter, + pagination=StudyPagination(page_nb=1, page_size=2), + ) + assert len(all_studies) == max(0, min(len(expected_ids) - 2, 2)) + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + def test_get_all__incompatible_case( db_session: Session, @@ -191,6 +197,15 @@ def test_get_all__study_name_filter( if expected_ids is not None: assert {s.id for s in all_studies} == expected_ids + # test pagination + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all( + study_filter=StudyFilter(name=name, access_permissions=AccessPermissions(is_admin=True)), + pagination=StudyPagination(page_nb=1, page_size=2), + ) + assert len(all_studies) == max(0, min(len(expected_ids) - 2, 2)) + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + @pytest.mark.parametrize( "managed, expected_ids", @@ -238,6 +253,15 @@ def test_get_all__managed_study_filter( if expected_ids is not None: assert {s.id for s in all_studies} == expected_ids + # test pagination + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all( + study_filter=StudyFilter(managed=managed, access_permissions=AccessPermissions(is_admin=True)), + pagination=StudyPagination(page_nb=1, page_size=2), + ) + assert len(all_studies) == max(0, min(len(expected_ids) - 2, 2)) + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + @pytest.mark.parametrize( "archived, expected_ids", @@ -267,10 +291,9 @@ def test_get_all__archived_study_filter( # 1- retrieving all studies requires only 1 query # 2- accessing studies attributes does not require additional queries to db # 3- having an exact total of queries equals to 1 + study_filter = StudyFilter(archived=archived, access_permissions=AccessPermissions(is_admin=True)) with DBStatementRecorder(db_session.bind) as db_recorder: - all_studies = repository.get_all( - study_filter=StudyFilter(archived=archived, access_permissions=AccessPermissions(is_admin=True)) - ) + all_studies = repository.get_all(study_filter=study_filter) _ = [s.owner for s in all_studies] _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] @@ -280,6 +303,15 @@ def test_get_all__archived_study_filter( if expected_ids is not None: assert {s.id for s in all_studies} == expected_ids + # test pagination + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all( + study_filter=study_filter, + pagination=StudyPagination(page_nb=1, page_size=1), + ) + assert len(all_studies) == max(0, min(len(expected_ids) - 1, 1)) + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + @pytest.mark.parametrize( "variant, expected_ids", @@ -309,10 +341,9 @@ def test_get_all__variant_study_filter( # 1- retrieving all studies requires only 1 query # 2- accessing studies attributes does not require additional queries to db # 3- having an exact total of queries equals to 1 + study_filter = StudyFilter(variant=variant, access_permissions=AccessPermissions(is_admin=True)) with DBStatementRecorder(db_session.bind) as db_recorder: - all_studies = repository.get_all( - study_filter=StudyFilter(variant=variant, access_permissions=AccessPermissions(is_admin=True)) - ) + all_studies = repository.get_all(study_filter=study_filter) _ = [s.owner for s in all_studies] _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] @@ -322,6 +353,15 @@ def test_get_all__variant_study_filter( if expected_ids is not None: assert {s.id for s in all_studies} == expected_ids + # test pagination + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all( + study_filter=study_filter, + pagination=StudyPagination(page_nb=1, page_size=1), + ) + assert len(all_studies) == max(0, min(len(expected_ids) - 1, 1)) + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + @pytest.mark.parametrize( "versions, expected_ids", @@ -353,10 +393,9 @@ def test_get_all__study_version_filter( # 1- retrieving all studies requires only 1 query # 2- accessing studies attributes does not require additional queries to db # 3- having an exact total of queries equals to 1 + study_filter = StudyFilter(versions=versions, access_permissions=AccessPermissions(is_admin=True)) with DBStatementRecorder(db_session.bind) as db_recorder: - all_studies = repository.get_all( - study_filter=StudyFilter(versions=versions, access_permissions=AccessPermissions(is_admin=True)) - ) + all_studies = repository.get_all(study_filter=study_filter) _ = [s.owner for s in all_studies] _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] @@ -366,6 +405,15 @@ def test_get_all__study_version_filter( if expected_ids is not None: assert {s.id for s in all_studies} == expected_ids + # test pagination + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all( + study_filter=study_filter, + pagination=StudyPagination(page_nb=1, page_size=1), + ) + assert len(all_studies) == max(0, min(len(expected_ids) - 1, 1)) + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + @pytest.mark.parametrize( "users, expected_ids", @@ -416,6 +464,15 @@ def test_get_all__study_users_filter( if expected_ids is not None: assert {s.id for s in all_studies} == expected_ids + # test pagination + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all( + study_filter=StudyFilter(users=users, access_permissions=AccessPermissions(is_admin=True)), + pagination=StudyPagination(page_nb=1, page_size=2), + ) + assert len(all_studies) == max(0, min(len(expected_ids) - 2, 2)) + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + @pytest.mark.parametrize( "groups, expected_ids", @@ -466,6 +523,15 @@ def test_get_all__study_groups_filter( if expected_ids is not None: assert {s.id for s in all_studies} == expected_ids + # test pagination + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all( + study_filter=StudyFilter(groups=groups, access_permissions=AccessPermissions(is_admin=True)), + pagination=StudyPagination(page_nb=1, page_size=2), + ) + assert len(all_studies) == max(0, min(len(expected_ids) - 2, 2)) + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + @pytest.mark.parametrize( "study_ids, expected_ids", @@ -511,6 +577,15 @@ def test_get_all__study_ids_filter( if expected_ids is not None: assert {s.id for s in all_studies} == expected_ids + # test pagination + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all( + study_filter=StudyFilter(study_ids=study_ids, access_permissions=AccessPermissions(is_admin=True)), + pagination=StudyPagination(page_nb=1, page_size=2), + ) + assert len(all_studies) == max(0, min(len(expected_ids) - 2, 2)) + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + @pytest.mark.parametrize( "exists, expected_ids", @@ -553,6 +628,15 @@ def test_get_all__study_existence_filter( if expected_ids is not None: assert {s.id for s in all_studies} == expected_ids + # test pagination + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all( + study_filter=StudyFilter(exists=exists, access_permissions=AccessPermissions(is_admin=True)), + pagination=StudyPagination(page_nb=1, page_size=2), + ) + assert len(all_studies) == max(0, min(len(expected_ids) - 2, 2)) + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + @pytest.mark.parametrize( "workspace, expected_ids", @@ -596,6 +680,15 @@ def test_get_all__study_workspace_filter( if expected_ids is not None: assert {s.id for s in all_studies} == expected_ids + # test pagination + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all( + study_filter=StudyFilter(workspace=workspace, access_permissions=AccessPermissions(is_admin=True)), + pagination=StudyPagination(page_nb=1, page_size=2), + ) + assert len(all_studies) == max(0, min(len(expected_ids) - 2, 2)) + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + @pytest.mark.parametrize( "folder, expected_ids", @@ -628,10 +721,9 @@ def test_get_all__study_folder_filter( # 1- retrieving all studies requires only 1 query # 2- accessing studies attributes does not require additional queries to db # 3- having an exact total of queries equals to 1 + study_filter = StudyFilter(folder=folder, access_permissions=AccessPermissions(is_admin=True)) with DBStatementRecorder(db_session.bind) as db_recorder: - all_studies = repository.get_all( - study_filter=StudyFilter(folder=folder, access_permissions=AccessPermissions(is_admin=True)) - ) + all_studies = repository.get_all(study_filter=study_filter) _ = [s.owner for s in all_studies] _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] @@ -642,6 +734,15 @@ def test_get_all__study_folder_filter( if expected_ids is not None: assert {s.id for s in all_studies} == expected_ids + # test pagination + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all( + study_filter=study_filter, + pagination=StudyPagination(page_nb=1, page_size=1), + ) + assert len(all_studies) == max(0, min(len(expected_ids) - 1, 1)) + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + @pytest.mark.parametrize( "tags, expected_ids", @@ -695,6 +796,15 @@ def test_get_all__study_tags_filter( if expected_ids is not None: assert {s.id for s in all_studies} == expected_ids + # test pagination + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all( + study_filter=StudyFilter(tags=tags, access_permissions=AccessPermissions(is_admin=True)), + pagination=StudyPagination(page_nb=1, page_size=2), + ) + assert len(all_studies) == max(0, min(len(expected_ids) - 2, 2)) + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + @pytest.mark.parametrize( "user_id, study_groups, expected_ids", @@ -847,6 +957,12 @@ def test_get_all__non_admin_permissions_filter( if expected_ids is not None: assert {s.id for s in all_studies} == expected_ids + # test pagination + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all(study_filter=study_filter, pagination=StudyPagination(page_nb=1, page_size=2)) + assert len(all_studies) == max(0, min(len(expected_ids) - 2, 2)) + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + @pytest.mark.parametrize( "is_admin, study_groups, expected_ids", @@ -972,6 +1088,12 @@ def test_get_all__admin_permissions_filter( if expected_ids is not None: assert {s.id for s in all_studies} == expected_ids + # test pagination + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all(study_filter=study_filter, pagination=StudyPagination(page_nb=1, page_size=2)) + assert len(all_studies) == max(0, min(len(expected_ids) - 2, 2)) + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + def test_update_tags( db_session: Session, @@ -1004,3 +1126,71 @@ def test_update_tags( # Check that only "Tag1" and "Tag3" are present in the database tags = db_session.query(Tag).all() assert {tag.label for tag in tags} == {"Tag1", "Tag3"} + + +@pytest.mark.parametrize( + "managed, study_ids, exists, expected_ids", + [ + (None, [], False, {"5", "6"}), + (None, [], True, {"1", "2", "3", "4", "7", "8"}), + (None, [], None, {"1", "2", "3", "4", "5", "6", "7", "8"}), + (None, [1, 3, 5, 7], False, {"5"}), + (None, [1, 3, 5, 7], True, {"1", "3", "7"}), + (None, [1, 3, 5, 7], None, {"1", "3", "5", "7"}), + (True, [], False, {"5"}), + (True, [], True, {"1", "2", "3", "4", "8"}), + (True, [], None, {"1", "2", "3", "4", "5", "8"}), + (True, [1, 3, 5, 7], False, {"5"}), + (True, [1, 3, 5, 7], True, {"1", "3"}), + (True, [1, 3, 5, 7], None, {"1", "3", "5"}), + (True, [2, 4, 6, 8], True, {"2", "4", "8"}), + (True, [2, 4, 6, 8], None, {"2", "4", "8"}), + (False, [], False, {"6"}), + (False, [], True, {"7"}), + (False, [], None, {"6", "7"}), + (False, [1, 3, 5, 7], False, set()), + (False, [1, 3, 5, 7], True, {"7"}), + (False, [1, 3, 5, 7], None, {"7"}), + ], +) +def test_count_studies__general_case( + db_session: Session, + managed: t.Union[bool, None], + study_ids: t.Sequence[str], + exists: t.Union[bool, None], + expected_ids: t.Set[str], +) -> None: + test_workspace = "test-repository" + icache: Mock = Mock(spec=ICache) + repository = StudyMetadataRepository(cache_service=icache, session=db_session) + + study_1 = VariantStudy(id=1) + study_2 = VariantStudy(id=2) + study_3 = VariantStudy(id=3) + study_4 = VariantStudy(id=4) + study_5 = RawStudy(id=5, missing=datetime.datetime.now(), workspace=DEFAULT_WORKSPACE_NAME) + study_6 = RawStudy(id=6, missing=datetime.datetime.now(), workspace=test_workspace) + study_7 = RawStudy(id=7, missing=None, workspace=test_workspace) + study_8 = RawStudy(id=8, missing=None, workspace=DEFAULT_WORKSPACE_NAME) + + db_session.add_all([study_1, study_2, study_3, study_4, study_5, study_6, study_7, study_8]) + db_session.commit() + + # use the db recorder to check that: + # 1- retrieving all studies requires only 1 query + # 2- accessing studies attributes does not require additional queries to db + # 3- having an exact total of queries equals to 1 + with DBStatementRecorder(db_session.bind) as db_recorder: + count = repository.count_studies( + study_filter=StudyFilter( + managed=managed, + study_ids=study_ids, + exists=exists, + access_permissions=AccessPermissions(is_admin=True), + ) + ) + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + + # test that the expected studies are returned + if expected_ids is not None: + assert count == len(expected_ids) From c491baf4bda68c09285ef15727e05099e1c1ee42 Mon Sep 17 00:00:00 2001 From: Hatim Dinia Date: Wed, 28 Feb 2024 13:54:48 +0100 Subject: [PATCH 041/248] refactor(areas): remove areas UI form fields (#1955) --- .../business/areas/properties_management.py | 34 ------------------- tests/integration/test_integration.py | 9 ----- webapp/public/locales/en/main.json | 2 -- webapp/public/locales/fr/main.json | 2 -- .../Modelization/Areas/Properties/Fields.tsx | 18 ---------- 5 files changed, 65 deletions(-) diff --git a/antarest/study/business/areas/properties_management.py b/antarest/study/business/areas/properties_management.py index 1c18ca2b45..7b09a08cf4 100644 --- a/antarest/study/business/areas/properties_management.py +++ b/antarest/study/business/areas/properties_management.py @@ -13,28 +13,12 @@ AREA_PATH = "input/areas/{area}" THERMAL_PATH = "input/thermal/areas/{field}/{{area}}" -UI_PATH = f"{AREA_PATH}/ui/ui" OPTIMIZATION_PATH = f"{AREA_PATH}/optimization" NODAL_OPTIMIZATION_PATH = f"{OPTIMIZATION_PATH}/nodal optimization" FILTERING_PATH = f"{OPTIMIZATION_PATH}/filtering" # Keep the order FILTER_OPTIONS = ["hourly", "daily", "weekly", "monthly", "annual"] DEFAULT_FILTER_VALUE = FILTER_OPTIONS -DEFAULT_UI = { - "color_r": 230, - "color_g": 108, - "color_b": 44, -} - - -def encode_color(ui: Dict[str, Any]) -> str: - data = {**DEFAULT_UI, **ui} - return f"{data['color_r']},{data['color_g']},{data['color_b']}" - - -def decode_color(encoded_color: str, current_ui: Optional[Dict[str, int]]) -> Dict[str, Any]: - r, g, b = map(int, encoded_color.split(",")) - return {**(current_ui or {}), "color_r": r, "color_g": g, "color_b": b} def sort_filter_options(options: Iterable[str]) -> List[str]: @@ -60,9 +44,6 @@ class AdequacyPatchMode(EnumIgnoreCase): class PropertiesFormFields(FormFieldsBaseModel): - color: Optional[str] = Field(regex="^\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*$") - pos_x: Optional[float] - pos_y: Optional[float] energy_cost_unsupplied: Optional[float] energy_cost_spilled: Optional[float] non_dispatch_power: Optional[bool] @@ -89,21 +70,6 @@ def validation(cls, values: Dict[str, Any]) -> Dict[str, Any]: FIELDS_INFO: Dict[str, FieldInfo] = { - # `color` must be before `pos_x` and `pos_y`, because they are include in the `decode_color`'s return dict value - "color": { - "path": UI_PATH, - "encode": encode_color, - "decode": decode_color, - "default_value": encode_color(DEFAULT_UI), - }, - "pos_x": { - "path": f"{UI_PATH}/x", - "default_value": 0.0, - }, - "pos_y": { - "path": f"{UI_PATH}/y", - "default_value": 0.0, - }, "energy_cost_unsupplied": { "path": THERMAL_PATH.format(field="unserverdenergycost"), "default_value": 0.0, diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index ffa4d4a91d..1dc1b385ae 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -1095,9 +1095,6 @@ def test_area_management(client: TestClient, admin_access_token: str, study_id: res_properties_config_json["filterSynthesis"] = set(res_properties_config_json["filterSynthesis"]) res_properties_config_json["filterByYear"] = set(res_properties_config_json["filterByYear"]) assert res_properties_config_json == { - "color": "230,108,44", - "posX": 0.0, - "posY": 0.0, "energyCostUnsupplied": 0.0, "energyCostSpilled": 0.0, "nonDispatchPower": True, @@ -1112,9 +1109,6 @@ def test_area_management(client: TestClient, admin_access_token: str, study_id: f"/v1/studies/{study_id}/areas/area 1/properties/form", headers=admin_headers, json={ - "color": "123,108,96", - "posX": 3.4, - "posY": 9.0, "energyCostUnsupplied": 2.0, "energyCostSpilled": 4.0, "nonDispatchPower": False, @@ -1130,9 +1124,6 @@ def test_area_management(client: TestClient, admin_access_token: str, study_id: res_properties_config_json["filterSynthesis"] = set(res_properties_config_json["filterSynthesis"]) res_properties_config_json["filterByYear"] = set(res_properties_config_json["filterByYear"]) assert res_properties_config_json == { - "color": "123,108,96", - "posX": 3.4, - "posY": 9.0, "energyCostUnsupplied": 2.0, "energyCostSpilled": 4.0, "nonDispatchPower": False, diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 81b9935f03..d70a1fc810 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -373,8 +373,6 @@ "study.configuration.geographicTrimmingAreas": "Geographic Trimming (areas)", "study.configuration.geographicTrimmingLinks": "Geographic Trimming (links)", "study.modelization.properties": "Properties", - "study.modelization.properties.posX": "Position X", - "study.modelization.properties.posY": "Position Y", "study.modelization.properties.energyCost": "Energy cost (€/Wh)", "study.modelization.properties.unsupplied": "Unsupplied", "study.modelization.properties.spilled": "Spilled", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 0f1f06ad45..17492deeb5 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -373,8 +373,6 @@ "study.configuration.geographicTrimmingAreas": "Filtre géographique (zones)", "study.configuration.geographicTrimmingLinks": "Filtre géographique (liens)", "study.modelization.properties": "Propriétés", - "study.modelization.properties.posX": "Position X", - "study.modelization.properties.posY": "Position Y", "study.modelization.properties.energyCost": "Coût de l'énergie", "study.modelization.properties.unsupplied": "Non distribuée", "study.modelization.properties.spilled": "Non évacuée", diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/Fields.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/Fields.tsx index 2ec3ec8df9..01a98538be 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/Fields.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/Fields.tsx @@ -3,7 +3,6 @@ import { useOutletContext } from "react-router"; import { useMemo } from "react"; import SelectFE from "../../../../../../common/fieldEditors/SelectFE"; import Fieldset from "../../../../../../common/Fieldset"; -import ColorPickerFE from "../../../../../../common/fieldEditors/ColorPickerFE"; import SwitchFE from "../../../../../../common/fieldEditors/SwitchFE"; import NumberFE from "../../../../../../common/fieldEditors/NumberFE"; import { useFormContextPlus } from "../../../../../../common/Form"; @@ -27,23 +26,6 @@ function Fields() { return ( <> -
- - - -
Date: Mon, 4 Mar 2024 16:59:20 +0100 Subject: [PATCH 042/248] fix(api-study): check area duplicates on creation (#1964) --- antarest/core/exceptions.py | 8 ++++++++ antarest/study/business/area_management.py | 12 ++++++++++-- tests/integration/test_integration.py | 19 +++++++++++-------- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/antarest/core/exceptions.py b/antarest/core/exceptions.py index 9a2230c1d1..cada3a5f5d 100644 --- a/antarest/core/exceptions.py +++ b/antarest/core/exceptions.py @@ -253,6 +253,14 @@ def __init__(self, *area_ids: str) -> None: super().__init__(HTTPStatus.NOT_FOUND, msg) +class DuplicateAreaName(HTTPException): + """Exception raised when trying to create an area with an already existing name.""" + + def __init__(self, area_name: str) -> None: + msg = f"Area '{area_name}' already exists and could not be created" + super().__init__(HTTPStatus.CONFLICT, msg) + + class DistrictNotFound(HTTPException): def __init__(self, *district_ids: str) -> None: count = len(district_ids) diff --git a/antarest/study/business/area_management.py b/antarest/study/business/area_management.py index 369b5a96ad..544f18d8cf 100644 --- a/antarest/study/business/area_management.py +++ b/antarest/study/business/area_management.py @@ -5,7 +5,7 @@ from pydantic import BaseModel -from antarest.core.exceptions import LayerNotAllowedToBeDeleted, LayerNotFound +from antarest.core.exceptions import DuplicateAreaName, LayerNotAllowedToBeDeleted, LayerNotFound from antarest.study.business.utils import execute_or_add_commands from antarest.study.model import Patch, PatchArea, PatchCluster, RawStudy, Study from antarest.study.repository import StudyMetadataRepository @@ -318,12 +318,20 @@ def remove_layer(self, study: RawStudy, layer_id: str) -> None: def create_area(self, study: Study, area_creation_info: AreaCreationDTO) -> AreaInfoDTO: file_study = self.storage_service.get_storage(study).get_raw(study) + + # check if area already exists + area_id = transform_name_to_id(area_creation_info.name) + if area_id in set(file_study.config.areas): + raise DuplicateAreaName(area_creation_info.name) + + # Create area and apply changes in the study command = CreateArea( area_name=area_creation_info.name, command_context=self.storage_service.variant_study_service.command_factory.command_context, ) execute_or_add_commands(study, file_study, [command], self.storage_service) - area_id = transform_name_to_id(area_creation_info.name) + + # Update metadata patch = self.patch_service.get(study) patch.areas = patch.areas or {} patch.areas[area_id] = area_creation_info.metadata or PatchArea() diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index 1dc1b385ae..c927e576af 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -9,7 +9,7 @@ from antarest.core.model import PublicMode from antarest.launcher.model import LauncherLoadDTO from antarest.study.business.adequacy_patch_management import PriceTakingOrder -from antarest.study.business.area_management import AreaType, LayerInfoDTO +from antarest.study.business.area_management import LayerInfoDTO from antarest.study.business.areas.properties_management import AdequacyPatchMode from antarest.study.business.areas.renewable_management import TimeSeriesInterpretation from antarest.study.business.general_management import Mode @@ -420,23 +420,26 @@ def test_area_management(client: TestClient, admin_access_token: str, study_id: headers=admin_headers, json={ "name": "area 1", - "type": AreaType.AREA.value, + "type": "AREA", "metadata": {"country": "FR", "tags": ["a"]}, }, ) + assert res.status_code == 200, res.json() + + # Test area creation with duplicate name res = client.post( f"/v1/studies/{study_id}/areas", headers=admin_headers, json={ - "name": "area 1", - "type": AreaType.AREA.value, + "name": "Area 1", # Same name but with different case + "type": "AREA", "metadata": {"country": "FR"}, }, ) - assert res.status_code == 500 + assert res.status_code == 409, res.json() assert res.json() == { - "description": "Area 'area 1' already exists and could not be created", - "exception": "CommandApplicationError", + "description": "Area 'Area 1' already exists and could not be created", + "exception": "DuplicateAreaName", } client.post( @@ -444,7 +447,7 @@ def test_area_management(client: TestClient, admin_access_token: str, study_id: headers=admin_headers, json={ "name": "area 2", - "type": AreaType.AREA.value, + "type": "AREA", "metadata": {"country": "DE"}, }, ) From f7f082a0e836c81d0791fbd316eeb324a1d99088 Mon Sep 17 00:00:00 2001 From: MartinBelthle <102529366+MartinBelthle@users.noreply.github.com> Date: Tue, 5 Mar 2024 18:03:01 +0100 Subject: [PATCH 043/248] fix(comments): use a command to update comments on a variant (#1959) Co-authored-by: Laurent LAPORTE --- antarest/study/service.py | 34 ++++----- antarest/study/storage/storage_service.py | 11 +-- .../model/command/update_raw_file.py | 9 +++ .../variant_blueprint/test_variant_manager.py | 72 ++++++++++++++++--- 4 files changed, 87 insertions(+), 39 deletions(-) diff --git a/antarest/study/service.py b/antarest/study/service.py index dc3288b4e2..c13f755aad 100644 --- a/antarest/study/service.py +++ b/antarest/study/service.py @@ -84,7 +84,6 @@ MatrixIndex, PatchArea, PatchCluster, - PatchStudy, RawStudy, Study, StudyAdditionalData, @@ -121,6 +120,7 @@ upgrade_study, ) from antarest.study.storage.utils import assert_permission, get_start_date, is_managed, remove_from_cache +from antarest.study.storage.variantstudy.business.utils import transform_command_to_dto from antarest.study.storage.variantstudy.model.command.icommand import ICommand from antarest.study.storage.variantstudy.model.command.replace_matrix import ReplaceMatrix from antarest.study.storage.variantstudy.model.command.update_comments import UpdateComments @@ -395,17 +395,7 @@ def get_comments(self, study_id: str, params: RequestParameters) -> t.Union[str, study = self.get_study(study_id) assert_permission(params.user, study, StudyPermissionType.READ) - output: t.Union[str, JSON] - raw_study_service = self.storage_service.raw_study_service - variant_study_service = self.storage_service.variant_study_service - if isinstance(study, RawStudy): - output = raw_study_service.get(metadata=study, url="/settings/comments") - elif isinstance(study, VariantStudy): - patch = raw_study_service.patch_service.get(study) - patch_study = PatchStudy() if patch.study is None else patch.study - output = patch_study.comments or variant_study_service.get(metadata=study, url="/settings/comments") - else: - raise StudyTypeUnsupported(study.id, study.type) + output = self.storage_service.get_storage(study).get(metadata=study, url="/settings/comments") with contextlib.suppress(AttributeError, UnicodeDecodeError): output = output.decode("utf-8") # type: ignore @@ -440,14 +430,20 @@ def edit_comments( new=bytes(data.comments, "utf-8"), params=params, ) - elif isinstance(study, VariantStudy): - patch = self.storage_service.raw_study_service.patch_service.get(study) - patch_study = patch.study or PatchStudy() - patch_study.comments = data.comments - patch.study = patch_study - self.storage_service.raw_study_service.patch_service.save(study, patch) else: - raise StudyTypeUnsupported(study.id, study.type) + variant_study_service = self.storage_service.variant_study_service + command = [ + UpdateRawFile( + target="settings/comments", + b64Data=base64.b64encode(data.comments.encode("utf-8")).decode("utf-8"), + command_context=variant_study_service.command_factory.command_context, + ) + ] + variant_study_service.append_commands( + study.id, + transform_command_to_dto(command, force_aggregate=True), + RequestParameters(user=params.user), + ) def get_studies_information( self, diff --git a/antarest/study/storage/storage_service.py b/antarest/study/storage/storage_service.py index affe97eae1..599e948948 100644 --- a/antarest/study/storage/storage_service.py +++ b/antarest/study/storage/storage_service.py @@ -5,7 +5,6 @@ from typing import Union -from antarest.core.exceptions import StudyTypeUnsupported from antarest.study.common.studystorage import IStudyStorageService from antarest.study.model import RawStudy, Study from antarest.study.storage.rawstudy.raw_study_service import RawStudyService @@ -49,13 +48,5 @@ def get_storage(self, study: Study) -> IStudyStorageService[Union[RawStudy, Vari Returns: The study storage service associated with the study type. - - Raises: - StudyTypeUnsupported: If the study type is not supported by the available storage services. """ - if isinstance(study, RawStudy): - return self.raw_study_service - elif isinstance(study, VariantStudy): - return self.variant_study_service - else: - raise StudyTypeUnsupported(study.id, study.type) + return self.raw_study_service if isinstance(study, RawStudy) else self.variant_study_service diff --git a/antarest/study/storage/variantstudy/model/command/update_raw_file.py b/antarest/study/storage/variantstudy/model/command/update_raw_file.py index c4b6cfb46b..3e7b3b8759 100644 --- a/antarest/study/storage/variantstudy/model/command/update_raw_file.py +++ b/antarest/study/storage/variantstudy/model/command/update_raw_file.py @@ -26,6 +26,15 @@ class UpdateRawFile(ICommand): target: str b64Data: str + def __repr__(self) -> str: + cls = self.__class__.__name__ + target = self.target + try: + data = base64.decodebytes(self.b64Data.encode("utf-8")).decode("utf-8") + return f"{cls}(target={target!r}, data={data!r})" + except (ValueError, TypeError): + return f"{cls}(target={target!r}, b64Data={self.b64Data!r})" + def _apply_config(self, study_data: FileStudyTreeConfig) -> Tuple[CommandOutput, Dict[str, Any]]: return CommandOutput(status=True, message="ok"), {} diff --git a/tests/integration/variant_blueprint/test_variant_manager.py b/tests/integration/variant_blueprint/test_variant_manager.py index 5af256dbbe..df3cf590e4 100644 --- a/tests/integration/variant_blueprint/test_variant_manager.py +++ b/tests/integration/variant_blueprint/test_variant_manager.py @@ -1,21 +1,45 @@ import logging +import typing as t +import pytest from starlette.testclient import TestClient from antarest.core.tasks.model import TaskDTO, TaskStatus -def test_variant_manager(client: TestClient, admin_access_token: str, study_id: str, caplog) -> None: +@pytest.fixture(name="base_study_id") +def base_study_id_fixture(client: TestClient, admin_access_token: str, caplog: t.Any) -> str: + """Create a base study and return its ID.""" + admin_headers = {"Authorization": f"Bearer {admin_access_token}"} with caplog.at_level(level=logging.WARNING): - admin_headers = {"Authorization": f"Bearer {admin_access_token}"} - - base_study_res = client.post("/v1/studies?name=foo", headers=admin_headers) + res = client.post("/v1/studies?name=Base1", headers=admin_headers) + return t.cast(str, res.json()) + + +@pytest.fixture(name="variant_id") +def variant_id_fixture( + client: TestClient, + admin_access_token: str, + base_study_id: str, + caplog: t.Any, +) -> str: + """Create a variant and return its ID.""" + admin_headers = {"Authorization": f"Bearer {admin_access_token}"} + with caplog.at_level(level=logging.WARNING): + res = client.post(f"/v1/studies/{base_study_id}/variants?name=Variant1", headers=admin_headers) + return t.cast(str, res.json()) - base_study_id = base_study_res.json() - res = client.post(f"/v1/studies/{base_study_id}/variants?name=foo", headers=admin_headers) - variant_id = res.json() +def test_variant_manager( + client: TestClient, + admin_access_token: str, + base_study_id: str, + variant_id: str, + caplog: t.Any, +) -> None: + admin_headers = {"Authorization": f"Bearer {admin_access_token}"} + with caplog.at_level(level=logging.WARNING): client.post(f"/v1/launcher/run/{variant_id}", headers=admin_headers) res = client.get(f"v1/studies/{variant_id}/synthesis", headers=admin_headers) @@ -26,9 +50,9 @@ def test_variant_manager(client: TestClient, admin_access_token: str, study_id: client.post(f"/v1/studies/{variant_id}/variants?name=baz", headers=admin_headers) res = client.get(f"/v1/studies/{base_study_id}/variants", headers=admin_headers) children = res.json() - assert children["node"]["name"] == "foo" + assert children["node"]["name"] == "Base1" assert len(children["children"]) == 1 - assert children["children"][0]["node"]["name"] == "foo" + 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" @@ -169,7 +193,7 @@ def test_variant_manager(client: TestClient, admin_access_token: str, study_id: res = client.post(f"/v1/studies/{variant_id}/freeze?name=bar", headers=admin_headers) assert res.status_code == 500 - new_study_id = "newid" + new_study_id = "new_id" res = client.get(f"/v1/studies/{new_study_id}", headers=admin_headers) assert res.status_code == 404 @@ -186,3 +210,31 @@ def test_variant_manager(client: TestClient, admin_access_token: str, study_id: res = client.get(f"/v1/studies/{variant_id}", headers=admin_headers) assert res.status_code == 404 + + +def test_comments(client: TestClient, admin_access_token: str, variant_id: str) -> None: + admin_headers = {"Authorization": f"Bearer {admin_access_token}"} + + # Put comments + comment = "updated comment" + res = client.put(f"/v1/studies/{variant_id}/comments", json={"comments": comment}, headers=admin_headers) + assert res.status_code == 204 + + # Asserts comments are updated + res = client.get(f"/v1/studies/{variant_id}/comments", headers=admin_headers) + assert res.json() == comment + + # Generates the study + res = client.put(f"/v1/studies/{variant_id}/generate?denormalize=false&from_scratch=true", headers=admin_headers) + task_id = res.json() + # Wait for task completion + res = client.get(f"/v1/tasks/{task_id}", headers=admin_headers, params={"wait_for_completion": True}) + assert res.status_code == 200 + task_result = TaskDTO.parse_obj(res.json()) + assert task_result.status == TaskStatus.COMPLETED + assert task_result.result is not None + assert task_result.result.success + + # Asserts comments did not disappear + res = client.get(f"/v1/studies/{variant_id}/comments", headers=admin_headers) + assert res.json() == comment From c9689648a9a71bf5b14a7d03d09f324193b9fd91 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Fri, 8 Mar 2024 10:35:10 +0100 Subject: [PATCH 044/248] refactor(eslint): add new rules and fix new errors --- webapp/.eslintrc.cjs | 8 ++ webapp/package-lock.json | 120 +++++++++++++++++- webapp/package.json | 1 + .../App/Data/DatasetCreationDialog.tsx | 10 +- .../src/components/App/Data/MatrixDialog.tsx | 6 +- .../Singlestudy/explore/Debug/Data/Json.tsx | 8 +- .../explore/Results/ResultDetails/index.tsx | 8 +- .../Candidates/CreateCandidateDialog.tsx | 4 +- .../ExportModal/ExportFilter/index.tsx | 4 +- .../App/Studies/ExportModal/index.tsx | 5 +- webapp/src/components/common/LogModal.tsx | 6 +- webapp/src/hooks/useMemoLocked.ts | 1 + 12 files changed, 154 insertions(+), 27 deletions(-) diff --git a/webapp/.eslintrc.cjs b/webapp/.eslintrc.cjs index bd9d45c683..74a900fee9 100644 --- a/webapp/.eslintrc.cjs +++ b/webapp/.eslintrc.cjs @@ -11,12 +11,14 @@ module.exports = { "plugin:react/recommended", "plugin:react/jsx-runtime", "plugin:react-hooks/recommended", + "plugin:jsdoc/recommended-typescript", "plugin:prettier/recommended", ], plugins: ["react-refresh"], ignorePatterns: ["dist", ".eslintrc.cjs"], parser: "@typescript-eslint/parser", parserOptions: { + // `ecmaVersion` is automatically sets by `esXXXX` in `env` sourceType: "module", project: ["./tsconfig.json", "./tsconfig.node.json"], tsconfigRootDir: __dirname, @@ -41,6 +43,10 @@ module.exports = { ], }, ], + curly: "error", + "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-param-reassign": [ "error", { @@ -65,6 +71,8 @@ module.exports = { "warn", { allowConstantExport: true }, ], + "react/hook-use-state": "error", "react/prop-types": "off", + "react/self-closing-comp": "error", }, }; diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 2c81f56aa3..3baefa00f7 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -92,6 +92,7 @@ "@vitejs/plugin-react-swc": "3.5.0", "eslint": "8.55.0", "eslint-config-prettier": "9.0.0", + "eslint-plugin-jsdoc": "48.2.0", "eslint-plugin-prettier": "5.0.0", "eslint-plugin-react": "7.33.2", "eslint-plugin-react-hooks": "4.6.0", @@ -479,6 +480,20 @@ "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz", "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" }, + "node_modules/@es-joy/jsdoccomment": { + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.42.0.tgz", + "integrity": "sha512-R1w57YlVA6+YE01wch3GPYn6bCsrOV3YW/5oGGE2tmX6JcL9Nr+b5IikrjMPF+v9CV3ay+obImEdsDhovhJrzw==", + "dev": true, + "dependencies": { + "comment-parser": "1.4.1", + "esquery": "^1.5.0", + "jsdoc-type-pratt-parser": "~4.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.19.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", @@ -3951,6 +3966,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/are-docs-informative": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", + "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", + "dev": true, + "engines": { + "node": ">=14" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4287,6 +4311,18 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/call-bind": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", @@ -4501,6 +4537,15 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, + "node_modules/comment-parser": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", + "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", + "dev": true, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5578,6 +5623,29 @@ "eslint": ">=7.0.0" } }, + "node_modules/eslint-plugin-jsdoc": { + "version": "48.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-48.2.0.tgz", + "integrity": "sha512-O2B1XLBJnUCRkggFzUQ+PBYJDit8iAgXdlu8ucolqGrbmOWPvttZQZX8d1sC0MbqDMSLs8SHSQxaNPRY1RQREg==", + "dev": true, + "dependencies": { + "@es-joy/jsdoccomment": "~0.42.0", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.1", + "debug": "^4.3.4", + "escape-string-regexp": "^4.0.0", + "esquery": "^1.5.0", + "is-builtin-module": "^3.2.1", + "semver": "^7.6.0", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, "node_modules/eslint-plugin-prettier": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.0.0.tgz", @@ -7180,6 +7248,21 @@ "resolved": "https://registry.npmjs.org/is-browser/-/is-browser-2.1.0.tgz", "integrity": "sha512-F5rTJxDQ2sW81fcfOR1GnCXT6sVJC104fCyfj+mjpwNEwaPYSn5fte5jiHmBg3DHsIoL/l8Kvw5VN5SsTRcRFQ==" }, + "node_modules/is-builtin-module": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", + "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", + "dev": true, + "dependencies": { + "builtin-modules": "^3.3.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -7609,6 +7692,15 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.0.0.tgz", + "integrity": "sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -9999,9 +10091,9 @@ } }, "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", "dependencies": { "lru-cache": "^6.0.0" }, @@ -10211,6 +10303,28 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.17", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.17.tgz", + "integrity": "sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg==", + "dev": true + }, "node_modules/split.js": { "version": "1.6.5", "resolved": "https://registry.npmjs.org/split.js/-/split.js-1.6.5.tgz", diff --git a/webapp/package.json b/webapp/package.json index e21bd5dd7e..4a4c3e5645 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -95,6 +95,7 @@ "@vitejs/plugin-react-swc": "3.5.0", "eslint": "8.55.0", "eslint-config-prettier": "9.0.0", + "eslint-plugin-jsdoc": "48.2.0", "eslint-plugin-prettier": "5.0.0", "eslint-plugin-react": "7.33.2", "eslint-plugin-react-hooks": "4.6.0", diff --git a/webapp/src/components/App/Data/DatasetCreationDialog.tsx b/webapp/src/components/App/Data/DatasetCreationDialog.tsx index 41304025a2..c10b1eaaf4 100644 --- a/webapp/src/components/App/Data/DatasetCreationDialog.tsx +++ b/webapp/src/components/App/Data/DatasetCreationDialog.tsx @@ -50,9 +50,9 @@ function DatasetCreationDialog(props: PropTypes) { const [name, setName] = useState(""); const [isJson, setIsJson] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); - const [currentFile, setFile] = useState(); + const [currentFile, setCurrentFile] = useState(); const [importing, setImporting] = useState(false); - const [publicStatus, setPublic] = useState(false); + const [publicStatus, setPublicStatus] = useState(false); const onSave = async () => { let closeModal = true; @@ -93,7 +93,7 @@ function DatasetCreationDialog(props: PropTypes) { const onUpload = (e: ChangeEvent) => { const { target } = e; if (target && target.files && target.files.length === 1) { - setFile(target.files[0]); + setCurrentFile(target.files[0]); } }; @@ -116,7 +116,7 @@ function DatasetCreationDialog(props: PropTypes) { if (data) { setSelectedGroupList(data.groups); - setPublic(data.public); + setPublicStatus(data.public); setName(data.name); } } catch (e) { @@ -249,7 +249,7 @@ function DatasetCreationDialog(props: PropTypes) { {t("global.public")} setPublic(!publicStatus)} + onChange={() => setPublicStatus(!publicStatus)} inputProps={{ "aria-label": "primary checkbox" }} /> diff --git a/webapp/src/components/App/Data/MatrixDialog.tsx b/webapp/src/components/App/Data/MatrixDialog.tsx index ddd7a59b85..df2b14ab99 100644 --- a/webapp/src/components/App/Data/MatrixDialog.tsx +++ b/webapp/src/components/App/Data/MatrixDialog.tsx @@ -17,7 +17,7 @@ function MatrixDialog(props: PropTypes) { const [t] = useTranslation(); const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); const [loading, setLoading] = useState(false); - const [matrix, setCurrentMatrix] = useState({ + const [matrix, setMatrix] = useState({ index: [], columns: [], data: [], @@ -34,7 +34,7 @@ function MatrixDialog(props: PropTypes) { columns: matrix ? res.columns : [], data: matrix ? res.data : [], }; - setCurrentMatrix(matrixContent); + setMatrix(matrixContent); } } catch (error) { enqueueErrorSnackbar(t("data.error.matrix"), error as AxiosError); @@ -44,7 +44,7 @@ function MatrixDialog(props: PropTypes) { }; init(); return () => { - setCurrentMatrix({ index: [], columns: [], data: [] }); + setMatrix({ index: [], columns: [], data: [] }); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [enqueueErrorSnackbar, matrixInfo, t]); diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Json.tsx b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Json.tsx index 334fa84638..3923ce319a 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Json.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Json.tsx @@ -22,7 +22,7 @@ function Json({ path, studyId }: Props) { const { enqueueSnackbar } = useSnackbar(); const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); const [jsonData, setJsonData] = useState(null); - const [isSaveAllowed, setSaveAllowed] = useState(false); + const [isSaveAllowed, setIsSaveAllowed] = useState(false); const res = usePromiseWithSnackbarError( () => getStudyData(studyId, path, -1), @@ -34,7 +34,7 @@ function Json({ path, studyId }: Props) { // Reset save button when path changes useUpdateEffect(() => { - setSaveAllowed(false); + setIsSaveAllowed(false); }, [studyId, path]); //////////////////////////////////////////////////////////////// @@ -48,7 +48,7 @@ function Json({ path, studyId }: Props) { enqueueSnackbar(t("studies.success.saveData"), { variant: "success", }); - setSaveAllowed(false); + setIsSaveAllowed(false); } catch (e) { enqueueErrorSnackbar(t("studies.error.saveData"), e as AxiosError); } @@ -57,7 +57,7 @@ function Json({ path, studyId }: Props) { const handleJsonChange = (newJson: string) => { setJsonData(newJson); - setSaveAllowed(true); + setIsSaveAllowed(true); }; //////////////////////////////////////////////////////////////// diff --git a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx index e1c3748b73..425245f9db 100644 --- a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx @@ -62,7 +62,7 @@ function ResultDetails() { const { data: output } = outputRes; const [dataType, setDataType] = useState(DataType.General); - const [timestep, setTimeStep] = useState(Timestep.Hourly); + const [timestep, setTimestep] = useState(Timestep.Hourly); const [year, setYear] = useState(-1); const [itemType, setItemType] = useState(OutputItemType.Areas); const [selectedItemId, setSelectedItemId] = useState(""); @@ -151,7 +151,9 @@ function ResultDetails() { // !NOTE: Workaround to display the date in the correct format, to be replaced by a proper solution. const dateTimeFromIndex = useMemo(() => { - if (!matrixRes.data) return []; + if (!matrixRes.data) { + return []; + } // Annual format has a static string if (timestep === Timestep.Annual) { @@ -359,7 +361,7 @@ function ResultDetails() { size="small" variant="outlined" onChange={(event) => { - setTimeStep(event?.target.value as Timestep); + setTimestep(event?.target.value as Timestep); }} /> ), diff --git a/webapp/src/components/App/Singlestudy/explore/Xpansion/Candidates/CreateCandidateDialog.tsx b/webapp/src/components/App/Singlestudy/explore/Xpansion/Candidates/CreateCandidateDialog.tsx index 40a4acad23..18366d7fe2 100644 --- a/webapp/src/components/App/Singlestudy/explore/Xpansion/Candidates/CreateCandidateDialog.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Xpansion/Candidates/CreateCandidateDialog.tsx @@ -22,14 +22,14 @@ interface PropType { function CreateCandidateDialog(props: PropType) { const { open, links, onClose, onSave } = props; const [t] = useTranslation(); - const [isToggled, setToggle] = useState(true); + const [isToggled, setIsToggled] = useState(true); //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// const handleToggle = () => { - setToggle(!isToggled); + setIsToggled(!isToggled); }; const handleSubmit = (data: SubmitHandlerPlus) => { diff --git a/webapp/src/components/App/Studies/ExportModal/ExportFilter/index.tsx b/webapp/src/components/App/Studies/ExportModal/ExportFilter/index.tsx index e751898f5a..e7a7fc9e85 100644 --- a/webapp/src/components/App/Studies/ExportModal/ExportFilter/index.tsx +++ b/webapp/src/components/App/Studies/ExportModal/ExportFilter/index.tsx @@ -35,7 +35,7 @@ interface PropTypes { function ExportFilterModal(props: PropTypes) { const [t] = useTranslation(); const { output, synthesis, filter, setFilter } = props; - const [year, setCurrentYear] = useState([]); + const [year, setYear] = useState([]); const [byYear, setByYear] = useState<{ isByYear: boolean; nbYear: number }>({ isByYear: false, nbYear: -1, @@ -105,7 +105,7 @@ function ExportFilterModal(props: PropTypes) { }))} data={year.map((elm) => elm.toString())} setValue={(value: string[] | string) => - setCurrentYear((value as string[]).map((elm) => parseInt(elm, 10))) + setYear((value as string[]).map((elm) => parseInt(elm, 10))) } sx={{ width: "100%", mb: 2 }} required diff --git a/webapp/src/components/App/Studies/ExportModal/index.tsx b/webapp/src/components/App/Studies/ExportModal/index.tsx index 83e426358d..0be51dda04 100644 --- a/webapp/src/components/App/Studies/ExportModal/index.tsx +++ b/webapp/src/components/App/Studies/ExportModal/index.tsx @@ -62,7 +62,8 @@ export default function ExportModal(props: BasicDialogProps & Props) { const [optionSelection, setOptionSelection] = useState("exportWith"); const [outputList, setOutputList] = useState(); const [currentOutput, setCurrentOutput] = useState(); - const [synthesis, setStudySynthesis] = useState(); + const [studySynthesis, setStudySynthesis] = + useState(); const [filter, setFilter] = useState({ type: StudyOutputDownloadType.AREAS, level: StudyOutputDownloadLevelDTO.WEEKLY, @@ -206,7 +207,7 @@ export default function ExportModal(props: BasicDialogProps & Props) { ( diff --git a/webapp/src/components/common/LogModal.tsx b/webapp/src/components/common/LogModal.tsx index 8dbcdeccb3..74032b95be 100644 --- a/webapp/src/components/common/LogModal.tsx +++ b/webapp/src/components/common/LogModal.tsx @@ -35,7 +35,7 @@ function LogModal(props: Props) { const [logDetail, setLogDetail] = useState(content); const divRef = useRef(null); const logRef = useRef(null); - const [autoscroll, setAutoScroll] = useState(true); + const [autoScroll, setAutoScroll] = useState(true); const [t] = useTranslation(); const updateLog = useCallback( @@ -92,11 +92,11 @@ function LogModal(props: Props) { useEffect(() => { if (logRef.current) { - if (autoscroll) { + if (autoScroll) { scrollToEnd(); } } - }, [logDetail, autoscroll]); + }, [logDetail, autoScroll]); useEffect(() => { if (followLogs) { diff --git a/webapp/src/hooks/useMemoLocked.ts b/webapp/src/hooks/useMemoLocked.ts index 6af425bbc6..b6e03535f9 100644 --- a/webapp/src/hooks/useMemoLocked.ts +++ b/webapp/src/hooks/useMemoLocked.ts @@ -10,6 +10,7 @@ import { useState } from "react"; */ function useMemoLocked(factory: () => T): T { + // eslint-disable-next-line react/hook-use-state const [state] = useState(factory); return state; } From 7616429809fa9312699837e8cac6f50534aec370 Mon Sep 17 00:00:00 2001 From: Hatim Dinia Date: Fri, 8 Mar 2024 10:35:18 +0100 Subject: [PATCH 045/248] docs(ui): update JSDoc comments to resolve lint warnings --- .../App/Singlestudy/explore/Debug/utils.ts | 19 +++++++++-------- .../explore/Modelization/Areas/Hydro/utils.ts | 3 ++- .../Modelization/Areas/common/utils.ts | 18 +++++++++------- .../explore/Modelization/Map/utils.ts | 10 ++++++++- .../explore/TableModeList/utils.ts | 8 ++++++- .../common/GroupedDataTable/utils.ts | 18 ++++++++-------- .../src/components/common/SplitLayoutView.tsx | 8 ++++++- .../src/components/common/SplitView/index.tsx | 11 ++++++++-- webapp/src/hoc/reactHookFormSupport.tsx | 21 +++++++++++++++++-- webapp/src/hooks/useNavigateOnCondition.ts | 17 +++++++-------- webapp/src/services/utils/index.ts | 12 ++++++++--- webapp/src/utils/fnUtils.ts | 11 ++++++++-- webapp/src/utils/stringUtils.ts | 9 ++++++-- 13 files changed, 116 insertions(+), 49 deletions(-) diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/utils.ts b/webapp/src/components/App/Singlestudy/explore/Debug/utils.ts index e97f0e00a7..a6e3798806 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Debug/utils.ts @@ -22,9 +22,7 @@ export type TreeData = Record | string; // Utils //////////////////////////////////////////////////////////////// -/** - * Maps file types and folder to their corresponding icon components. - */ +//Maps file types and folder to their corresponding icon components. const iconByFileType: Record = { matrix: DatasetIcon, json: DataObjectIcon, @@ -34,8 +32,9 @@ const iconByFileType: Record = { /** * Gets the icon component for a given file type or folder. - * @param {FileType | "folder"} type - The type of the file or "folder". - * @returns {SvgIconComponent} The corresponding icon component. + * + * @param type - The type of the file or "folder". + * @returns The corresponding icon component. */ export const getFileIcon = (type: FileType | "folder"): SvgIconComponent => { return iconByFileType[type] || TextSnippetIcon; @@ -43,8 +42,9 @@ export const getFileIcon = (type: FileType | "folder"): SvgIconComponent => { /** * Determines the file type based on the tree data. - * @param {TreeData} treeData - The data of the tree item. - * @returns {FileType | "folder"} The determined file type or "folder". + * + * @param treeData - The data of the tree item. + * @returns The determined file type or "folder". */ export const determineFileType = (treeData: TreeData): FileType | "folder" => { if (typeof treeData === "string") { @@ -63,8 +63,9 @@ export const determineFileType = (treeData: TreeData): FileType | "folder" => { /** * Filters out specific keys from the tree data. - * @param {TreeData} data - The original tree data. - * @returns {TreeData} The filtered tree data. + * + * @param data - The original tree data. + * @returns The filtered tree data. */ export const filterTreeData = (data: TreeData): TreeData => { const excludedKeys = new Set(["Desktop", "study", "logs"]); diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/utils.ts index ed8457afe4..7b4878aa73 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/utils.ts @@ -210,7 +210,8 @@ export const MATRICES: Matrices = { /** * Generates an array of column names from 0 to 100, optionally with a suffix. - * @param columnSuffix The suffix to append to the column names. + * + * @param columnSuffix - The suffix to append to the column names. * @returns An array of strings representing column names from 0 to 100. */ function generateColumns(columnSuffix = ""): string[] { diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/utils.ts index 8528245c84..9231804f53 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/utils.ts @@ -20,17 +20,21 @@ export const saveField = R.curry( /** * Custom aggregation function summing the values of each row, - * to display enabled and installed capacity in the same cell. - * @param colHeader - the column header - * @param rows - the column rows to aggregate - * @returns a string with the sum of enabled and installed capacity. - * @example "100/200" - * @see https://www.material-react-table.com/docs/guides/aggregation-and-grouping#custom-aggregation-functions + * to display enabled and installed capacity in the same cell. This function is + * designed for use with Material React Table's custom aggregation feature, allowing + * the combination of enabled and installed capacities into a single cell. + * + * @returns A string representing the sum of enabled and installed capacity in the format "enabled/installed". + * @example + * Assuming an aggregation of rows where enabled capacities sum to 100 and installed capacities sum to 200 + * "100/200" + * + * @see https://www.material-react-table.com/docs/guides/aggregation-and-grouping#custom-aggregation-functions for more information on custom aggregation functions in Material React Table. */ export const capacityAggregationFn = < T extends ThermalClusterWithCapacity | RenewableClusterWithCapacity, >(): MRT_AggregationFn => { - return (colHeader, rows) => { + return (_colHeader, rows) => { const { enabledCapacitySum, installedCapacitySum } = rows.reduce( (acc, row) => { acc.enabledCapacitySum += row.original.enabledCapacity ?? 0; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/utils.ts index 742ebe152a..1117d461a5 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/utils.ts @@ -83,7 +83,15 @@ export const getTextColor = (bgColor: RGB): string => { //////////////////////////////////////////////////////////////// /** - * Sets the graph nodes from the nodes data + * Custom hook to compute and return nodes with adjusted positions based on the current layer and view settings. + * It adjusts node positions to ensure they are correctly positioned in the graph based on the current zoom level and layer. + * Additionally, it calculates the color for each node, supporting layer-specific color adjustments. + * + * @param nodes - Array of nodes to render. + * @param width - Width of the rendering area. + * @param height - Height of the rendering area. + * @param currentLayerId - The ID of the current layer, used to adjust node positions and colors. + * @returns Array of nodes with updated positions and colors for rendering. */ export function useRenderNodes( nodes: StudyMapNode[], diff --git a/webapp/src/components/App/Singlestudy/explore/TableModeList/utils.ts b/webapp/src/components/App/Singlestudy/explore/TableModeList/utils.ts index bcd307a954..0d8060c307 100644 --- a/webapp/src/components/App/Singlestudy/explore/TableModeList/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/TableModeList/utils.ts @@ -21,7 +21,13 @@ export interface TableTemplate { //////////////////////////////////////////////////////////////// /** - * Allows to check columns validity for specified type. + * Allows to check columns validity for specified type. Creates a table template with unique ID, name, type, and columns configuration. + * This function is intended to define the structure and type of data that a table can hold. + * + * @param name - The name of the table template. + * @param type - The type of the table, determining the allowed columns and their configuration based on the table mode type. + * @param columns - The configuration of columns specific to the table mode type. + * @returns A table template object including a unique ID, name, type, and columns configuration. */ export function createTableTemplate( name: string, diff --git a/webapp/src/components/common/GroupedDataTable/utils.ts b/webapp/src/components/common/GroupedDataTable/utils.ts index aad96a3784..df119f0fa8 100644 --- a/webapp/src/components/common/GroupedDataTable/utils.ts +++ b/webapp/src/components/common/GroupedDataTable/utils.ts @@ -20,9 +20,9 @@ export interface TRow { * If the base value is found in the list of existing values, it appends a number * in the format `(n)` to the base value, incrementing `n` until a unique value is found. * - * @param {string} baseValue - The original base value to check. - * @param {string[]} existingValues - The list of existing values to check against. - * @returns {string} A unique value. + * @param baseValue - The original base value to check for uniqueness. + * @param existingValues - The list of existing values to check against for duplicates. + * @returns A unique value derived from the base value by appending a number in parentheses, if necessary. */ export const generateNextValue = ( baseValue: string, @@ -54,14 +54,14 @@ export const generateNextValue = ( * based on the given original value and the existing values in tableData. * * If the property is "name", the function appends " - copy" to the original value. - * If the property is "id", the function uses nameToId to get the base value. + * If the property is "id", the function uses `nameToId` to get the base value. * - * This function leverages generateNextValue to ensure the uniqueness of the value. + * This function leverages `generateNextValue` to ensure the uniqueness of the value. * - * @param {"name" | "id"} property - The property for which the unique value is generated. - * @param {string} originalValue - The original value of the specified property. - * @param {TRow[]} tableData - The existing table data to check against. - * @returns {string} A unique value for the specified property. + * @param property - The property for which the unique value is generated, either "name" or "id". + * @param originalValue - The original value of the specified property. + * @param tableData - The existing table data to check against for ensuring uniqueness. + * @returns A unique value for the specified property. */ export const generateUniqueValue = ( property: "name" | "id", diff --git a/webapp/src/components/common/SplitLayoutView.tsx b/webapp/src/components/common/SplitLayoutView.tsx index 62c1209e24..5df41be676 100644 --- a/webapp/src/components/common/SplitLayoutView.tsx +++ b/webapp/src/components/common/SplitLayoutView.tsx @@ -8,7 +8,13 @@ interface Props { } /** - * @deprecated Use SplitView instead. + * Renders a split layout view with a fixed left column and a flexible right column. + * This component is deprecated and should be replaced with the `SplitView` component for enhanced functionality and flexibility. + * + * @deprecated Use `SplitView` instead for better layout management and customization options. + * + * @param props - The component props including `left` and `right` components to render in the split layout, and `sx` for styling. + * @returns A React component that displays a split layout with left and right sections. */ function SplitLayoutView(props: Props) { const { left, right, sx } = props; diff --git a/webapp/src/components/common/SplitView/index.tsx b/webapp/src/components/common/SplitView/index.tsx index 3e1249af91..0bb8f86da2 100644 --- a/webapp/src/components/common/SplitView/index.tsx +++ b/webapp/src/components/common/SplitView/index.tsx @@ -11,8 +11,8 @@ export interface SplitViewProps { } /** - * Renders a resizable split view layout. It can be configured - * for both horizontal and vertical directions. + * Renders a resizable split view layout, configurable for both horizontal and vertical directions. + * * @see {@link SplitViewProps} for the properties it accepts. * * @example @@ -20,6 +20,13 @@ export interface SplitViewProps { * * * + * + * @param props - The component props. + * @param props.children - Child components to be rendered within the split views. + * @param props.direction - The orientation of the split view ("horizontal" or "vertical"). + * @param props.sizes - Initial sizes of each view in percentages. The array must sum to 100 and match the number of children. + * @param props.gutterSize - The size of the gutter between split views. Defaults to 4. + * @returns A React component displaying a split layout view with resizable panes. */ function SplitView({ children, diff --git a/webapp/src/hoc/reactHookFormSupport.tsx b/webapp/src/hoc/reactHookFormSupport.tsx index 282ce5358a..a0aec8f2b2 100644 --- a/webapp/src/hoc/reactHookFormSupport.tsx +++ b/webapp/src/hoc/reactHookFormSupport.tsx @@ -67,19 +67,36 @@ export type ReactHookFormSupportProps< shouldUnregister?: never; }; +/** + * Provides React Hook Form support to a field editor component, enhancing it with form control and validation capabilities. + * It integrates custom validation logic, value transformation, and handles form submission state. + * + * @param options - Configuration options for the hook support. + * @param options.preValidate - A function that pre-validates the value before the main validation. + * @param options.setValueAs - A function that transforms the value before setting it into the form. + * @returns A function that takes a field editor component and returns a new component wrapped with React Hook Form functionality. + */ function reactHookFormSupport( options: ReactHookFormSupport = {}, ) { const { preValidate, setValueAs = R.identity } = options; /** - * Wrap in a higher component the specified field editor component + * Wraps the provided field editor component with React Hook Form functionality, + * applying the specified pre-validation and value transformation logic. + * + * @param FieldEditor - The field editor component to wrap. + * @returns The wrapped component with added React Hook Form support. */ function wrapWithReactHookFormSupport< TProps extends FieldEditorProps, >(FieldEditor: React.ComponentType) { /** - * The wrapper component + * The wrapper component that integrates React Hook Form capabilities with the original field editor. + * It manages form control registration, handles value changes and blurring with custom logic, and displays validation errors. + * + * @param props - The props of the field editor, extended with React Hook Form and custom options. + * @returns The field editor component wrapped with React Hook Form functionality. */ function ReactHookFormSupport< TFieldValues extends FieldValues = FieldValues, diff --git a/webapp/src/hooks/useNavigateOnCondition.ts b/webapp/src/hooks/useNavigateOnCondition.ts index 6930e1ecf6..036ce0e72f 100644 --- a/webapp/src/hooks/useNavigateOnCondition.ts +++ b/webapp/src/hooks/useNavigateOnCondition.ts @@ -23,18 +23,17 @@ interface UseNavigateOnConditionOptions { } /** - * A React hook for conditional navigation using react-router-dom. + * A React hook for conditional navigation using react-router-dom. This hook allows for navigating to a different route + * based on custom logic encapsulated in a `shouldNavigate` function. It observes specified dependencies and triggers navigation + * when they change if the conditions defined in `shouldNavigate` are met. * - * @function - * @name useNavigateOnCondition - * - * @param {Object} options - Configuration options for the hook. - * @param {DependencyList} options.deps - An array of dependencies that the effect will observe. - * @param {To} options.to - The target location to navigate to, it could be a route as a string or a relative numeric location. - * @param {function} [options.shouldNavigate] - An optional function that returns a boolean to determine whether navigation should take place. + * @param options - Configuration options for the hook. + * @param options.deps - An array of dependencies that the effect will observe. + * @param options.to - The target location to navigate to, which can be a route as a string or a relative numeric location. + * @param options.shouldNavigate - An optional function that returns a boolean to determine whether navigation should take place. Defaults to a function that always returns true. * * @example - * - Basic usage + * Basic usage * useNavigateOnCondition({ * deps: [someDependency], * to: '/some-route', diff --git a/webapp/src/services/utils/index.ts b/webapp/src/services/utils/index.ts index 0386ebef79..f7980ee2bd 100644 --- a/webapp/src/services/utils/index.ts +++ b/webapp/src/services/utils/index.ts @@ -155,7 +155,7 @@ export const exportText = (fileData: string, filename: string): void => { * * Ex: '820' -> '8.2' * - * @param v Version in format '[major][minor]0' (ex: '820'). + * @param v - Version in format '[major][minor]0' (ex: '820'). * @returns Version in format '[major].[minor]' (ex: '8.2'). */ export const displayVersionName = (v: string): string => `${v[0]}.${v[1]}`; @@ -248,7 +248,12 @@ export const sortByName = (list: T[]): T[] => { }; /** - * @deprecated This function is deprecated. Please use nameToId instead. + * Converts a name string to an ID format. + * + * @deprecated Please use `nameToId` instead. + * + * @param name - The string to transform. + * @returns The transformed ID string. */ export const transformNameToId = (name: string): string => { let duppl = false; @@ -290,7 +295,8 @@ export const transformNameToId = (name: string): string => { * Converts a name string to a valid ID string. * Replacing any characters that are not alphanumeric or -_,()& with a space, * trimming the resulting string, and converting it to lowercase. - * @param name The name string to convert to an ID. + * + * @param name - The name string to convert to an ID. * @returns The resulting ID string. */ export const nameToId = (name: string): string => { diff --git a/webapp/src/utils/fnUtils.ts b/webapp/src/utils/fnUtils.ts index d232d83246..226e58c836 100644 --- a/webapp/src/utils/fnUtils.ts +++ b/webapp/src/utils/fnUtils.ts @@ -1,6 +1,13 @@ /** - * Use it instead of disabling ESLint rule. + * A utility function designed to be used as a placeholder or stub. It can be used in situations where you might + * otherwise be tempted to disable an ESLint rule temporarily, such as when you need to pass a function that + * does nothing (for example, as a default prop in React components or as a no-operation callback). + * + * By using this function, you maintain code cleanliness and intention clarity without directly suppressing + * linting rules. + * + * @param args - Accepts any number of arguments of any type, but does nothing with them. */ export function voidFn(...args: TArgs) { - // Do nothing + // Intentionally empty, as its purpose is to do nothing. } diff --git a/webapp/src/utils/stringUtils.ts b/webapp/src/utils/stringUtils.ts index 1b83ea6b6d..823829fb29 100644 --- a/webapp/src/utils/stringUtils.ts +++ b/webapp/src/utils/stringUtils.ts @@ -11,9 +11,14 @@ export const isSearchMatching = R.curry( ); /** - * Formats a string with values. + * Formats a string by replacing placeholders with specified values. + * + * @param str - The string containing placeholders in the format `{placeholder}`. + * @param values - An object mapping placeholders to their replacement values. + * @returns The formatted string with all placeholders replaced by their corresponding values. + * * @example - * format("Hello {name}", { name: "John" }); // returns "Hello John" + * format("Hello {name}", { name: "John" }); // Returns: "Hello John" */ export function format(str: string, values: Record): string { return str.replace(/{([a-zA-Z0-9]+)}/g, (_, key) => values[key]); From fcdb1c9f2aab897cea293a2a8716ec356989f150 Mon Sep 17 00:00:00 2001 From: MartinBelthle <102529366+MartinBelthle@users.noreply.github.com> Date: Sat, 9 Mar 2024 14:14:40 +0100 Subject: [PATCH 046/248] feat(clusters): add new endpoint for clusters duplication (#1972) --- antarest/core/exceptions.py | 10 + .../business/areas/renewable_management.py | 82 +++++-- .../business/areas/st_storage_management.py | 89 +++++-- .../business/areas/thermal_management.py | 93 +++++-- antarest/study/web/study_data_blueprint.py | 50 ++++ .../study_data_blueprint/test_renewable.py | 232 +++++++++++++++++- .../study_data_blueprint/test_st_storage.py | 209 +++++++++++++++- .../study_data_blueprint/test_thermal.py | 228 ++++++++++++++++- 8 files changed, 923 insertions(+), 70 deletions(-) diff --git a/antarest/core/exceptions.py b/antarest/core/exceptions.py index cada3a5f5d..f521ffec63 100644 --- a/antarest/core/exceptions.py +++ b/antarest/core/exceptions.py @@ -312,3 +312,13 @@ def __init__(self, area_id: str) -> None: HTTPStatus.NOT_FOUND, f"Cluster configuration for area: '{area_id}' not found", ) + + +class ClusterAlreadyExists(HTTPException): + """Exception raised when attempting to create a cluster with an already existing ID.""" + + def __init__(self, cluster_type: str, cluster_id: str) -> None: + super().__init__( + HTTPStatus.CONFLICT, + f"{cluster_type} cluster with ID '{cluster_id}' already exists and could not be created.", + ) diff --git a/antarest/study/business/areas/renewable_management.py b/antarest/study/business/areas/renewable_management.py index ab9a2e9802..c4152924bf 100644 --- a/antarest/study/business/areas/renewable_management.py +++ b/antarest/study/business/areas/renewable_management.py @@ -3,10 +3,11 @@ from pydantic import validator -from antarest.core.exceptions import ClusterConfigNotFound, ClusterNotFound +from antarest.core.exceptions import ClusterAlreadyExists, ClusterConfigNotFound, ClusterNotFound from antarest.study.business.enum_ignore_case import EnumIgnoreCase from antarest.study.business.utils import AllOptionalMetaclass, camel_case_model, execute_or_add_commands from antarest.study.model import Study +from antarest.study.storage.rawstudy.model.filesystem.config.model import transform_name_to_id from antarest.study.storage.rawstudy.model.filesystem.config.renewable import ( RenewableConfig, RenewableConfigType, @@ -17,6 +18,7 @@ from antarest.study.storage.storage_service import StudyStorageService from antarest.study.storage.variantstudy.model.command.create_renewables_cluster import CreateRenewablesCluster from antarest.study.storage.variantstudy.model.command.remove_renewables_cluster import RemoveRenewablesCluster +from antarest.study.storage.variantstudy.model.command.replace_matrix import ReplaceMatrix from antarest.study.storage.variantstudy.model.command.update_config import UpdateConfig __all__ = ( @@ -47,7 +49,7 @@ class Config: def schema_extra(schema: t.MutableMapping[str, t.Any]) -> None: schema["example"] = RenewableClusterInput( group="Gas", - name="2 avail and must 1", + name="Gas Cluster XY", enabled=False, unitCount=100, nominalCapacity=1000.0, @@ -85,9 +87,9 @@ class Config: @staticmethod def schema_extra(schema: t.MutableMapping[str, t.Any]) -> None: schema["example"] = RenewableClusterOutput( - id="2 avail and must 1", + id="Gas cluster YZ", group="Gas", - name="2 avail and must 1", + name="Gas Cluster YZ", enabled=False, unitCount=100, nominalCapacity=1000.0, @@ -157,23 +159,25 @@ def create_cluster( The newly created cluster. """ file_study = self._get_file_study(study) - study_version = study.version - cluster = cluster_data.to_config(study_version) - - command = CreateRenewablesCluster( - area_id=area_id, - cluster_name=cluster.id, - parameters=cluster.dict(by_alias=True, exclude={"id"}), - command_context=self.storage_service.variant_study_service.command_factory.command_context, - ) + cluster = cluster_data.to_config(study.version) + command = self._make_create_cluster_cmd(area_id, cluster) execute_or_add_commands( study, file_study, [command], self.storage_service, ) + output = self.get_cluster(study, area_id, cluster.id) + return output - return self.get_cluster(study, area_id, cluster.id) + def _make_create_cluster_cmd(self, area_id: str, cluster: RenewableConfigType) -> CreateRenewablesCluster: + command = CreateRenewablesCluster( + area_id=area_id, + cluster_name=cluster.id, + parameters=cluster.dict(by_alias=True, exclude={"id"}), + command_context=self.storage_service.variant_study_service.command_factory.command_context, + ) + return command def get_cluster(self, study: Study, area_id: str, cluster_id: str) -> RenewableClusterOutput: """ @@ -273,3 +277,53 @@ def delete_clusters(self, study: Study, area_id: str, cluster_ids: t.Sequence[st ] execute_or_add_commands(study, file_study, commands, self.storage_service) + + def duplicate_cluster( + self, + study: Study, + area_id: str, + source_id: str, + new_cluster_name: str, + ) -> RenewableClusterOutput: + """ + Creates a duplicate cluster within the study area with a new name. + + Args: + study: The study in which the cluster will be duplicated. + area_id: The identifier of the area where the cluster will be duplicated. + source_id: The identifier of the cluster to be duplicated. + new_cluster_name: The new name for the duplicated cluster. + + Returns: + The duplicated cluster configuration. + + Raises: + ClusterAlreadyExists: If a cluster with the new name already exists in the area. + """ + new_id = transform_name_to_id(new_cluster_name, lower=False) + lower_new_id = new_id.lower() + if any(lower_new_id == cluster.id.lower() for cluster in self.get_clusters(study, area_id)): + raise ClusterAlreadyExists("Renewable", new_id) + + # Cluster duplication + current_cluster = self.get_cluster(study, area_id, source_id) + current_cluster.name = new_cluster_name + creation_form = RenewableClusterCreation(**current_cluster.dict(by_alias=False, exclude={"id"})) + new_config = creation_form.to_config(study.version) + create_cluster_cmd = self._make_create_cluster_cmd(area_id, new_config) + + # Matrix edition + lower_source_id = source_id.lower() + source_path = f"input/renewables/series/{area_id}/{lower_source_id}/series" + new_path = f"input/renewables/series/{area_id}/{lower_new_id}/series" + + # Prepare and execute commands + storage_service = self.storage_service.get_storage(study) + command_context = self.storage_service.variant_study_service.command_factory.command_context + current_matrix = storage_service.get(study, source_path)["data"] + replace_matrix_cmd = ReplaceMatrix(target=new_path, matrix=current_matrix, command_context=command_context) + commands = [create_cluster_cmd, replace_matrix_cmd] + + execute_or_add_commands(study, self._get_file_study(study), commands, self.storage_service) + + return RenewableClusterOutput(**new_config.dict(by_alias=False)) diff --git a/antarest/study/business/areas/st_storage_management.py b/antarest/study/business/areas/st_storage_management.py index d18dce9f9c..ca498c030a 100644 --- a/antarest/study/business/areas/st_storage_management.py +++ b/antarest/study/business/areas/st_storage_management.py @@ -8,12 +8,14 @@ from typing_extensions import Literal from antarest.core.exceptions import ( + ClusterAlreadyExists, STStorageConfigNotFoundError, STStorageFieldsNotFoundError, STStorageMatrixNotFoundError, ) from antarest.study.business.utils import AllOptionalMetaclass, camel_case_model, execute_or_add_commands from antarest.study.model import Study +from antarest.study.storage.rawstudy.model.filesystem.config.model import transform_name_to_id from antarest.study.storage.rawstudy.model.filesystem.config.st_storage import ( STStorageConfig, STStorageGroup, @@ -24,6 +26,7 @@ from antarest.study.storage.storage_service import StudyStorageService from antarest.study.storage.variantstudy.model.command.create_st_storage import CreateSTStorage from antarest.study.storage.variantstudy.model.command.remove_st_storage import RemoveSTStorage +from antarest.study.storage.variantstudy.model.command.replace_matrix import ReplaceMatrix from antarest.study.storage.variantstudy.model.command.update_config import UpdateConfig __all__ = ( @@ -72,8 +75,8 @@ def validate_name(cls, name: t.Optional[str]) -> str: raise ValueError("'name' must not be empty") return name - @property - def to_config(self) -> STStorageConfig: + # noinspection PyUnusedLocal + def to_config(self, study_version: t.Union[str, int]) -> STStorageConfig: values = self.dict(by_alias=False, exclude_none=True) return STStorageConfig(**values) @@ -203,7 +206,7 @@ def validate_rule_curve( upper_array = np.array(upper_rule_curve.data, dtype=np.float64) # noinspection PyUnresolvedReferences if (lower_array > upper_array).any(): - raise ValueError("Each 'lower_rule_curve' value must be lower" " or equal to each 'upper_rule_curve'") + raise ValueError("Each 'lower_rule_curve' value must be lower or equal to each 'upper_rule_curve'") return values @@ -257,21 +260,25 @@ def create_storage( Returns: The ID of the newly created short-term storage. """ - storage = form.to_config - command = CreateSTStorage( - area_id=area_id, - parameters=storage, - command_context=self.storage_service.variant_study_service.command_factory.command_context, - ) file_study = self._get_file_study(study) + storage = form.to_config(study.version) + command = self._make_create_cluster_cmd(area_id, storage) execute_or_add_commands( study, file_study, [command], self.storage_service, ) + output = self.get_storage(study, area_id, storage_id=storage.id) + return output - return self.get_storage(study, area_id, storage_id=storage.id) + def _make_create_cluster_cmd(self, area_id: str, cluster: STStorageConfig) -> CreateSTStorage: + command = CreateSTStorage( + area_id=area_id, + parameters=cluster, + command_context=self.storage_service.variant_study_service.command_factory.command_context, + ) + return command def get_storages( self, @@ -418,6 +425,59 @@ def delete_storages( file_study = self._get_file_study(study) execute_or_add_commands(study, file_study, [command], self.storage_service) + def duplicate_cluster(self, study: Study, area_id: str, source_id: str, new_cluster_name: str) -> STStorageOutput: + """ + Creates a duplicate cluster within the study area with a new name. + + Args: + study: The study in which the cluster will be duplicated. + area_id: The identifier of the area where the cluster will be duplicated. + source_id: The identifier of the cluster to be duplicated. + new_cluster_name: The new name for the duplicated cluster. + + Returns: + The duplicated cluster configuration. + + Raises: + ClusterAlreadyExists: If a cluster with the new name already exists in the area. + """ + new_id = transform_name_to_id(new_cluster_name) + lower_new_id = new_id.lower() + if any(lower_new_id == storage.id.lower() for storage in self.get_storages(study, area_id)): + raise ClusterAlreadyExists("Short-term storage", new_id) + + # Cluster duplication + current_cluster = self.get_storage(study, area_id, source_id) + current_cluster.name = new_cluster_name + creation_form = STStorageCreation(**current_cluster.dict(by_alias=False, exclude={"id"})) + new_config = creation_form.to_config(study.version) + create_cluster_cmd = self._make_create_cluster_cmd(area_id, new_config) + + # Matrix edition + lower_source_id = source_id.lower() + ts_names = ["pmax_injection", "pmax_withdrawal", "lower_rule_curve", "upper_rule_curve", "inflows"] + source_paths = [ + STORAGE_SERIES_PATH.format(area_id=area_id, storage_id=lower_source_id, ts_name=ts_name) + for ts_name in ts_names + ] + new_paths = [ + STORAGE_SERIES_PATH.format(area_id=area_id, storage_id=lower_new_id, ts_name=ts_name) + for ts_name in ts_names + ] + + # Prepare and execute commands + commands: t.List[t.Union[CreateSTStorage, ReplaceMatrix]] = [create_cluster_cmd] + storage_service = self.storage_service.get_storage(study) + command_context = self.storage_service.variant_study_service.command_factory.command_context + for source_path, new_path in zip(source_paths, new_paths): + current_matrix = storage_service.get(study, source_path)["data"] + command = ReplaceMatrix(target=new_path, matrix=current_matrix, command_context=command_context) + commands.append(command) + + execute_or_add_commands(study, self._get_file_study(study), commands, self.storage_service) + + return STStorageOutput(**new_config.dict(by_alias=False)) + def get_matrix( self, study: Study, @@ -484,12 +544,11 @@ def _save_matrix_obj( ts_name: STStorageTimeSeries, matrix_obj: t.Dict[str, t.Any], ) -> None: - file_study = self._get_file_study(study) path = STORAGE_SERIES_PATH.format(area_id=area_id, storage_id=storage_id, ts_name=ts_name) - try: - file_study.tree.save(matrix_obj, path.split("/")) - except KeyError: - raise STStorageMatrixNotFoundError(study.id, area_id, storage_id, ts_name) from None + matrix = matrix_obj["data"] + command_context = self.storage_service.variant_study_service.command_factory.command_context + command = ReplaceMatrix(target=path, matrix=matrix, command_context=command_context) + execute_or_add_commands(study, self._get_file_study(study), [command], self.storage_service) def validate_matrices( self, diff --git a/antarest/study/business/areas/thermal_management.py b/antarest/study/business/areas/thermal_management.py index dfcc52a2a0..f44ad7ba10 100644 --- a/antarest/study/business/areas/thermal_management.py +++ b/antarest/study/business/areas/thermal_management.py @@ -3,9 +3,10 @@ from pydantic import validator -from antarest.core.exceptions import ClusterConfigNotFound, ClusterNotFound +from antarest.core.exceptions import ClusterAlreadyExists, ClusterConfigNotFound, ClusterNotFound from antarest.study.business.utils import AllOptionalMetaclass, camel_case_model, execute_or_add_commands from antarest.study.model import Study +from antarest.study.storage.rawstudy.model.filesystem.config.model import transform_name_to_id from antarest.study.storage.rawstudy.model.filesystem.config.thermal import ( Thermal860Config, Thermal860Properties, @@ -16,6 +17,7 @@ from antarest.study.storage.storage_service import StudyStorageService from antarest.study.storage.variantstudy.model.command.create_cluster import CreateCluster from antarest.study.storage.variantstudy.model.command.remove_cluster import RemoveCluster +from antarest.study.storage.variantstudy.model.command.replace_matrix import ReplaceMatrix from antarest.study.storage.variantstudy.model.command.update_config import UpdateConfig __all__ = ( @@ -40,7 +42,7 @@ class Config: def schema_extra(schema: t.MutableMapping[str, t.Any]) -> None: schema["example"] = ThermalClusterInput( group="Gas", - name="2 avail and must 1", + name="Gas Cluster XY", enabled=False, unitCount=100, nominalCapacity=1000.0, @@ -79,9 +81,9 @@ class Config: @staticmethod def schema_extra(schema: t.MutableMapping[str, t.Any]) -> None: schema["example"] = ThermalClusterOutput( - id="2 avail and must 1", + id="Gas cluster YZ", group="Gas", - name="2 avail and must 1", + name="Gas Cluster YZ", enabled=False, unitCount=100, nominalCapacity=1000.0, @@ -190,16 +192,8 @@ def create_cluster(self, study: Study, area_id: str, cluster_data: ThermalCluste """ file_study = self._get_file_study(study) - study_version = study.version - cluster = cluster_data.to_config(study_version) - # NOTE: currently, in the `CreateCluster` class, there is a confusion - # between the cluster name and the cluster ID (which is a section name). - command = CreateCluster( - area_id=area_id, - cluster_name=cluster.id, - parameters=cluster.dict(by_alias=True, exclude={"id"}), - command_context=self.storage_service.variant_study_service.command_factory.command_context, - ) + cluster = cluster_data.to_config(study.version) + command = self._make_create_cluster_cmd(area_id, cluster) execute_or_add_commands( study, file_study, @@ -209,6 +203,17 @@ def create_cluster(self, study: Study, area_id: str, cluster_data: ThermalCluste output = self.get_cluster(study, area_id, cluster.id) return output + def _make_create_cluster_cmd(self, area_id: str, cluster: ThermalConfigType) -> CreateCluster: + # NOTE: currently, in the `CreateCluster` class, there is a confusion + # between the cluster name and the cluster ID (which is a section name). + command = CreateCluster( + area_id=area_id, + cluster_name=cluster.id, + parameters=cluster.dict(by_alias=True, exclude={"id"}), + command_context=self.storage_service.variant_study_service.command_factory.command_context, + ) + return command + def update_cluster( self, study: Study, @@ -286,3 +291,63 @@ def delete_clusters(self, study: Study, area_id: str, cluster_ids: t.Sequence[st ] execute_or_add_commands(study, file_study, commands, self.storage_service) + + def duplicate_cluster( + self, + study: Study, + area_id: str, + source_id: str, + new_cluster_name: str, + ) -> ThermalClusterOutput: + """ + Creates a duplicate cluster within the study area with a new name. + + Args: + study: The study in which the cluster will be duplicated. + area_id: The identifier of the area where the cluster will be duplicated. + source_id: The identifier of the cluster to be duplicated. + new_cluster_name: The new name for the duplicated cluster. + + Returns: + The duplicated cluster configuration. + + Raises: + ClusterAlreadyExists: If a cluster with the new name already exists in the area. + """ + new_id = transform_name_to_id(new_cluster_name, lower=False) + lower_new_id = new_id.lower() + if any(lower_new_id == cluster.id.lower() for cluster in self.get_clusters(study, area_id)): + raise ClusterAlreadyExists("Thermal", new_id) + + # Cluster duplication + source_cluster = self.get_cluster(study, area_id, source_id) + source_cluster.name = new_cluster_name + creation_form = ThermalClusterCreation(**source_cluster.dict(by_alias=False, exclude={"id"})) + new_config = creation_form.to_config(study.version) + create_cluster_cmd = self._make_create_cluster_cmd(area_id, new_config) + + # Matrix edition + lower_source_id = source_id.lower() + source_paths = [ + f"input/thermal/series/{area_id}/{lower_source_id}/series", + f"input/thermal/prepro/{area_id}/{lower_source_id}/modulation", + f"input/thermal/prepro/{area_id}/{lower_source_id}/data", + ] + new_paths = [ + f"input/thermal/series/{area_id}/{lower_new_id}/series", + f"input/thermal/prepro/{area_id}/{lower_new_id}/modulation", + f"input/thermal/prepro/{area_id}/{lower_new_id}/data", + ] + + # Prepare and execute commands + commands: t.List[t.Union[CreateCluster, ReplaceMatrix]] = [create_cluster_cmd] + storage_service = self.storage_service.get_storage(study) + command_context = self.storage_service.variant_study_service.command_factory.command_context + for source_path, new_path in zip(source_paths, new_paths): + current_matrix = storage_service.get(study, source_path)["data"] + command = ReplaceMatrix(target=new_path, matrix=current_matrix, command_context=command_context) + commands.append(command) + + execute_or_add_commands(study, self._get_file_study(study), commands, self.storage_service) + + return ThermalClusterOutput(**new_config.dict(by_alias=False)) diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index c7e6ac17fd..bc667f45d5 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -1,3 +1,4 @@ +import enum import logging from http import HTTPStatus from typing import Any, Dict, List, Optional, Sequence, Union, cast @@ -24,10 +25,12 @@ RenewableClusterCreation, RenewableClusterInput, RenewableClusterOutput, + RenewableManager, ) from antarest.study.business.areas.st_storage_management import ( STStorageCreation, STStorageInput, + STStorageManager, STStorageMatrix, STStorageOutput, STStorageTimeSeries, @@ -36,6 +39,7 @@ ThermalClusterCreation, ThermalClusterInput, ThermalClusterOutput, + ThermalManager, ) from antarest.study.business.binding_constraint_management import ( BindingConstraintPropertiesWithName, @@ -58,6 +62,20 @@ logger = logging.getLogger(__name__) +class ClusterType(str, enum.Enum): + """ + Cluster type: + + - `STORAGE`: short-term storages + - `RENEWABLES`: renewable clusters + - `THERMALS`: thermal clusters + """ + + ST_STORAGES = "storages" + RENEWABLES = "renewables" + THERMALS = "thermals" + + def create_study_data_routes(study_service: StudyService, config: Config) -> APIRouter: """ Endpoint implementation for studies area management @@ -2019,4 +2037,36 @@ def delete_st_storages( study = study_service.check_study_access(uuid, StudyPermissionType.WRITE, params) study_service.st_storage_manager.delete_storages(study, area_id, storage_ids) + @bp.post( + path="/studies/{uuid}/areas/{area_id}/{cluster_type}/{source_cluster_id}", + tags=[APITag.study_data], + summary="Duplicates a given cluster", + ) + def duplicate_cluster( + uuid: str, + area_id: str, + cluster_type: ClusterType, + source_cluster_id: str, + new_cluster_name: str = Query(..., alias="newName", title="New Cluster Name"), # type: ignore + current_user: JWTUser = Depends(auth.get_current_user), + ) -> Union[STStorageOutput, ThermalClusterOutput, RenewableClusterOutput]: + logger.info( + f"Duplicates {cluster_type.value} {source_cluster_id} of {area_id} for study {uuid}", + extra={"user": current_user.id}, + ) + params = RequestParameters(user=current_user) + study = study_service.check_study_access(uuid, StudyPermissionType.WRITE, params) + + manager: Union[STStorageManager, RenewableManager, ThermalManager] + if cluster_type == ClusterType.ST_STORAGES: + manager = STStorageManager(study_service.storage_service) + elif cluster_type == ClusterType.RENEWABLES: + manager = RenewableManager(study_service.storage_service) + elif cluster_type == ClusterType.THERMALS: + manager = ThermalManager(study_service.storage_service) + else: # pragma: no cover + raise NotImplementedError(f"Cluster type {cluster_type} not implemented") + + return manager.duplicate_cluster(study, area_id, source_cluster_id, new_cluster_name) + return bp diff --git a/tests/integration/study_data_blueprint/test_renewable.py b/tests/integration/study_data_blueprint/test_renewable.py index 14f1f4388a..8447c0430f 100644 --- a/tests/integration/study_data_blueprint/test_renewable.py +++ b/tests/integration/study_data_blueprint/test_renewable.py @@ -25,7 +25,9 @@ """ import json import re +import typing as t +import numpy as np import pytest from starlette.testclient import TestClient @@ -132,7 +134,23 @@ def test_lifecycle( # RENEWABLE CLUSTER MATRICES # ============================= - # TODO: add unit tests for renewable cluster matrices + matrix = np.random.randint(0, 2, size=(8760, 1)).tolist() + matrix_path = f"input/renewables/series/{area_id}/{fr_solar_pv_id.lower()}/series" + args = {"target": matrix_path, "matrix": matrix} + res = client.post( + f"/v1/studies/{study_id}/commands", + json=[{"action": "replace_matrix", "args": args}], + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code in {200, 201}, res.json() + + res = client.get( + f"/v1/studies/{study_id}/raw", + params={"path": matrix_path}, + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == 200 + assert res.json()["data"] == matrix # ================================== # RENEWABLE CLUSTER LIST / GROUPS @@ -211,6 +229,34 @@ def test_lifecycle( assert res.status_code == 200, res.json() assert res.json() == fr_solar_pv_cfg + # =============================== + # RENEWABLE CLUSTER DUPLICATION + # =============================== + + new_name = "Duplicate of SolarPV" + res = client.post( + f"/v1/studies/{study_id}/areas/{area_id}/renewables/{fr_solar_pv_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + params={"newName": new_name}, + ) + # asserts the config is the same + assert res.status_code in {200, 201}, res.json() + duplicated_config = dict(fr_solar_pv_cfg) + duplicated_config["name"] = new_name + duplicated_id = transform_name_to_id(new_name, lower=False) + duplicated_config["id"] = duplicated_id + assert res.json() == duplicated_config + + # asserts the matrix has also been duplicated + new_cluster_matrix_path = f"input/renewables/series/{area_id}/{duplicated_id.lower()}/series" + res = client.get( + f"/v1/studies/{study_id}/raw", + params={"path": new_cluster_matrix_path}, + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == 200 + assert res.json()["data"] == matrix + # ============================= # RENEWABLE CLUSTER DELETION # ============================= @@ -237,10 +283,11 @@ def test_lifecycle( # It's possible to delete multiple renewable clusters at once. # Create two clusters + other_cluster_name = "Other Cluster 1" res = client.post( f"/v1/studies/{study_id}/areas/{area_id}/clusters/renewable", headers={"Authorization": f"Bearer {user_access_token}"}, - json={"name": "Other Cluster 1"}, + json={"name": other_cluster_name}, ) assert res.status_code == 200, res.json() other_cluster_id1 = res.json()["id"] @@ -253,28 +300,24 @@ def test_lifecycle( assert res.status_code == 200, res.json() other_cluster_id2 = res.json()["id"] - # We can delete the two renewable clusters at once. + # We can delete two renewable clusters at once. res = client.request( "DELETE", f"/v1/studies/{study_id}/areas/{area_id}/clusters/renewable", headers={"Authorization": f"Bearer {user_access_token}"}, - json=[other_cluster_id1, other_cluster_id2], + json=[other_cluster_id2, duplicated_id], ) assert res.status_code == 204, res.json() assert res.text in {"", "null"} # Old FastAPI versions return 'null'. - # The list of renewable clusters should be empty. + # There should only be one remaining cluster res = client.get( f"/v1/studies/{study_id}/areas/{area_id}/clusters/renewable", headers={"Authorization": f"Bearer {user_access_token}"}, ) - assert res.status_code == 200, res.json() - expected = [ - c - for c in EXISTING_CLUSTERS - if transform_name_to_id(c["name"], lower=False) not in [other_cluster_id1, other_cluster_id2] - ] - assert res.json() == expected + assert res.status_code == 200 + obj = res.json() + assert len(obj) == 1 # =========================== # RENEWABLE CLUSTER ERRORS @@ -422,3 +465,168 @@ def test_lifecycle( obj = res.json() description = obj["description"] assert bad_study_id in description + + # Cannot duplicate a fake cluster + unknown_id = "unknown" + res = client.post( + f"/v1/studies/{study_id}/areas/{area_id}/renewables/{unknown_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + params={"newName": "duplicata"}, + ) + assert res.status_code == 404 + obj = res.json() + assert obj["description"] == f"Cluster: '{unknown_id}' not found" + assert obj["exception"] == "ClusterNotFound" + + # Cannot duplicate with an existing id + res = client.post( + f"/v1/studies/{study_id}/areas/{area_id}/renewables/{other_cluster_id1}", + headers={"Authorization": f"Bearer {user_access_token}"}, + params={"newName": other_cluster_name.upper()}, # different case, but same ID + ) + assert res.status_code == 409, res.json() + obj = res.json() + description = obj["description"] + assert other_cluster_name.upper() in description + assert obj["exception"] == "ClusterAlreadyExists" + + @pytest.fixture(name="base_study_id") + def base_study_id_fixture(self, request: t.Any, client: TestClient, user_access_token: str) -> str: + """Prepare a managed study for the variant study tests.""" + params = request.param + res = client.post( + "/v1/studies", + headers={"Authorization": f"Bearer {user_access_token}"}, + params=params, + ) + assert res.status_code in {200, 201}, res.json() + study_id: str = res.json() + return study_id + + @pytest.fixture(name="variant_id") + def variant_id_fixture(self, request: t.Any, client: TestClient, user_access_token: str, base_study_id: str) -> str: + """Prepare a variant study for the variant study tests.""" + name = request.param + res = client.post( + f"/v1/studies/{base_study_id}/variants", + headers={"Authorization": f"Bearer {user_access_token}"}, + params={"name": name}, + ) + assert res.status_code in {200, 201}, res.json() + study_id: str = res.json() + return study_id + + # noinspection PyTestParametrized + @pytest.mark.parametrize("base_study_id", [{"name": "Base Study", "version": 860}], indirect=True) + @pytest.mark.parametrize("variant_id", ["Variant Study"], indirect=True) + def test_variant_lifecycle(self, client: TestClient, user_access_token: str, variant_id: str) -> None: + """ + In this test, we want to check that renewable clusters can be managed + in the context of a "variant" study. + """ + # Create an area + area_name = "France" + res = client.post( + f"/v1/studies/{variant_id}/areas", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={"name": area_name, "type": "AREA"}, + ) + assert res.status_code in {200, 201}, res.json() + area_cfg = res.json() + area_id = area_cfg["id"] + + # Create a renewable cluster + cluster_name = "Th1" + res = client.post( + f"/v1/studies/{variant_id}/areas/{area_id}/clusters/renewable", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={ + "name": cluster_name, + "group": "Wind Offshore", + "unitCount": 13, + "nominalCapacity": 42500, + }, + ) + assert res.status_code in {200, 201}, res.json() + cluster_id: str = res.json()["id"] + + # Update the renewable cluster + res = client.patch( + f"/v1/studies/{variant_id}/areas/{area_id}/clusters/renewable/{cluster_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={"unitCount": 15}, + ) + assert res.status_code == 200, res.json() + cluster_cfg = res.json() + assert cluster_cfg["unitCount"] == 15 + + # Update the series matrix + matrix = np.random.randint(0, 2, size=(8760, 1)).tolist() + matrix_path = f"input/renewables/series/{area_id}/{cluster_id.lower()}/series" + args = {"target": matrix_path, "matrix": matrix} + res = client.post( + f"/v1/studies/{variant_id}/commands", + json=[{"action": "replace_matrix", "args": args}], + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code in {200, 201}, res.json() + + # Duplicate the renewable cluster + new_name = "Th2" + res = client.post( + f"/v1/studies/{variant_id}/areas/{area_id}/renewables/{cluster_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + params={"newName": new_name}, + ) + assert res.status_code in {200, 201}, res.json() + cluster_cfg = res.json() + assert cluster_cfg["name"] == new_name + new_id = cluster_cfg["id"] + + # Check that the duplicate has the right properties + res = client.get( + f"/v1/studies/{variant_id}/areas/{area_id}/clusters/renewable/{new_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == 200, res.json() + cluster_cfg = res.json() + assert cluster_cfg["group"] == "Wind Offshore" + assert cluster_cfg["unitCount"] == 15 + assert cluster_cfg["nominalCapacity"] == 42500 + + # Check that the duplicate has the right matrix + new_cluster_matrix_path = f"input/renewables/series/{area_id}/{new_id.lower()}/series" + res = client.get( + f"/v1/studies/{variant_id}/raw", + params={"path": new_cluster_matrix_path}, + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == 200 + assert res.json()["data"] == matrix + + # Delete the renewable cluster + res = client.delete( + f"/v1/studies/{variant_id}/areas/{area_id}/clusters/renewable", + headers={"Authorization": f"Bearer {user_access_token}"}, + json=[cluster_id], + ) + assert res.status_code == 204, res.json() + + # Check the list of variant commands + res = client.get( + f"/v1/studies/{variant_id}/commands", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == 200, res.json() + commands = res.json() + assert len(commands) == 7 + actions = [command["action"] for command in commands] + assert actions == [ + "create_area", + "create_renewables_cluster", + "update_config", + "replace_matrix", + "create_renewables_cluster", + "replace_matrix", + "remove_renewables_cluster", + ] diff --git a/tests/integration/study_data_blueprint/test_st_storage.py b/tests/integration/study_data_blueprint/test_st_storage.py index fdffe5efe1..5f2421d911 100644 --- a/tests/integration/study_data_blueprint/test_st_storage.py +++ b/tests/integration/study_data_blueprint/test_st_storage.py @@ -1,5 +1,6 @@ import json import re +import typing as t from unittest.mock import ANY import numpy as np @@ -123,14 +124,15 @@ def test_lifecycle__nominal( # ============================= # updating the matrix of a short-term storage - array = np.random.rand(8760, 1) * 1000 + array = np.random.randint(0, 1000, size=(8760, 1)) + array_list = array.tolist() res = client.put( f"/v1/studies/{study_id}/areas/{area_id}/storages/{siemens_battery_id}/series/inflows", headers={"Authorization": f"Bearer {user_access_token}"}, json={ "index": list(range(array.shape[0])), "columns": list(range(array.shape[1])), - "data": array.tolist(), + "data": array_list, }, ) assert res.status_code == 200, res.json() @@ -231,6 +233,32 @@ def test_lifecycle__nominal( assert res.status_code == 200, res.json() assert res.json() == siemens_config + # ============================= + # SHORT-TERM STORAGE DUPLICATION + # ============================= + + new_name = "Duplicate of Siemens" + res = client.post( + f"/v1/studies/{study_id}/areas/{area_id}/storages/{siemens_battery_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + params={"newName": new_name}, + ) + assert res.status_code in {200, 201}, res.json() + # asserts the config is the same + duplicated_config = dict(siemens_config) + duplicated_config["name"] = new_name # type: ignore + duplicated_id = transform_name_to_id(new_name) + duplicated_config["id"] = duplicated_id # type: ignore + assert res.json() == duplicated_config + + # asserts the matrix has also been duplicated + res = client.get( + f"/v1/studies/{study_id}/areas/{area_id}/storages/{duplicated_id}/series/inflows", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == 200 + assert res.json()["data"] == array_list + # ============================= # SHORT-TERM STORAGE DELETION # ============================= @@ -303,25 +331,25 @@ def test_lifecycle__nominal( assert res.status_code == 200, res.json() siemens_config = {**DEFAULT_PROPERTIES, **siemens_properties, "id": siemens_battery_id} grand_maison_config = {**DEFAULT_PROPERTIES, **grand_maison_properties, "id": grand_maison_id} - assert res.json() == [siemens_config, grand_maison_config] + assert res.json() == [duplicated_config, siemens_config, grand_maison_config] - # We can delete the two short-term storages at once. + # We can delete the three short-term storages at once. res = client.request( "DELETE", f"/v1/studies/{study_id}/areas/{area_id}/storages", headers={"Authorization": f"Bearer {user_access_token}"}, - json=[siemens_battery_id, grand_maison_id], + json=[grand_maison_id, duplicated_config["id"]], ) assert res.status_code == 204, res.json() assert res.text in {"", "null"} # Old FastAPI versions return 'null'. - # The list of short-term storages should be empty. + # Only one st-storage should remain. res = client.get( f"/v1/studies/{study_id}/areas/{area_id}/storages", headers={"Authorization": f"Bearer {user_access_token}"}, ) assert res.status_code == 200, res.json() - assert res.json() == [] + assert len(res.json()) == 1 # =========================== # SHORT-TERM STORAGE ERRORS @@ -450,6 +478,30 @@ def test_lifecycle__nominal( description = obj["description"] assert bad_study_id in description + # Cannot duplicate a fake st-storage + unknown_id = "unknown" + res = client.post( + f"/v1/studies/{study_id}/areas/{area_id}/storages/{unknown_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + params={"newName": "duplicata"}, + ) + assert res.status_code == 404, res.json() + obj = res.json() + assert obj["description"] == f"Fields of storage '{unknown_id}' not found" + assert obj["exception"] == "STStorageFieldsNotFoundError" + + # Cannot duplicate with an existing id + res = client.post( + f"/v1/studies/{study_id}/areas/{area_id}/storages/{siemens_battery_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + params={"newName": siemens_battery.upper()}, # different case, but same ID + ) + assert res.status_code == 409, res.json() + obj = res.json() + description = obj["description"] + assert siemens_battery.lower() in description + assert obj["exception"] == "ClusterAlreadyExists" + def test__default_values( self, client: TestClient, @@ -632,3 +684,146 @@ def test__default_values( "initiallevel": 0.0, } assert actual == expected + + @pytest.fixture(name="base_study_id") + def base_study_id_fixture(self, request: t.Any, client: TestClient, user_access_token: str) -> str: + """Prepare a managed study for the variant study tests.""" + params = request.param + res = client.post( + "/v1/studies", + headers={"Authorization": f"Bearer {user_access_token}"}, + params=params, + ) + assert res.status_code in {200, 201}, res.json() + study_id: str = res.json() + return study_id + + @pytest.fixture(name="variant_id") + def variant_id_fixture(self, request: t.Any, client: TestClient, user_access_token: str, base_study_id: str) -> str: + """Prepare a variant study for the variant study tests.""" + name = request.param + res = client.post( + f"/v1/studies/{base_study_id}/variants", + headers={"Authorization": f"Bearer {user_access_token}"}, + params={"name": name}, + ) + assert res.status_code in {200, 201}, res.json() + study_id: str = res.json() + return study_id + + # noinspection PyTestParametrized + @pytest.mark.parametrize("base_study_id", [{"name": "Base Study", "version": 860}], indirect=True) + @pytest.mark.parametrize("variant_id", ["Variant Study"], indirect=True) + def test_variant_lifecycle(self, client: TestClient, user_access_token: str, variant_id: str) -> None: + """ + In this test, we want to check that short-term storages can be managed + in the context of a "variant" study. + """ + # Create an area + area_name = "France" + res = client.post( + f"/v1/studies/{variant_id}/areas", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={"name": area_name, "type": "AREA"}, + ) + assert res.status_code in {200, 201}, res.json() + area_cfg = res.json() + area_id = area_cfg["id"] + + # Create a short-term storage + cluster_name = "Tesla1" + res = client.post( + f"/v1/studies/{variant_id}/areas/{area_id}/storages", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={ + "name": cluster_name, + "group": "Battery", + "injectionNominalCapacity": 4500, + "withdrawalNominalCapacity": 4230, + "reservoirCapacity": 5700, + }, + ) + assert res.status_code in {200, 201}, res.json() + cluster_id: str = res.json()["id"] + + # Update the short-term storage + res = client.patch( + f"/v1/studies/{variant_id}/areas/{area_id}/storages/{cluster_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={"reservoirCapacity": 5600}, + ) + assert res.status_code == 200, res.json() + cluster_cfg = res.json() + assert cluster_cfg["reservoirCapacity"] == 5600 + + # Update the series matrix + matrix = np.random.randint(0, 2, size=(8760, 1)).tolist() + matrix_path = f"input/st-storage/series/{area_id}/{cluster_id.lower()}/pmax_injection" + args = {"target": matrix_path, "matrix": matrix} + res = client.post( + f"/v1/studies/{variant_id}/commands", + json=[{"action": "replace_matrix", "args": args}], + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code in {200, 201}, res.json() + + # Duplicate the short-term storage + new_name = "Tesla2" + res = client.post( + f"/v1/studies/{variant_id}/areas/{area_id}/storages/{cluster_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + params={"newName": new_name}, + ) + assert res.status_code in {200, 201}, res.json() + cluster_cfg = res.json() + assert cluster_cfg["name"] == new_name + new_id = cluster_cfg["id"] + + # Check that the duplicate has the right properties + res = client.get( + f"/v1/studies/{variant_id}/areas/{area_id}/storages/{new_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == 200, res.json() + cluster_cfg = res.json() + assert cluster_cfg["group"] == "Battery" + assert cluster_cfg["injectionNominalCapacity"] == 4500 + assert cluster_cfg["withdrawalNominalCapacity"] == 4230 + assert cluster_cfg["reservoirCapacity"] == 5600 + + # Check that the duplicate has the right matrix + new_cluster_matrix_path = f"input/st-storage/series/{area_id}/{new_id.lower()}/pmax_injection" + res = client.get( + f"/v1/studies/{variant_id}/raw", + params={"path": new_cluster_matrix_path}, + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == 200 + assert res.json()["data"] == matrix + + # Delete the short-term storage + res = client.delete( + f"/v1/studies/{variant_id}/areas/{area_id}/storages", + headers={"Authorization": f"Bearer {user_access_token}"}, + json=[cluster_id], + ) + assert res.status_code == 204, res.json() + + # Check the list of variant commands + res = client.get( + f"/v1/studies/{variant_id}/commands", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == 200, res.json() + commands = res.json() + assert len(commands) == 7 + actions = [command["action"] for command in commands] + assert actions == [ + "create_area", + "create_st_storage", + "update_config", + "replace_matrix", + "create_st_storage", + "replace_matrix", + "remove_st_storage", + ] diff --git a/tests/integration/study_data_blueprint/test_thermal.py b/tests/integration/study_data_blueprint/test_thermal.py index 1890d44acf..9fc7388642 100644 --- a/tests/integration/study_data_blueprint/test_thermal.py +++ b/tests/integration/study_data_blueprint/test_thermal.py @@ -29,7 +29,9 @@ """ import json import re +import typing as t +import numpy as np import pytest from starlette.testclient import TestClient @@ -455,7 +457,23 @@ def test_lifecycle( # THERMAL CLUSTER MATRICES # ============================= - # TODO: add unit tests for thermal cluster matrices + matrix = np.random.randint(0, 2, size=(8760, 1)).tolist() + matrix_path = f"input/thermal/prepro/{area_id}/{fr_gas_conventional_id.lower()}/data" + args = {"target": matrix_path, "matrix": matrix} + res = client.post( + f"/v1/studies/{study_id}/commands", + json=[{"action": "replace_matrix", "args": args}], + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code in {200, 201}, res.json() + + res = client.get( + f"/v1/studies/{study_id}/raw", + params={"path": matrix_path}, + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == 200 + assert res.json()["data"] == matrix # ================================== # THERMAL CLUSTER LIST / GROUPS @@ -536,6 +554,34 @@ def test_lifecycle( assert res.status_code == 200, res.json() assert res.json() == fr_gas_conventional_cfg + # ============================= + # THERMAL CLUSTER DUPLICATION + # ============================= + + new_name = "Duplicate of Fr_Gas_Conventional" + res = client.post( + f"/v1/studies/{study_id}/areas/{area_id}/thermals/{fr_gas_conventional_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + params={"newName": new_name}, + ) + assert res.status_code in {200, 201}, res.json() + # asserts the config is the same + duplicated_config = dict(fr_gas_conventional_cfg) + duplicated_config["name"] = new_name + duplicated_id = transform_name_to_id(new_name, lower=False) + duplicated_config["id"] = duplicated_id + assert res.json() == duplicated_config + + # asserts the matrix has also been duplicated + new_cluster_matrix_path = f"input/thermal/prepro/{area_id}/{duplicated_id.lower()}/data" + res = client.get( + f"/v1/studies/{study_id}/raw", + params={"path": new_cluster_matrix_path}, + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == 200 + assert res.json()["data"] == matrix + # ============================= # THERMAL CLUSTER DELETION # ============================= @@ -573,18 +619,15 @@ def test_lifecycle( assert res.status_code == 204, res.json() assert res.text in {"", "null"} # Old FastAPI versions return 'null'. - # The list of thermal clusters should be empty. + # The list of thermal clusters should not contain the deleted ones. res = client.get( f"/v1/studies/{study_id}/areas/{area_id}/clusters/thermal", headers={"Authorization": f"Bearer {user_access_token}"}, ) assert res.status_code == 200, res.json() - expected = [ - c - for c in EXISTING_CLUSTERS - if transform_name_to_id(c["name"], lower=False) not in [other_cluster_id1, other_cluster_id2] - ] - assert res.json() == expected + deleted_clusters = [other_cluster_id1, other_cluster_id2, fr_gas_conventional_id] + for cluster in res.json(): + assert transform_name_to_id(cluster["name"], lower=False) not in deleted_clusters # =========================== # THERMAL CLUSTER ERRORS @@ -748,3 +791,172 @@ def test_lifecycle( obj = res.json() description = obj["description"] assert bad_study_id in description + + # Cannot duplicate a fake cluster + unknown_id = "unknown" + res = client.post( + f"/v1/studies/{study_id}/areas/{area_id}/thermals/{unknown_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + params={"newName": "duplicate"}, + ) + assert res.status_code == 404, res.json() + obj = res.json() + assert obj["description"] == f"Cluster: '{unknown_id}' not found" + assert obj["exception"] == "ClusterNotFound" + + # Cannot duplicate with an existing id + res = client.post( + f"/v1/studies/{study_id}/areas/{area_id}/thermals/{duplicated_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + params={"newName": new_name.upper()}, # different case but same ID + ) + assert res.status_code == 409, res.json() + obj = res.json() + description = obj["description"] + assert new_name.upper() in description + assert obj["exception"] == "ClusterAlreadyExists" + + @pytest.fixture(name="base_study_id") + def base_study_id_fixture(self, request: t.Any, client: TestClient, user_access_token: str) -> str: + """Prepare a managed study for the variant study tests.""" + params = request.param + res = client.post( + "/v1/studies", + headers={"Authorization": f"Bearer {user_access_token}"}, + params=params, + ) + assert res.status_code in {200, 201}, res.json() + study_id: str = res.json() + return study_id + + @pytest.fixture(name="variant_id") + def variant_id_fixture(self, request: t.Any, client: TestClient, user_access_token: str, base_study_id: str) -> str: + """Prepare a variant study for the variant study tests.""" + name = request.param + res = client.post( + f"/v1/studies/{base_study_id}/variants", + headers={"Authorization": f"Bearer {user_access_token}"}, + params={"name": name}, + ) + assert res.status_code in {200, 201}, res.json() + study_id: str = res.json() + return study_id + + # noinspection PyTestParametrized + @pytest.mark.parametrize("base_study_id", [{"name": "Base Study", "version": 860}], indirect=True) + @pytest.mark.parametrize("variant_id", ["Variant Study"], indirect=True) + def test_variant_lifecycle(self, client: TestClient, user_access_token: str, variant_id: str) -> None: + """ + In this test, we want to check that thermal clusters can be managed + in the context of a "variant" study. + """ + # Create an area + area_name = "France" + res = client.post( + f"/v1/studies/{variant_id}/areas", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={"name": area_name, "type": "AREA"}, + ) + assert res.status_code in {200, 201}, res.json() + area_cfg = res.json() + area_id = area_cfg["id"] + + # Create a thermal cluster + cluster_name = "Th1" + res = client.post( + f"/v1/studies/{variant_id}/areas/{area_id}/clusters/thermal", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={ + "name": cluster_name, + "group": "Nuclear", + "unitCount": 13, + "nominalCapacity": 42500, + "marginalCost": 0.1, + }, + ) + assert res.status_code in {200, 201}, res.json() + cluster_id: str = res.json()["id"] + + # Update the thermal cluster + res = client.patch( + f"/v1/studies/{variant_id}/areas/{area_id}/clusters/thermal/{cluster_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={ + "marginalCost": 0.2, + }, + ) + assert res.status_code == 200, res.json() + cluster_cfg = res.json() + assert cluster_cfg["marginalCost"] == 0.2 + + # Update the prepro matrix + matrix = np.random.randint(0, 2, size=(8760, 1)).tolist() + matrix_path = f"input/thermal/prepro/{area_id}/{cluster_id.lower()}/data" + args = {"target": matrix_path, "matrix": matrix} + res = client.post( + f"/v1/studies/{variant_id}/commands", + json=[{"action": "replace_matrix", "args": args}], + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code in {200, 201}, res.json() + + # Duplicate the thermal cluster + new_name = "Th2" + res = client.post( + f"/v1/studies/{variant_id}/areas/{area_id}/thermals/{cluster_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + params={"newName": new_name}, + ) + assert res.status_code in {200, 201}, res.json() + cluster_cfg = res.json() + assert cluster_cfg["name"] == new_name + new_id = cluster_cfg["id"] + + # Check that the duplicate has the right properties + res = client.get( + f"/v1/studies/{variant_id}/areas/{area_id}/clusters/thermal/{new_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == 200, res.json() + cluster_cfg = res.json() + assert cluster_cfg["group"] == "Nuclear" + assert cluster_cfg["unitCount"] == 13 + assert cluster_cfg["nominalCapacity"] == 42500 + assert cluster_cfg["marginalCost"] == 0.2 + + # Check that the duplicate has the right matrix + new_cluster_matrix_path = f"input/thermal/prepro/{area_id}/{new_id.lower()}/data" + res = client.get( + f"/v1/studies/{variant_id}/raw", + params={"path": new_cluster_matrix_path}, + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == 200 + assert res.json()["data"] == matrix + + # Delete the thermal cluster + res = client.delete( + f"/v1/studies/{variant_id}/areas/{area_id}/clusters/thermal", + headers={"Authorization": f"Bearer {user_access_token}"}, + json=[cluster_id], + ) + assert res.status_code == 204, res.json() + + # Check the list of variant commands + res = client.get( + f"/v1/studies/{variant_id}/commands", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == 200, res.json() + commands = res.json() + assert len(commands) == 7 + actions = [command["action"] for command in commands] + assert actions == [ + "create_area", + "create_cluster", + "update_config", + "replace_matrix", + "create_cluster", + "replace_matrix", + "remove_cluster", + ] From b3f654a658a2309c80512ab294ad724875f4c126 Mon Sep 17 00:00:00 2001 From: MartinBelthle <102529366+MartinBelthle@users.noreply.github.com> Date: Sat, 9 Mar 2024 15:49:42 +0100 Subject: [PATCH 047/248] fix(storages): use command when updating matrices (#1971) Resolves [ANT-1352] --- antarest/core/exceptions.py | 24 ++- .../business/areas/st_storage_management.py | 63 +++++-- .../study_data_blueprint/test_st_storage.py | 169 ++++++++++-------- .../areas/test_st_storage_management.py | 27 ++- 4 files changed, 187 insertions(+), 96 deletions(-) diff --git a/antarest/core/exceptions.py b/antarest/core/exceptions.py index f521ffec63..361ba53644 100644 --- a/antarest/core/exceptions.py +++ b/antarest/core/exceptions.py @@ -34,13 +34,35 @@ class STStorageConfigNotFoundError(HTTPException): """Configuration for short-term storage is not found""" def __init__(self, study_id: str, area_id: str) -> None: - detail = f"The short-term storage configuration of area '{area_id}' not found:" + detail = f"The short-term storage configuration of area '{area_id}' not found" super().__init__(HTTPStatus.NOT_FOUND, detail) def __str__(self) -> str: return self.detail +class STStorageNotFoundError(HTTPException): + """Short-term storage is not found""" + + def __init__(self, study_id: str, area_id: str, st_storage_id: str) -> None: + detail = f"Short-term storage '{st_storage_id}' not found in area '{area_id}'" + super().__init__(HTTPStatus.NOT_FOUND, detail) + + def __str__(self) -> str: + return self.detail + + +class DuplicateSTStorageId(HTTPException): + """Exception raised when trying to create a short-term storage with an already existing id.""" + + def __init__(self, study_id: str, area_id: str, st_storage_id: str) -> None: + detail = f"Short term storage '{st_storage_id}' already exists in area '{area_id}'" + super().__init__(HTTPStatus.CONFLICT, detail) + + def __str__(self) -> str: + return self.detail + + class UnknownModuleError(Exception): def __init__(self, message: str) -> None: super(UnknownModuleError, self).__init__(message) diff --git a/antarest/study/business/areas/st_storage_management.py b/antarest/study/business/areas/st_storage_management.py index ca498c030a..7109d8c668 100644 --- a/antarest/study/business/areas/st_storage_management.py +++ b/antarest/study/business/areas/st_storage_management.py @@ -8,10 +8,13 @@ from typing_extensions import Literal from antarest.core.exceptions import ( + AreaNotFound, ClusterAlreadyExists, + DuplicateSTStorageId, STStorageConfigNotFoundError, STStorageFieldsNotFoundError, STStorageMatrixNotFoundError, + STStorageNotFoundError, ) from antarest.study.business.utils import AllOptionalMetaclass, camel_case_model, execute_or_add_commands from antarest.study.model import Study @@ -262,6 +265,7 @@ def create_storage( """ file_study = self._get_file_study(study) storage = form.to_config(study.version) + _check_creation_feasibility(file_study, area_id, storage.id) command = self._make_create_cluster_cmd(area_id, storage) execute_or_add_commands( study, @@ -357,18 +361,11 @@ def update_storage( """ study_version = study.version - # review: reading the configuration poses a problem for variants, - # because it requires generating a snapshot, which takes time. - # This reading could be avoided if we don't need the previous values - # (no cross-field validation, no default values, etc.). - # In return, we won't be able to return a complete `STStorageOutput` object. - # So, we need to make sure the frontend doesn't need the missing fields. - # This missing information could also be a problem for the API users. - # The solution would be to avoid reading the configuration if the study is a variant - # (we then use the default values), otherwise, for a RAW study, we read the configuration - # and update the modified values. + # For variants, this method requires generating a snapshot, which takes time. + # But sadly, there's no other way to prevent creating wrong commands. file_study = self._get_file_study(study) + _check_update_feasibility(file_study, area_id, storage_id) path = STORAGE_LIST_PATH.format(area_id=area_id, storage_id=storage_id) try: @@ -415,6 +412,9 @@ def delete_storages( area_id: The area ID of the short-term storage. storage_ids: IDs list of short-term storages to remove. """ + file_study = self._get_file_study(study) + _check_deletion_feasibility(file_study, area_id, storage_ids) + command_context = self.storage_service.variant_study_service.command_factory.command_context for storage_id in storage_ids: command = RemoveSTStorage( @@ -422,7 +422,6 @@ def delete_storages( storage_id=storage_id, command_context=command_context, ) - file_study = self._get_file_study(study) execute_or_add_commands(study, file_study, [command], self.storage_service) def duplicate_cluster(self, study: Study, area_id: str, source_id: str, new_cluster_name: str) -> STStorageOutput: @@ -455,6 +454,7 @@ def duplicate_cluster(self, study: Study, area_id: str, source_id: str, new_clus # Matrix edition lower_source_id = source_id.lower() + # noinspection SpellCheckingInspection ts_names = ["pmax_injection", "pmax_withdrawal", "lower_rule_curve", "upper_rule_curve", "inflows"] source_paths = [ STORAGE_SERIES_PATH.format(area_id=area_id, storage_id=lower_source_id, ts_name=ts_name) @@ -533,8 +533,7 @@ def update_matrix( ts_name: Name of the time series to update. ts: Matrix of the time series to update. """ - matrix_object = ts.dict() - self._save_matrix_obj(study, area_id, storage_id, ts_name, matrix_object) + self._save_matrix_obj(study, area_id, storage_id, ts_name, ts.data) def _save_matrix_obj( self, @@ -542,13 +541,13 @@ def _save_matrix_obj( area_id: str, storage_id: str, ts_name: STStorageTimeSeries, - matrix_obj: t.Dict[str, t.Any], + matrix_data: t.List[t.List[float]], ) -> None: - path = STORAGE_SERIES_PATH.format(area_id=area_id, storage_id=storage_id, ts_name=ts_name) - matrix = matrix_obj["data"] + file_study = self._get_file_study(study) command_context = self.storage_service.variant_study_service.command_factory.command_context - command = ReplaceMatrix(target=path, matrix=matrix, command_context=command_context) - execute_or_add_commands(study, self._get_file_study(study), [command], self.storage_service) + path = STORAGE_SERIES_PATH.format(area_id=area_id, storage_id=storage_id, ts_name=ts_name) + command = ReplaceMatrix(target=path, matrix=matrix_data, command_context=command_context) + execute_or_add_commands(study, file_study, [command], self.storage_service) def validate_matrices( self, @@ -593,3 +592,31 @@ def validate_matrices( # Validation successful return True + + +def _get_existing_storage_ids(file_study: FileStudy, area_id: str) -> t.Set[str]: + try: + area = file_study.config.areas[area_id] + except KeyError: + raise AreaNotFound(area_id) from None + else: + return {s.id for s in area.st_storages} + + +def _check_deletion_feasibility(file_study: FileStudy, area_id: str, storage_ids: t.Sequence[str]) -> None: + existing_ids = _get_existing_storage_ids(file_study, area_id) + for storage_id in storage_ids: + if storage_id not in existing_ids: + raise STStorageNotFoundError(file_study.config.study_id, area_id, storage_id) + + +def _check_update_feasibility(file_study: FileStudy, area_id: str, storage_id: str) -> None: + existing_ids = _get_existing_storage_ids(file_study, area_id) + if storage_id not in existing_ids: + raise STStorageNotFoundError(file_study.config.study_id, area_id, storage_id) + + +def _check_creation_feasibility(file_study: FileStudy, area_id: str, storage_id: str) -> None: + existing_ids = _get_existing_storage_ids(file_study, area_id) + if storage_id in existing_ids: + raise DuplicateSTStorageId(file_study.config.study_id, area_id, storage_id) diff --git a/tests/integration/study_data_blueprint/test_st_storage.py b/tests/integration/study_data_blueprint/test_st_storage.py index 5f2421d911..161e6417b8 100644 --- a/tests/integration/study_data_blueprint/test_st_storage.py +++ b/tests/integration/study_data_blueprint/test_st_storage.py @@ -29,11 +29,9 @@ class TestSTStorage: which contains the following areas: ["de", "es", "fr", "it"]. """ + @pytest.mark.parametrize("study_type", ["raw", "variant"]) def test_lifecycle__nominal( - self, - client: TestClient, - user_access_token: str, - study_id: str, + self, client: TestClient, user_access_token: str, study_id: str, study_type: str ) -> None: """ The purpose of this integration test is to test the endpoints @@ -59,10 +57,15 @@ def test_lifecycle__nominal( We will test the deletion of short-term storages. """ + # ============================= + # SET UP + # ============================= + user_headers = {"Authorization": f"Bearer {user_access_token}"} + # Upgrade study to version 860 res = client.put( f"/v1/studies/{study_id}/upgrade", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, params={"target_version": 860}, ) res.raise_for_status() @@ -70,6 +73,25 @@ def test_lifecycle__nominal( task = wait_task_completion(client, user_access_token, task_id) assert task.status == TaskStatus.COMPLETED, task + # Copies the study, to convert it into a managed one. + res = client.post( + f"/v1/studies/{study_id}/copy", + headers={"Authorization": f"Bearer {user_access_token}"}, + params={"dest": "default", "with_outputs": False, "use_task": False}, # type: ignore + ) + assert res.status_code == 201, res.json() + study_id = res.json() + + if study_type == "variant": + # Create Variant + res = client.post( + f"/v1/studies/{study_id}/variants", + headers=user_headers, + params={"name": "Variant 1"}, + ) + assert res.status_code in {200, 201}, res.json() + study_id = res.json() + # ============================= # SHORT-TERM STORAGE CREATION # ============================= @@ -85,7 +107,7 @@ def test_lifecycle__nominal( for attempt in attempts: res = client.post( f"/v1/studies/{study_id}/areas/{area_id}/storages", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json=attempt, ) assert res.status_code == 422, res.json() @@ -102,7 +124,7 @@ def test_lifecycle__nominal( } res = client.post( f"/v1/studies/{study_id}/areas/{area_id}/storages", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json=siemens_properties, ) assert res.status_code == 200, res.json() @@ -114,7 +136,7 @@ def test_lifecycle__nominal( # reading the properties of a short-term storage res = client.get( f"/v1/studies/{study_id}/areas/{area_id}/storages/{siemens_battery_id}", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, ) assert res.status_code == 200, res.json() assert res.json() == siemens_config @@ -128,7 +150,7 @@ def test_lifecycle__nominal( array_list = array.tolist() res = client.put( f"/v1/studies/{study_id}/areas/{area_id}/storages/{siemens_battery_id}/series/inflows", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json={ "index": list(range(array.shape[0])), "columns": list(range(array.shape[1])), @@ -141,7 +163,7 @@ def test_lifecycle__nominal( # reading the matrix of a short-term storage res = client.get( f"/v1/studies/{study_id}/areas/{area_id}/storages/{siemens_battery_id}/series/inflows", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, ) assert res.status_code == 200, res.json() matrix = res.json() @@ -151,7 +173,7 @@ def test_lifecycle__nominal( # validating the matrices of a short-term storage res = client.get( f"/v1/studies/{study_id}/areas/{area_id}/storages/{siemens_battery_id}/validate", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, ) assert res.status_code == 200, res.json() assert res.json() is True @@ -163,7 +185,7 @@ def test_lifecycle__nominal( # Reading the list of short-term storages res = client.get( f"/v1/studies/{study_id}/areas/{area_id}/storages", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, ) assert res.status_code == 200, res.json() assert res.json() == [siemens_config] @@ -171,7 +193,7 @@ def test_lifecycle__nominal( # updating properties res = client.patch( f"/v1/studies/{study_id}/areas/{area_id}/storages/{siemens_battery_id}", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json={ "name": "New Siemens Battery", "reservoirCapacity": 2500, @@ -187,7 +209,7 @@ def test_lifecycle__nominal( res = client.get( f"/v1/studies/{study_id}/areas/{area_id}/storages/{siemens_battery_id}", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, ) assert res.status_code == 200, res.json() assert res.json() == siemens_config @@ -199,7 +221,7 @@ def test_lifecycle__nominal( # updating properties res = client.patch( f"/v1/studies/{study_id}/areas/{area_id}/storages/{siemens_battery_id}", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json={ "initialLevel": 0.59, "reservoirCapacity": 0, @@ -219,7 +241,7 @@ def test_lifecycle__nominal( bad_properties = {"efficiency": 2.0} res = client.patch( f"/v1/studies/{study_id}/areas/{area_id}/storages/{siemens_battery_id}", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json=bad_properties, ) assert res.status_code == 422, res.json() @@ -228,7 +250,7 @@ def test_lifecycle__nominal( # The short-term storage properties should not have been updated. res = client.get( f"/v1/studies/{study_id}/areas/{area_id}/storages/{siemens_battery_id}", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, ) assert res.status_code == 200, res.json() assert res.json() == siemens_config @@ -267,7 +289,7 @@ def test_lifecycle__nominal( res = client.request( "DELETE", f"/v1/studies/{study_id}/areas/{area_id}/storages", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json=[siemens_battery_id], ) assert res.status_code == 204, res.json() @@ -277,7 +299,7 @@ def test_lifecycle__nominal( res = client.request( "DELETE", f"/v1/studies/{study_id}/areas/{area_id}/storages", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json=[], ) assert res.status_code == 204, res.json() @@ -297,7 +319,7 @@ def test_lifecycle__nominal( } res = client.post( f"/v1/studies/{study_id}/areas/{area_id}/storages", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json=siemens_properties, ) assert res.status_code == 200, res.json() @@ -316,7 +338,7 @@ def test_lifecycle__nominal( } res = client.post( f"/v1/studies/{study_id}/areas/{area_id}/storages", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json=grand_maison_properties, ) assert res.status_code == 200, res.json() @@ -326,7 +348,7 @@ def test_lifecycle__nominal( # Reading the list of short-term storages res = client.get( f"/v1/studies/{study_id}/areas/{area_id}/storages", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, ) assert res.status_code == 200, res.json() siemens_config = {**DEFAULT_PROPERTIES, **siemens_properties, "id": siemens_battery_id} @@ -337,7 +359,7 @@ def test_lifecycle__nominal( res = client.request( "DELETE", f"/v1/studies/{study_id}/areas/{area_id}/storages", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json=[grand_maison_id, duplicated_config["id"]], ) assert res.status_code == 204, res.json() @@ -346,7 +368,7 @@ def test_lifecycle__nominal( # Only one st-storage should remain. res = client.get( f"/v1/studies/{study_id}/areas/{area_id}/storages", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, ) assert res.status_code == 200, res.json() assert len(res.json()) == 1 @@ -360,25 +382,21 @@ def test_lifecycle__nominal( res = client.request( "DELETE", f"/v1/studies/{study_id}/areas/{bad_area_id}/storages", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json=[siemens_battery_id], ) - assert res.status_code == 500, res.json() + assert res.status_code == 404 obj = res.json() - description = obj["description"] - assert bad_area_id in description - assert re.search( - r"CommandName.REMOVE_ST_STORAGE", - description, - flags=re.IGNORECASE, - ) + + assert obj["description"] == f"Area is not found: '{bad_area_id}'" + assert obj["exception"] == "AreaNotFound" # Check delete with the wrong value of `study_id` bad_study_id = "bad_study" res = client.request( "DELETE", f"/v1/studies/{bad_study_id}/areas/{area_id}/storages", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json=[siemens_battery_id], ) obj = res.json() @@ -389,7 +407,7 @@ def test_lifecycle__nominal( # Check get with wrong `area_id` res = client.get( f"/v1/studies/{study_id}/areas/{bad_area_id}/storages/{siemens_battery_id}", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, ) obj = res.json() description = obj["description"] @@ -399,7 +417,7 @@ def test_lifecycle__nominal( # Check get with wrong `study_id` res = client.get( f"/v1/studies/{bad_study_id}/areas/{area_id}/storages/{siemens_battery_id}", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, ) obj = res.json() description = obj["description"] @@ -409,7 +427,7 @@ def test_lifecycle__nominal( # Check POST with wrong `study_id` res = client.post( f"/v1/studies/{bad_study_id}/areas/{area_id}/storages", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json={"name": siemens_battery, "group": "Battery"}, ) obj = res.json() @@ -420,20 +438,18 @@ def test_lifecycle__nominal( # Check POST with wrong `area_id` res = client.post( f"/v1/studies/{study_id}/areas/{bad_area_id}/storages", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json={"name": siemens_battery, "group": "Battery"}, ) - assert res.status_code == 500, res.json() + assert res.status_code == 404 obj = res.json() - description = obj["description"] - assert bad_area_id in description - assert re.search(r"Area ", description, flags=re.IGNORECASE) - assert re.search(r"does not exist ", description, flags=re.IGNORECASE) + assert obj["description"] == f"Area is not found: '{bad_area_id}'" + assert obj["exception"] == "AreaNotFound" # Check POST with wrong `group` res = client.post( f"/v1/studies/{study_id}/areas/{area_id}/storages", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json={"name": siemens_battery, "group": "GroupFoo"}, ) assert res.status_code == 422, res.json() @@ -444,33 +460,30 @@ def test_lifecycle__nominal( # Check PATCH with the wrong `area_id` res = client.patch( f"/v1/studies/{study_id}/areas/{bad_area_id}/storages/{siemens_battery_id}", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json={"efficiency": 1.0}, ) - assert res.status_code == 404, res.json() + assert res.status_code == 404 obj = res.json() - description = obj["description"] - assert bad_area_id in description - assert re.search(r"not a child of ", description, flags=re.IGNORECASE) + assert obj["description"] == f"Area is not found: '{bad_area_id}'" + assert obj["exception"] == "AreaNotFound" # Check PATCH with the wrong `storage_id` bad_storage_id = "bad_storage" res = client.patch( f"/v1/studies/{study_id}/areas/{area_id}/storages/{bad_storage_id}", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json={"efficiency": 1.0}, ) - assert res.status_code == 404, res.json() + assert res.status_code == 404 obj = res.json() - description = obj["description"] - assert bad_storage_id in description - assert re.search(r"fields of storage", description, flags=re.IGNORECASE) - assert re.search(r"not found", description, flags=re.IGNORECASE) + assert obj["description"] == f"Short-term storage '{bad_storage_id}' not found in area '{area_id}'" + assert obj["exception"] == "STStorageNotFoundError" # Check PATCH with the wrong `study_id` res = client.patch( f"/v1/studies/{bad_study_id}/areas/{area_id}/storages/{siemens_battery_id}", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json={"efficiency": 1.0}, ) assert res.status_code == 404, res.json() @@ -478,7 +491,7 @@ def test_lifecycle__nominal( description = obj["description"] assert bad_study_id in description - # Cannot duplicate a fake st-storage + # Cannot duplicate a unknown st-storage unknown_id = "unknown" res = client.post( f"/v1/studies/{study_id}/areas/{area_id}/storages/{unknown_id}", @@ -502,11 +515,8 @@ def test_lifecycle__nominal( assert siemens_battery.lower() in description assert obj["exception"] == "ClusterAlreadyExists" - def test__default_values( - self, - client: TestClient, - user_access_token: str, - ) -> None: + @pytest.mark.parametrize("study_type", ["raw", "variant"]) + def test__default_values(self, client: TestClient, user_access_token: str, study_type: str) -> None: """ The purpose of this integration test is to test the default values of the properties of a short-term storage. @@ -516,18 +526,29 @@ def test__default_values( Then the short-term storage is created with initialLevel = 0.0, and initialLevelOptim = False. """ # Create a new study in version 860 (or higher) + user_headers = {"Authorization": f"Bearer {user_access_token}"} res = client.post( "/v1/studies", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, params={"name": "MyStudy", "version": 860}, ) assert res.status_code in {200, 201}, res.json() study_id = res.json() + if study_type == "variant": + # Create Variant + res = client.post( + f"/v1/studies/{study_id}/variants", + headers=user_headers, + params={"name": "Variant 1"}, + ) + assert res.status_code in {200, 201}, res.json() + study_id = res.json() + # Create a new area named "FR" res = client.post( f"/v1/studies/{study_id}/areas", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json={"name": "FR", "type": "AREA"}, ) assert res.status_code in {200, 201}, res.json() @@ -537,7 +558,7 @@ def test__default_values( tesla_battery = "Tesla Battery" res = client.post( f"/v1/studies/{study_id}/areas/{area_id}/storages", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json={"name": tesla_battery, "group": "Battery"}, ) assert res.status_code == 200, res.json() @@ -549,7 +570,7 @@ def test__default_values( # are properly set in the configuration file. res = client.get( f"/v1/studies/{study_id}/raw", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, params={"path": f"input/st-storage/clusters/{area_id}/list/{tesla_battery_id}"}, ) assert res.status_code == 200, res.json() @@ -564,7 +585,7 @@ def test__default_values( # Create a variant of the study res = client.post( f"/v1/studies/{study_id}/variants", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, params={"name": "MyVariant"}, ) assert res.status_code in {200, 201}, res.json() @@ -574,7 +595,7 @@ def test__default_values( siemens_battery = "Siemens Battery" res = client.post( f"/v1/studies/{variant_id}/areas/{area_id}/storages", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json={"name": siemens_battery, "group": "Battery"}, ) assert res.status_code == 200, res.json() @@ -582,7 +603,7 @@ def test__default_values( # Check the variant commands res = client.get( f"/v1/studies/{variant_id}/commands", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, ) assert res.status_code == 200, res.json() commands = res.json() @@ -608,7 +629,7 @@ def test__default_values( siemens_battery_id = transform_name_to_id(siemens_battery) res = client.patch( f"/v1/studies/{variant_id}/areas/{area_id}/storages/{siemens_battery_id}", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json={"initialLevel": 0.5}, ) assert res.status_code == 200, res.json() @@ -616,7 +637,7 @@ def test__default_values( # Check the variant commands res = client.get( f"/v1/studies/{variant_id}/commands", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, ) assert res.status_code == 200, res.json() commands = res.json() @@ -636,7 +657,7 @@ def test__default_values( # Update the initialLevel property of the "Siemens Battery" short-term storage back to 0 res = client.patch( f"/v1/studies/{variant_id}/areas/{area_id}/storages/{siemens_battery_id}", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, json={"initialLevel": 0.0, "injectionNominalCapacity": 1600}, ) assert res.status_code == 200, res.json() @@ -644,7 +665,7 @@ def test__default_values( # Check the variant commands res = client.get( f"/v1/studies/{variant_id}/commands", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, ) assert res.status_code == 200, res.json() commands = res.json() @@ -671,7 +692,7 @@ def test__default_values( # are properly set in the configuration file. res = client.get( f"/v1/studies/{variant_id}/raw", - headers={"Authorization": f"Bearer {user_access_token}"}, + headers=user_headers, params={"path": f"input/st-storage/clusters/{area_id}/list/{siemens_battery_id}"}, ) assert res.status_code == 200, res.json() diff --git a/tests/study/business/areas/test_st_storage_management.py b/tests/study/business/areas/test_st_storage_management.py index 646dc26c78..5c3e7e660c 100644 --- a/tests/study/business/areas/test_st_storage_management.py +++ b/tests/study/business/areas/test_st_storage_management.py @@ -11,16 +11,19 @@ from sqlalchemy.orm.session import Session # type: ignore from antarest.core.exceptions import ( + AreaNotFound, STStorageConfigNotFoundError, STStorageFieldsNotFoundError, STStorageMatrixNotFoundError, + STStorageNotFoundError, ) from antarest.core.model import PublicMode from antarest.login.model import Group, User from antarest.study.business.areas.st_storage_management import STStorageInput, STStorageManager from antarest.study.model import RawStudy, Study, StudyContentStatus from antarest.study.storage.rawstudy.ini_reader import IniReader -from antarest.study.storage.rawstudy.model.filesystem.config.st_storage import STStorageGroup +from antarest.study.storage.rawstudy.model.filesystem.config.model import Area, FileStudyTreeConfig +from antarest.study.storage.rawstudy.model.filesystem.config.st_storage import STStorageConfig, STStorageGroup from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy from antarest.study.storage.rawstudy.model.filesystem.ini_file_node import IniFileNode from antarest.study.storage.rawstudy.model.filesystem.root.filestudytree import FileStudyTree @@ -287,11 +290,29 @@ def test_update_storage__nominal_case( get_node=Mock(return_value=ini_file_node), ) + area = Mock(spec=Area) + mock_config = Mock(spec=FileStudyTreeConfig, study_id=study.id) + file_study.config = mock_config + # Given the following arguments manager = STStorageManager(study_storage_service) - - # Run the method being tested edit_form = STStorageInput(initial_level=0, initial_level_optim=False) + + # Test behavior for area not in study + mock_config.areas = {"fake_area": area} + with pytest.raises(AreaNotFound) as ctx: + manager.update_storage(study, area_id="West", storage_id="storage1", form=edit_form) + assert ctx.value.detail == "Area is not found: 'West'" + + # Test behavior for st_storage not in study + mock_config.areas = {"West": area} + area.st_storages = [STStorageConfig(name="fake_name", group="battery")] + with pytest.raises(STStorageNotFoundError) as ctx: + manager.update_storage(study, area_id="West", storage_id="storage1", form=edit_form) + assert ctx.value.detail == "Short-term storage 'storage1' not found in area 'West'" + + # Test behavior for nominal case + area.st_storages = [STStorageConfig(name="storage1", group="battery")] manager.update_storage(study, area_id="West", storage_id="storage1", form=edit_form) # Assert that the storage fields have been updated From d90c5ccf0f3706388508b7960a040053a715ec21 Mon Sep 17 00:00:00 2001 From: MartinBelthle <102529366+MartinBelthle@users.noreply.github.com> Date: Sat, 9 Mar 2024 20:11:37 +0100 Subject: [PATCH 048/248] fix(variants): avoid Recursive error when creating big variant tree (#1967) --- .../storage/variantstudy/business/utils.py | 9 ++-- .../study/storage/variantstudy/model/model.py | 53 +++++++++++++++---- .../studies_blueprint/test_synthesis.py | 2 +- .../variant_blueprint/test_variant_manager.py | 13 +++++ 4 files changed, 64 insertions(+), 13 deletions(-) diff --git a/antarest/study/storage/variantstudy/business/utils.py b/antarest/study/storage/variantstudy/business/utils.py index 6f04601ec5..933c72bed7 100644 --- a/antarest/study/storage/variantstudy/business/utils.py +++ b/antarest/study/storage/variantstudy/business/utils.py @@ -52,10 +52,13 @@ def get_or_create_section(json_ini: JSON, section: str) -> JSON: def remove_none_args(command_dto: CommandDTO) -> CommandDTO: - if isinstance(command_dto.args, list): - command_dto.args = [{k: v for k, v in args.items() if v is not None} for args in command_dto.args] + args = command_dto.args + if isinstance(args, list): + command_dto.args = [{k: v for k, v in args.items() if v is not None} for args in args] + elif isinstance(args, dict): + command_dto.args = {k: v for k, v in args.items() if v is not None} else: - command_dto.args = {k: v for k, v in command_dto.args.items() if v is not None} + raise TypeError(f"Invalid type for args: {type(args)}") return command_dto diff --git a/antarest/study/storage/variantstudy/model/model.py b/antarest/study/storage/variantstudy/model/model.py index 1e51032ce4..cd478742b4 100644 --- a/antarest/study/storage/variantstudy/model/model.py +++ b/antarest/study/storage/variantstudy/model/model.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple, Union +import typing as t from pydantic import BaseModel @@ -7,28 +7,63 @@ class GenerationResultInfoDTO(BaseModel): + """ + Result information of a snapshot generation process. + + Attributes: + success: A boolean indicating whether the generation process was successful. + details: A list of tuples containing detailed information about the generation process. + """ + success: bool - details: List[Tuple[str, bool, str]] + details: t.MutableSequence[t.Tuple[str, bool, str]] class CommandDTO(BaseModel): - id: Optional[str] + """ + This class represents a command. + + Attributes: + id: The unique identifier of the command. + action: The action to be performed by the command. + args: The arguments for the command action. + version: The version of the command. + """ + + id: t.Optional[str] action: str - # if args is a list, this mean the command will be mapped to the list of args - args: Union[List[JSON], JSON] + args: t.Union[t.MutableSequence[JSON], JSON] version: int = 1 class CommandResultDTO(BaseModel): + """ + This class represents the result of a command. + + Attributes: + study_id: The unique identifier of the study. + id: The unique identifier of the command. + success: A boolean indicating whether the command was successful. + message: A message detailing the result of the command. + """ + study_id: str id: str success: bool message: str -class VariantTreeDTO(BaseModel): - node: StudyMetadataDTO - children: List["VariantTreeDTO"] +class VariantTreeDTO: + """ + This class represents a variant tree structure. + Attributes: + node: The metadata of the study (ID, name, version, etc.). + children: A list of variant children. + """ -VariantTreeDTO.update_forward_refs() + def __init__(self, node: StudyMetadataDTO, children: t.MutableSequence["VariantTreeDTO"]) -> None: + # We are intentionally not using Pydantic’s `BaseModel` here to prevent potential + # `RecursionError` exceptions that can occur with Pydantic versions before v2. + self.node = node + self.children = children or [] diff --git a/tests/integration/studies_blueprint/test_synthesis.py b/tests/integration/studies_blueprint/test_synthesis.py index 9afd66be9b..059fba2aa7 100644 --- a/tests/integration/studies_blueprint/test_synthesis.py +++ b/tests/integration/studies_blueprint/test_synthesis.py @@ -58,7 +58,7 @@ def test_raw_study( ) assert res.status_code == 200, res.json() duration = time.time() - start - assert 0 <= duration <= 0.1, f"Duration is {duration} seconds" + assert 0 <= duration <= 0.3, f"Duration is {duration} seconds" def test_variant_study( self, diff --git a/tests/integration/variant_blueprint/test_variant_manager.py b/tests/integration/variant_blueprint/test_variant_manager.py index df3cf590e4..8a300e75da 100644 --- a/tests/integration/variant_blueprint/test_variant_manager.py +++ b/tests/integration/variant_blueprint/test_variant_manager.py @@ -238,3 +238,16 @@ def test_comments(client: TestClient, admin_access_token: str, variant_id: str) # Asserts comments did not disappear res = client.get(f"/v1/studies/{variant_id}/comments", headers=admin_headers) assert res.json() == comment + + +def test_recursive_variant_tree(client: TestClient, admin_access_token: str): + admin_headers = {"Authorization": f"Bearer {admin_access_token}"} + base_study_res = client.post("/v1/studies?name=foo", headers=admin_headers) + base_study_id = base_study_res.json() + parent_id = base_study_res.json() + for k in range(150): + res = client.post(f"/v1/studies/{base_study_id}/variants?name=variant_{k}", headers=admin_headers) + base_study_id = res.json() + # Asserts that we do not trigger a Recursive Exception + res = client.get(f"/v1/studies/{parent_id}/variants", headers=admin_headers) + assert res.status_code == 200 From c60a318e0b1dc0e1489612d856000d8806f9ac84 Mon Sep 17 00:00:00 2001 From: MartinBelthle <102529366+MartinBelthle@users.noreply.github.com> Date: Mon, 11 Mar 2024 10:40:16 +0100 Subject: [PATCH 049/248] fix(xpansion): catch Exception when no sensitvity folder in xpansion (#1961) Resolves [ANT-1216] --- .../study/business/xpansion_management.py | 2 +- antarest/study/storage/rawstudy/ini_reader.py | 2 +- .../rawstudy/model/filesystem/bucket_node.py | 46 ++++--- .../rawstudy/model/filesystem/folder_node.py | 32 ++--- .../model/filesystem/json_file_node.py | 40 ++++++- .../root/user/expansion/expansion.py | 6 +- .../study/storage/study_upgrader/__init__.py | 47 +++----- .../storage/business/test_xpansion_manager.py | 101 ++++++---------- .../repository/filesystem/test_folder_node.py | 113 ++++++++++++++++-- 9 files changed, 230 insertions(+), 159 deletions(-) diff --git a/antarest/study/business/xpansion_management.py b/antarest/study/business/xpansion_management.py index f3adadad32..1bb80cfbaf 100644 --- a/antarest/study/business/xpansion_management.py +++ b/antarest/study/business/xpansion_management.py @@ -365,7 +365,7 @@ def get_xpansion_settings(self, study: Study) -> GetXpansionSettings: logger.info(f"Getting xpansion settings for study '{study.id}'") file_study = self.study_storage_service.get_storage(study).get_raw(study) config_obj = file_study.tree.get(["user", "expansion", "settings"]) - with contextlib.suppress(KeyError): + with contextlib.suppress(ChildNotFoundError): config_obj["sensitivity_config"] = file_study.tree.get( ["user", "expansion", "sensitivity", "sensitivity_in"] ) diff --git a/antarest/study/storage/rawstudy/ini_reader.py b/antarest/study/storage/rawstudy/ini_reader.py index f145b948a9..84be7c099b 100644 --- a/antarest/study/storage/rawstudy/ini_reader.py +++ b/antarest/study/storage/rawstudy/ini_reader.py @@ -109,7 +109,7 @@ def read(self, path: t.Any) -> JSON: sections = self._parse_ini_file(f) except FileNotFoundError: # If the file is missing, an empty dictionary is returned. - # This is required tp mimic the behavior of `configparser.ConfigParser`. + # This is required to mimic the behavior of `configparser.ConfigParser`. return {} elif hasattr(path, "read"): diff --git a/antarest/study/storage/rawstudy/model/filesystem/bucket_node.py b/antarest/study/storage/rawstudy/model/filesystem/bucket_node.py index 3415edd1c3..6106326d2f 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/bucket_node.py +++ b/antarest/study/storage/rawstudy/model/filesystem/bucket_node.py @@ -1,4 +1,4 @@ -from typing import Any, Callable, Dict, List, Optional +import typing as t from antarest.core.model import JSON, SUB_JSON from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfig @@ -12,7 +12,7 @@ class RegisteredFile: def __init__( self, key: str, - node: Optional[Callable[[ContextServer, FileStudyTreeConfig], INode[Any, Any, Any]]], + node: t.Optional[t.Callable[[ContextServer, FileStudyTreeConfig], INode[t.Any, t.Any, t.Any]]], filename: str = "", ): self.key = key @@ -29,42 +29,36 @@ def __init__( self, context: ContextServer, config: FileStudyTreeConfig, - registered_files: Optional[List[RegisteredFile]] = None, - default_file_node: Callable[..., INode[Any, Any, Any]] = RawFileNode, + registered_files: t.Optional[t.List[RegisteredFile]] = None, + default_file_node: t.Callable[..., INode[t.Any, t.Any, t.Any]] = RawFileNode, ): super().__init__(context, config) - self.registered_files: List[RegisteredFile] = registered_files or [] - self.default_file_node: Callable[..., INode[Any, Any, Any]] = default_file_node + self.registered_files: t.List[RegisteredFile] = registered_files or [] + self.default_file_node: t.Callable[..., INode[t.Any, t.Any, t.Any]] = default_file_node - def _get_registered_file(self, key: str) -> Optional[RegisteredFile]: - for registered_file in self.registered_files: - if registered_file.key == key: - return registered_file - return None + def _get_registered_file_by_key(self, key: str) -> t.Optional[RegisteredFile]: + return next((rf for rf in self.registered_files if rf.key == key), None) - def _get_registered_file_from_filename(self, filename: str) -> Optional[RegisteredFile]: - for registered_file in self.registered_files: - if registered_file.filename == filename: - return registered_file - return None + def _get_registered_file_by_filename(self, filename: str) -> t.Optional[RegisteredFile]: + return next((rf for rf in self.registered_files if rf.filename == filename), None) def save( self, data: SUB_JSON, - url: Optional[List[str]] = None, + url: t.Optional[t.List[str]] = None, ) -> None: self._assert_not_in_zipped_file() if not self.config.path.exists(): self.config.path.mkdir() - if url is None or len(url) == 0: - assert isinstance(data, Dict) + if not url: + assert isinstance(data, dict) for key, value in data.items(): self._save(value, key) else: key = url[0] if len(url) > 1: - registered_file = self._get_registered_file(key) + registered_file = self._get_registered_file_by_key(key) if registered_file: node = registered_file.node or self.default_file_node node(self.context, self.config.next_file(key)).save(data, url[1:]) @@ -74,7 +68,7 @@ def save( self._save(data, key) def _save(self, data: SUB_JSON, key: str) -> None: - registered_file = self._get_registered_file(key) + registered_file = self._get_registered_file_by_key(key) if registered_file: node, filename = ( registered_file.node or self.default_file_node, @@ -88,12 +82,12 @@ def _save(self, data: SUB_JSON, key: str) -> None: BucketNode(self.context, self.config.next_file(key)).save(data) def build(self) -> TREE: - if not self.config.path.exists(): - return dict() + if not self.config.path.is_dir(): + return {} children: TREE = {} for item in sorted(self.config.path.iterdir()): - registered_file = self._get_registered_file_from_filename(item.name) + registered_file = self._get_registered_file_by_filename(item.name) if registered_file: node = registered_file.node or self.default_file_node children[registered_file.key] = node(self.context, self.config.next_file(item.name)) @@ -107,7 +101,7 @@ def build(self) -> TREE: 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]: return [] diff --git a/antarest/study/storage/rawstudy/model/filesystem/folder_node.py b/antarest/study/storage/rawstudy/model/filesystem/folder_node.py index 7d174cfbc6..3ea51c098d 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/folder_node.py +++ b/antarest/study/storage/rawstudy/model/filesystem/folder_node.py @@ -1,7 +1,7 @@ import shutil +import typing as t from abc import ABC, abstractmethod from http import HTTPStatus -from typing import Dict, List, Optional, Tuple, Union from fastapi import HTTPException @@ -38,7 +38,7 @@ def __init__( self, context: ContextServer, config: FileStudyTreeConfig, - children_glob_exceptions: Optional[List[str]] = None, + children_glob_exceptions: t.Optional[t.List[str]] = None, ) -> None: super().__init__(config) self.context = context @@ -50,11 +50,11 @@ def build(self) -> TREE: def _forward_get( self, - url: List[str], + url: t.List[str], depth: int = -1, formatted: bool = True, get_node: bool = False, - ) -> Union[JSON, INode[JSON, SUB_JSON, JSON]]: + ) -> t.Union[JSON, INode[JSON, SUB_JSON, JSON]]: children = self.build() names, sub_url = self.extract_child(children, url) @@ -84,7 +84,7 @@ def _forward_get( def _expand_get( self, depth: int = -1, formatted: bool = True, get_node: bool = False - ) -> Union[JSON, INode[JSON, SUB_JSON, JSON]]: + ) -> t.Union[JSON, INode[JSON, SUB_JSON, JSON]]: if get_node: return self @@ -99,11 +99,11 @@ def _expand_get( def _get( self, - url: Optional[List[str]] = None, + url: t.Optional[t.List[str]] = None, depth: int = -1, formatted: bool = True, get_node: bool = False, - ) -> Union[JSON, INode[JSON, SUB_JSON, JSON]]: + ) -> t.Union[JSON, INode[JSON, SUB_JSON, JSON]]: if url and url != [""]: return self._forward_get(url, depth, formatted, get_node) else: @@ -111,7 +111,7 @@ def _get( def get( self, - url: Optional[List[str]] = None, + url: t.Optional[t.List[str]] = None, depth: int = -1, expanded: bool = False, formatted: bool = True, @@ -122,7 +122,7 @@ def get( def get_node( self, - url: Optional[List[str]] = None, + url: t.Optional[t.List[str]] = None, ) -> INode[JSON, SUB_JSON, JSON]: output = self._get(url=url, get_node=True) assert isinstance(output, INode) @@ -131,7 +131,7 @@ def get_node( def save( self, data: SUB_JSON, - url: Optional[List[str]] = None, + url: t.Optional[t.List[str]] = None, ) -> None: self._assert_not_in_zipped_file() children = self.build() @@ -146,7 +146,7 @@ def save( for key in data: children[key].save(data[key]) - def delete(self, url: Optional[List[str]] = None) -> None: + def delete(self, url: t.Optional[t.List[str]] = None) -> None: if url and url != [""]: children = self.build() names, sub_url = self.extract_child(children, url) @@ -158,16 +158,16 @@ 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]: children = self.build() if url and url != [""]: (name,), sub_url = self.extract_child(children, url) return children[name].check_errors(data, sub_url, raising) else: - errors: List[str] = [] + errors: t.List[str] = [] for key in data: if key not in children: msg = f"key={key} not in {list(children.keys())} for {self.__class__.__name__}" @@ -186,7 +186,7 @@ def denormalize(self) -> None: for child in self.build().values(): child.denormalize() - def extract_child(self, children: TREE, url: List[str]) -> Tuple[List[str], List[str]]: + def extract_child(self, children: TREE, url: t.List[str]) -> t.Tuple[t.List[str], t.List[str]]: names, sub_url = url[0].split(","), url[1:] names = ( list( @@ -208,6 +208,6 @@ def extract_child(self, children: TREE, url: List[str]) -> Tuple[List[str], List for name in names: if name not in children: raise ChildNotFoundError(f"'{name}' not a child of {self.__class__.__name__}") - if type(children[name]) != child_class: + if not isinstance(children[name], child_class): raise FilterError("Filter selection has different classes") return names, sub_url 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 ee14da91ea..80f607485d 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/json_file_node.py +++ b/antarest/study/storage/rawstudy/model/filesystem/json_file_node.py @@ -1,6 +1,6 @@ import json +import typing as t from pathlib import Path -from typing import Any, Dict, Optional, cast from antarest.core.model import JSON from antarest.study.storage.rawstudy.ini_reader import IReader @@ -11,13 +11,41 @@ class JsonReader(IReader): - def read(self, path: Any) -> JSON: - if isinstance(path, Path): - return cast(JSON, json.loads(path.read_text(encoding="utf-8"))) - return cast(JSON, json.loads(path)) + """ + JSON file reader. + """ + + def read(self, path: t.Any) -> JSON: + content: t.Union[str, bytes] + + if isinstance(path, (Path, str)): + try: + with open(path, mode="r", encoding="utf-8") as f: + content = f.read() + except FileNotFoundError: + # If the file is missing, an empty dictionary is returned, + # to mimic the behavior of `configparser.ConfigParser`. + return {} + + elif hasattr(path, "read"): + with path: + content = path.read() + + else: # pragma: no cover + raise TypeError(repr(type(path))) + + try: + return t.cast(JSON, json.loads(content)) + except json.JSONDecodeError as exc: + err_msg = f"Failed to parse JSON file '{path}'" + raise ValueError(err_msg) from exc class JsonWriter(IniWriter): + """ + JSON file writer. + """ + def write(self, data: JSON, path: Path) -> None: with open(path, "w") as fh: json.dump(data, fh) @@ -28,6 +56,6 @@ def __init__( self, context: ContextServer, config: FileStudyTreeConfig, - types: Optional[Dict[str, Any]] = None, + types: t.Optional[t.Dict[str, t.Any]] = None, ) -> None: super().__init__(context, config, types, JsonReader(), JsonWriter()) diff --git a/antarest/study/storage/rawstudy/model/filesystem/root/user/expansion/expansion.py b/antarest/study/storage/rawstudy/model/filesystem/root/user/expansion/expansion.py index 2b7414234f..c38246f6cf 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/root/user/expansion/expansion.py +++ b/antarest/study/storage/rawstudy/model/filesystem/root/user/expansion/expansion.py @@ -14,11 +14,7 @@ class Expansion(BucketNode): registered_files = [ - RegisteredFile( - key="candidates", - node=ExpansionCandidates, - filename="candidates.ini", - ), + RegisteredFile(key="candidates", node=ExpansionCandidates, filename="candidates.ini"), RegisteredFile(key="settings", node=ExpansionSettings, filename="settings.ini"), RegisteredFile(key="capa", node=ExpansionMatrixResources), RegisteredFile(key="weights", node=ExpansionMatrixResources), diff --git a/antarest/study/storage/study_upgrader/__init__.py b/antarest/study/storage/study_upgrader/__init__.py index 1993b4a0c3..3f53def629 100644 --- a/antarest/study/storage/study_upgrader/__init__.py +++ b/antarest/study/storage/study_upgrader/__init__.py @@ -3,10 +3,10 @@ import shutil import tempfile import time +import typing as t from http import HTTPStatus from http.client import HTTPException from pathlib import Path -from typing import Callable, List, NamedTuple from antarest.core.exceptions import StudyValidationError @@ -23,40 +23,27 @@ logger = logging.getLogger(__name__) -class UpgradeMethod(NamedTuple): +class UpgradeMethod(t.NamedTuple): """Raw study upgrade method (old version, new version, upgrade function).""" old: str new: str - method: Callable[[Path], None] - files: List[Path] + method: t.Callable[[Path], None] + files: t.List[Path] +_GENERAL_DATA_PATH = Path("settings/generaldata.ini") + UPGRADE_METHODS = [ - UpgradeMethod("700", "710", upgrade_710, [Path("settings/generaldata.ini")]), + UpgradeMethod("700", "710", upgrade_710, [_GENERAL_DATA_PATH]), UpgradeMethod("710", "720", upgrade_720, []), - UpgradeMethod("720", "800", upgrade_800, [Path("settings/generaldata.ini")]), - UpgradeMethod( - "800", - "810", - upgrade_810, - [Path("settings/generaldata.ini"), Path("input")], - ), + UpgradeMethod("720", "800", upgrade_800, [_GENERAL_DATA_PATH]), + UpgradeMethod("800", "810", upgrade_810, [_GENERAL_DATA_PATH, Path("input")]), UpgradeMethod("810", "820", upgrade_820, [Path("input/links")]), - UpgradeMethod( - "820", - "830", - upgrade_830, - [Path("settings/generaldata.ini"), Path("input/areas")], - ), - UpgradeMethod("830", "840", upgrade_840, [Path("settings/generaldata.ini")]), - UpgradeMethod("840", "850", upgrade_850, [Path("settings/generaldata.ini")]), - UpgradeMethod( - "850", - "860", - upgrade_860, - [Path("input"), Path("settings/generaldata.ini")], - ), + UpgradeMethod("820", "830", upgrade_830, [_GENERAL_DATA_PATH, Path("input/areas")]), + UpgradeMethod("830", "840", upgrade_840, [_GENERAL_DATA_PATH]), + UpgradeMethod("840", "850", upgrade_850, [_GENERAL_DATA_PATH]), + UpgradeMethod("850", "860", upgrade_860, [Path("input"), _GENERAL_DATA_PATH]), ] @@ -127,7 +114,7 @@ def get_current_version(study_path: Path) -> str: ) -def can_upgrade_version(from_version: str, to_version: str) -> List[Path]: +def can_upgrade_version(from_version: str, to_version: str) -> t.List[Path]: """ Checks if upgrading from one version to another is possible. @@ -190,7 +177,7 @@ def _update_study_antares_file(target_version: str, study_path: Path) -> None: file.write_text(content, encoding="utf-8") -def _copies_only_necessary_files(files_to_upgrade: List[Path], study_path: Path, tmp_path: Path) -> List[Path]: +def _copies_only_necessary_files(files_to_upgrade: t.List[Path], study_path: Path, tmp_path: Path) -> t.List[Path]: """ Copies files concerned by the version upgrader into a temporary directory. Args: @@ -221,7 +208,7 @@ def _copies_only_necessary_files(files_to_upgrade: List[Path], study_path: Path, return files_to_retrieve -def _filters_out_children_files(files_to_upgrade: List[Path]) -> List[Path]: +def _filters_out_children_files(files_to_upgrade: t.List[Path]) -> t.List[Path]: """ Filters out children paths of "input" if "input" is already in the list. Args: @@ -237,7 +224,7 @@ def _filters_out_children_files(files_to_upgrade: List[Path]) -> List[Path]: return files_to_upgrade -def _replace_safely_original_files(files_to_replace: List[Path], study_path: Path, tmp_path: Path) -> None: +def _replace_safely_original_files(files_to_replace: t.List[Path], study_path: Path, tmp_path: Path) -> None: """ Replace files/folders of the study that should be upgraded by their copy already upgraded in the tmp directory. It uses Path.rename() and an intermediary tmp directory to swap the folders safely. diff --git a/tests/storage/business/test_xpansion_manager.py b/tests/storage/business/test_xpansion_manager.py index 1703325e8b..bb5651bcbd 100644 --- a/tests/storage/business/test_xpansion_manager.py +++ b/tests/storage/business/test_xpansion_manager.py @@ -81,6 +81,14 @@ def make_link_and_areas(empty_study: FileStudy) -> None: make_link(empty_study) +def set_up_xpansion_manager(tmp_path: Path) -> t.Tuple[FileStudy, RawStudy, XpansionManager]: + empty_study = make_empty_study(tmp_path, 810) + study = RawStudy(id="1", path=str(empty_study.config.study_path), version="810") + xpansion_manager = make_xpansion_manager(empty_study) + xpansion_manager.create_xpansion_configuration(study) + return empty_study, study, xpansion_manager + + @pytest.mark.unit_test @pytest.mark.parametrize( "version, expected_output", @@ -117,7 +125,7 @@ def test_create_configuration(tmp_path: Path, version: int, expected_output: JSO Test the creation of a configuration. """ empty_study = make_empty_study(tmp_path, version) - study = RawStudy(id="1", path=empty_study.config.study_path, version=version) + study = RawStudy(id="1", path=str(empty_study.config.study_path), version=str(version)) xpansion_manager = make_xpansion_manager(empty_study) with pytest.raises(ChildNotFoundError): @@ -135,7 +143,7 @@ def test_delete_xpansion_configuration(tmp_path: Path) -> None: Test the deletion of a configuration. """ empty_study = make_empty_study(tmp_path, 810) - study = RawStudy(id="1", path=empty_study.config.study_path, version=810) + study = RawStudy(id="1", path=str(empty_study.config.study_path), version="810") xpansion_manager = make_xpansion_manager(empty_study) with pytest.raises(ChildNotFoundError): @@ -183,7 +191,7 @@ def test_get_xpansion_settings(tmp_path: Path, version: int, expected_output: JS """ empty_study = make_empty_study(tmp_path, version) - study = RawStudy(id="1", path=empty_study.config.study_path, version=version) + study = RawStudy(id="1", path=str(empty_study.config.study_path), version=str(version)) xpansion_manager = make_xpansion_manager(empty_study) xpansion_manager.create_xpansion_configuration(study) @@ -197,12 +205,7 @@ def test_update_xpansion_settings(tmp_path: Path) -> None: """ Test the retrieval of the xpansion settings. """ - - empty_study = make_empty_study(tmp_path, 810) - study = RawStudy(id="1", path=empty_study.config.study_path, version=810) - xpansion_manager = make_xpansion_manager(empty_study) - - xpansion_manager.create_xpansion_configuration(study) + _, study, xpansion_manager = set_up_xpansion_manager(tmp_path) new_settings_obj = { "optimality_gap": 4.0, @@ -246,10 +249,7 @@ def test_update_xpansion_settings(tmp_path: Path) -> None: @pytest.mark.unit_test def test_add_candidate(tmp_path: Path) -> None: - empty_study = make_empty_study(tmp_path, 810) - study = RawStudy(id="1", path=empty_study.config.study_path, version=810) - xpansion_manager = make_xpansion_manager(empty_study) - xpansion_manager.create_xpansion_configuration(study) + empty_study, study, xpansion_manager = set_up_xpansion_manager(tmp_path) actual = empty_study.tree.get(["user", "expansion", "candidates"]) assert actual == {} @@ -298,10 +298,7 @@ def test_add_candidate(tmp_path: Path) -> None: @pytest.mark.unit_test def test_get_candidate(tmp_path: Path) -> None: - empty_study = make_empty_study(tmp_path, 810) - study = RawStudy(id="1", path=empty_study.config.study_path, version=810) - xpansion_manager = make_xpansion_manager(empty_study) - xpansion_manager.create_xpansion_configuration(study) + empty_study, study, xpansion_manager = set_up_xpansion_manager(tmp_path) assert empty_study.tree.get(["user", "expansion", "candidates"]) == {} @@ -334,10 +331,7 @@ def test_get_candidate(tmp_path: Path) -> None: @pytest.mark.unit_test def test_get_candidates(tmp_path: Path) -> None: - empty_study = make_empty_study(tmp_path, 810) - study = RawStudy(id="1", path=empty_study.config.study_path, version=810) - xpansion_manager = make_xpansion_manager(empty_study) - xpansion_manager.create_xpansion_configuration(study) + empty_study, study, xpansion_manager = set_up_xpansion_manager(tmp_path) assert empty_study.tree.get(["user", "expansion", "candidates"]) == {} @@ -372,10 +366,7 @@ def test_get_candidates(tmp_path: Path) -> None: @pytest.mark.unit_test def test_update_candidates(tmp_path: Path) -> None: - empty_study = make_empty_study(tmp_path, 810) - study = RawStudy(id="1", path=empty_study.config.study_path, version=810) - xpansion_manager = make_xpansion_manager(empty_study) - xpansion_manager.create_xpansion_configuration(study) + empty_study, study, xpansion_manager = set_up_xpansion_manager(tmp_path) assert empty_study.tree.get(["user", "expansion", "candidates"]) == {} @@ -406,10 +397,7 @@ def test_update_candidates(tmp_path: Path) -> None: @pytest.mark.unit_test def test_delete_candidate(tmp_path: Path) -> None: - empty_study = make_empty_study(tmp_path, 810) - study = RawStudy(id="1", path=empty_study.config.study_path, version=810) - xpansion_manager = make_xpansion_manager(empty_study) - xpansion_manager.create_xpansion_configuration(study) + empty_study, study, xpansion_manager = set_up_xpansion_manager(tmp_path) assert empty_study.tree.get(["user", "expansion", "candidates"]) == {} @@ -442,10 +430,7 @@ def test_delete_candidate(tmp_path: Path) -> None: @pytest.mark.unit_test def test_update_constraints(tmp_path: Path) -> None: - empty_study = make_empty_study(tmp_path, 810) - study = RawStudy(id="1", path=empty_study.config.study_path, version=810) - xpansion_manager = make_xpansion_manager(empty_study) - xpansion_manager.create_xpansion_configuration(study) + empty_study, study, xpansion_manager = set_up_xpansion_manager(tmp_path) with pytest.raises(XpansionFileNotFoundError): xpansion_manager.update_xpansion_constraints_settings(study=study, constraints_file_name="non_existent_file") @@ -464,10 +449,7 @@ def test_update_constraints(tmp_path: Path) -> None: @pytest.mark.unit_test def test_add_resources(tmp_path: Path) -> None: - empty_study = make_empty_study(tmp_path, 810) - study = RawStudy(id="1", path=empty_study.config.study_path, version=810) - xpansion_manager = make_xpansion_manager(empty_study) - xpansion_manager.create_xpansion_configuration(study) + empty_study, study, xpansion_manager = set_up_xpansion_manager(tmp_path) filename1 = "constraints1.txt" filename2 = "constraints2.txt" @@ -520,10 +502,8 @@ def test_add_resources(tmp_path: Path) -> None: @pytest.mark.unit_test def test_list_root_resources(tmp_path: Path) -> None: - empty_study = make_empty_study(tmp_path, 810) - study = RawStudy(id="1", path=empty_study.config.study_path, version=810) - xpansion_manager = make_xpansion_manager(empty_study) - xpansion_manager.create_xpansion_configuration(study) + empty_study, study, xpansion_manager = set_up_xpansion_manager(tmp_path) + constraints_file_content = b"0" constraints_file_name = "unknownfile.txt" @@ -533,10 +513,7 @@ def test_list_root_resources(tmp_path: Path) -> None: @pytest.mark.unit_test def test_get_single_constraints(tmp_path: Path) -> None: - empty_study = make_empty_study(tmp_path, 810) - study = RawStudy(id="1", path=empty_study.config.study_path, version=810) - xpansion_manager = make_xpansion_manager(empty_study) - xpansion_manager.create_xpansion_configuration(study) + empty_study, study, xpansion_manager = set_up_xpansion_manager(tmp_path) constraints_file_content = b"0" constraints_file_name = "constraints.txt" @@ -549,12 +526,18 @@ def test_get_single_constraints(tmp_path: Path) -> None: ) +@pytest.mark.unit_test +def test_get_settings_without_sensitivity(tmp_path: Path) -> None: + empty_study, study, xpansion_manager = set_up_xpansion_manager(tmp_path) + + empty_study.tree.delete(["user", "expansion", "sensitivity"]) + # should not fail even if the folder doesn't exist as it's optional + xpansion_manager.get_xpansion_settings(study) + + @pytest.mark.unit_test def test_get_all_constraints(tmp_path: Path) -> None: - empty_study = make_empty_study(tmp_path, 810) - study = RawStudy(id="1", path=empty_study.config.study_path, version=810) - xpansion_manager = make_xpansion_manager(empty_study) - xpansion_manager.create_xpansion_configuration(study) + _, study, xpansion_manager = set_up_xpansion_manager(tmp_path) filename1 = "constraints1.txt" filename2 = "constraints2.txt" @@ -576,10 +559,7 @@ def test_get_all_constraints(tmp_path: Path) -> None: @pytest.mark.unit_test def test_add_capa(tmp_path: Path) -> None: - empty_study = make_empty_study(tmp_path, 810) - study = RawStudy(id="1", path=empty_study.config.study_path, version=810) - xpansion_manager = make_xpansion_manager(empty_study) - xpansion_manager.create_xpansion_configuration(study) + empty_study, study, xpansion_manager = set_up_xpansion_manager(tmp_path) filename1 = "capa1.txt" filename2 = "capa2.txt" @@ -610,10 +590,7 @@ def test_add_capa(tmp_path: Path) -> None: @pytest.mark.unit_test def test_delete_capa(tmp_path: Path) -> None: - empty_study = make_empty_study(tmp_path, 810) - study = RawStudy(id="1", path=empty_study.config.study_path, version=810) - xpansion_manager = make_xpansion_manager(empty_study) - xpansion_manager.create_xpansion_configuration(study) + empty_study, study, xpansion_manager = set_up_xpansion_manager(tmp_path) filename1 = "capa1.txt" filename2 = "capa2.txt" @@ -636,10 +613,7 @@ def test_delete_capa(tmp_path: Path) -> None: @pytest.mark.unit_test def test_get_single_capa(tmp_path: Path) -> None: - empty_study = make_empty_study(tmp_path, 810) - study = RawStudy(id="1", path=empty_study.config.study_path, version=810) - xpansion_manager = make_xpansion_manager(empty_study) - xpansion_manager.create_xpansion_configuration(study) + _, study, xpansion_manager = set_up_xpansion_manager(tmp_path) filename1 = "capa1.txt" filename2 = "capa2.txt" @@ -664,10 +638,7 @@ def test_get_single_capa(tmp_path: Path) -> None: @pytest.mark.unit_test def test_get_all_capa(tmp_path: Path) -> None: - empty_study = make_empty_study(tmp_path, 810) - study = RawStudy(id="1", path=empty_study.config.study_path, version=810) - xpansion_manager = make_xpansion_manager(empty_study) - xpansion_manager.create_xpansion_configuration(study) + _, study, xpansion_manager = set_up_xpansion_manager(tmp_path) filename1 = "capa1.txt" filename2 = "capa2.txt" diff --git a/tests/storage/repository/filesystem/test_folder_node.py b/tests/storage/repository/filesystem/test_folder_node.py index a01214eedb..d08360e223 100644 --- a/tests/storage/repository/filesystem/test_folder_node.py +++ b/tests/storage/repository/filesystem/test_folder_node.py @@ -1,9 +1,14 @@ +import json +import textwrap +import typing as t from pathlib import Path from unittest.mock import Mock import pytest from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfig +from antarest.study.storage.rawstudy.model.filesystem.factory import StudyFactory +from antarest.study.storage.rawstudy.model.filesystem.folder_node import ChildNotFoundError from antarest.study.storage.rawstudy.model.filesystem.ini_file_node import IniFileNode from antarest.study.storage.rawstudy.model.filesystem.inode import INode from antarest.study.storage.rawstudy.model.filesystem.raw_file_node import RawFileNode @@ -11,7 +16,7 @@ from tests.storage.repository.filesystem.utils import TestMiddleNode, TestSubNode -def build_tree() -> INode: +def build_tree() -> INode[t.Any, t.Any, t.Any]: config = Mock() config.path.exist.return_value = True config.zip_path = None @@ -26,7 +31,7 @@ def build_tree() -> INode: @pytest.mark.unit_test -def test_get(): +def test_get() -> None: tree = build_tree() res = tree.get(["input"]) @@ -37,7 +42,97 @@ def test_get(): @pytest.mark.unit_test -def test_get_depth(): +def test_get_input_areas_sets(tmp_path: Path) -> None: + """ + Read the content of the `sets.ini` file in the `input/areas` directory. + The goal of this test is to verify the behavior of the `get` method of the `FileStudyTree` class + for the case where the subdirectories or the INI file do not exist. + """ + + study_factory = StudyFactory(Mock(), Mock(), Mock()) + study_id = "c5633166-afe1-4ce5-9305-75bc2779aad6" + file_study = study_factory.create_from_fs(tmp_path, study_id, use_cache=False) + url = ["input", "areas", "sets"] # sets.ini + + # Empty study tree structure + actual = file_study.tree.get(url) + assert actual == {} + + # Add the "settings" directory + tmp_path.joinpath("input").mkdir() + actual = file_study.tree.get(url) + assert actual == {} + + # Add the "areas" directory + tmp_path.joinpath("input/areas").mkdir() + actual = file_study.tree.get(url) + assert actual == {} + + # Add the "sets.ini" file + sets = textwrap.dedent( + """\ + [all areas] + caption = All areas + comments = Spatial aggregates on all areas + output = false + apply-filter = add-all + """ + ) + tmp_path.joinpath("input/areas/sets.ini").write_text(sets) + actual = file_study.tree.get(url) + expected = { + "all areas": { + "caption": "All areas", + "comments": "Spatial aggregates on all areas", + "output": False, + "apply-filter": "add-all", + } + } + assert actual == expected + + +@pytest.mark.unit_test +def test_get_user_expansion_sensitivity_sensitivity_in(tmp_path: Path) -> None: + """ + Read the content of the `sensitivity_in.json` file in the `user/expansion/sensitivity` directory. + The goal of this test is to verify the behavior of the `get` method of the `FileStudyTree` class + for the case where the subdirectories or the JSON file do not exist. + """ + + study_factory = StudyFactory(Mock(), Mock(), Mock()) + study_id = "616ac707-c108-47af-9e02-c37cc043511a" + file_study = study_factory.create_from_fs(tmp_path, study_id, use_cache=False) + url = ["user", "expansion", "sensitivity", "sensitivity_in"] + + # Empty study tree structure + # fixme: bad error message + with pytest.raises(ChildNotFoundError, match=r"'expansion' not a child of User"): + file_study.tree.get(url) + + # Add the "user" directory + tmp_path.joinpath("user").mkdir() + with pytest.raises(ChildNotFoundError, match=r"'expansion' not a child of User"): + file_study.tree.get(url) + + # Add the "expansion" directory + tmp_path.joinpath("user/expansion").mkdir() + with pytest.raises(ChildNotFoundError, match=r"'sensitivity' not a child of Expansion"): + file_study.tree.get(url) + + # Add the "sensitivity" directory + tmp_path.joinpath("user/expansion/sensitivity").mkdir() + actual = file_study.tree.get(url) + assert actual == {} + + # Add the "sensitivity_in.json" file + sensitivity_obj = {"epsilon": 10000.0, "projection": ["pv", "battery"], "capex": True} + tmp_path.joinpath("user/expansion/sensitivity/sensitivity_in.json").write_text(json.dumps(sensitivity_obj)) + actual_obj = file_study.tree.get(url) + assert actual_obj == sensitivity_obj + + +@pytest.mark.unit_test +def test_get_depth() -> None: config = Mock() config.path.exist.return_value = True tree = TestMiddleNode( @@ -46,7 +141,7 @@ def test_get_depth(): children={"childA": build_tree(), "childB": build_tree()}, ) - expected = { + expected: t.Dict[str, t.Dict[str, t.Any]] = { "childA": {}, "childB": {}, } @@ -54,7 +149,7 @@ def test_get_depth(): assert tree.get(depth=1) == expected -def test_validate(): +def test_validate() -> None: config = Mock() config.path.exist.return_value = True tree = TestMiddleNode( @@ -77,7 +172,7 @@ def test_validate(): @pytest.mark.unit_test -def test_save(): +def test_save() -> None: tree = build_tree() tree.save(105, ["output"]) @@ -88,7 +183,7 @@ def test_save(): @pytest.mark.unit_test -def test_filter(): +def test_filter() -> None: tree = build_tree() expected_json = { @@ -100,7 +195,7 @@ def test_filter(): assert tree.get(["*", "value"]) == expected_json -def test_delete(tmp_path: Path): +def test_delete(tmp_path: Path) -> None: folder_node = tmp_path / "folder_node" folder_node.mkdir() sub_folder = folder_node / "sub_folder" @@ -124,7 +219,7 @@ def test_delete(tmp_path: Path): assert folder_node.exists() assert sub_folder.exists() - config = FileStudyTreeConfig(study_path=tmp_path, path=folder_node, study_id=-1, version=-1) + config = FileStudyTreeConfig(study_path=tmp_path, path=folder_node, study_id="-1", version=-1) tree_node = TestMiddleNode( context=Mock(), config=config, From a4d647d75cad466d05dfbc7c60777f7b83328e8e Mon Sep 17 00:00:00 2001 From: mabw-rte <41002227+mabw-rte@users.noreply.github.com> Date: Mon, 11 Mar 2024 13:41:55 +0100 Subject: [PATCH 050/248] fix(study-search): skip repository study search tests pending a final fix (#1974) --- tests/study/test_repository.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/study/test_repository.py b/tests/study/test_repository.py index b698497b9c..cd8b6c790c 100644 --- a/tests/study/test_repository.py +++ b/tests/study/test_repository.py @@ -744,6 +744,8 @@ def test_get_all__study_folder_filter( assert len(db_recorder.sql_statements) == 1, str(db_recorder) +# TODO fix this test and all the others +@pytest.mark.skip(reason="This bug is to be fixed asap, the sql query is not working as expected") @pytest.mark.parametrize( "tags, expected_ids", [ From 70bd975788b46738870465f3aa1002a1a2107c1e Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Mon, 11 Mar 2024 10:27:18 +0100 Subject: [PATCH 051/248] fix(results): correct weekly data formatting to support 53-week years --- .../Singlestudy/explore/Results/ResultDetails/index.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx index 425245f9db..2675d7d424 100644 --- a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx @@ -160,11 +160,16 @@ function ResultDetails() { return ["Annual"]; } + // Directly use API's week index (handles 53 weeks) as no formatting is required. + // !NOTE: Suboptimal: Assumes API consistency, lacks flexibility. + if (timestep === Timestep.Weekly) { + return matrixRes.data.index.map((weekNumber) => weekNumber.toString()); + } + // Original date/time format mapping for moment parsing const parseFormat = { [Timestep.Hourly]: "MM/DD HH:mm", [Timestep.Daily]: "MM/DD", - [Timestep.Weekly]: "WW", [Timestep.Monthly]: "MM", }[timestep]; @@ -172,7 +177,6 @@ function ResultDetails() { const outputFormat = { [Timestep.Hourly]: "DD MMM HH:mm I", [Timestep.Daily]: "DD MMM I", - [Timestep.Weekly]: "WW", [Timestep.Monthly]: "MMM", }[timestep]; From b1cd0d2b83be4c475546aa66c367fe14fd8d7e76 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE <43534797+laurent-laporte-pro@users.noreply.github.com> Date: Wed, 13 Mar 2024 10:35:16 +0100 Subject: [PATCH 052/248] fix(st-storage): correction of incorrect wording between "withdrawal" and "injection" (#1977) --- webapp/public/locales/en/main.json | 12 ++++++------ webapp/public/locales/fr/main.json | 14 +++++++------- .../explore/Modelization/Areas/Storages/Matrix.tsx | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 81d547988b..ad3911dcc8 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -411,14 +411,14 @@ "study.modelization.storages.capacities": "Injection / withdrawal capacities", "study.modelization.storages.ruleCurves": "Rule Curves", "study.modelization.storages.inflows": "Inflows", - "study.modelization.storages.chargeCapacity": "Withdrawal capacity", - "study.modelization.storages.dischargeCapacity": "Injection capacity", + "study.modelization.storages.injectionCapacity": "Injection capacity", + "study.modelization.storages.withdrawalCapacity": "Withdrawal capacity", "study.modelization.storages.lowerRuleCurve": "Lower rule curve", "study.modelization.storages.upperRuleCurve": "Upper rule curve", - "study.modelization.storages.injectionNominalCapacity": "Withdrawal (MW)", - "study.modelization.storages.injectionNominalCapacity.info": "Withdrawal capacity from the network (MW)", - "study.modelization.storages.withdrawalNominalCapacity": "Injection (MW)", - "study.modelization.storages.withdrawalNominalCapacity.info": "Injection capacity from stock to the network (MW)", + "study.modelization.storages.injectionNominalCapacity": "Injection (MW)", + "study.modelization.storages.injectionNominalCapacity.info": "Injection capacity from stock to the network (MW)", + "study.modelization.storages.withdrawalNominalCapacity": "Withdrawal (MW)", + "study.modelization.storages.withdrawalNominalCapacity.info": "Withdrawal capacity from the network (MW)", "study.modelization.storages.reservoirCapacity": "Stock (MWh)", "study.modelization.storages.reservoirCapacity.info": "Stock (MWh)", "study.modelization.storages.efficiency": "Efficiency (%)", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index af08b8af96..4429c67f41 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -408,17 +408,17 @@ "study.modelization.hydro.allocation.viewMatrix": "Voir les allocations", "study.modelization.hydro.allocation.error.field.delete": "Erreur lors de la suppression de l'allocation", "study.modelization.storages": "Stockages", - "study.modelization.storages.capacities": "Capacités d'injection / soutirage", + "study.modelization.storages.capacities": "Capacités d’injection / soutirage", "study.modelization.storages.ruleCurves": "Courbe guides", "study.modelization.storages.inflows": "Apports", - "study.modelization.storages.chargeCapacity": "Capacité de soutirage", - "study.modelization.storages.dischargeCapacity": "Capacité d'injection", + "study.modelization.storages.injectionCapacity": "Capacité d’injection", + "study.modelization.storages.withdrawalCapacity": "Capacité de soutirage", "study.modelization.storages.lowerRuleCurve": "Courbe guide inférieure", "study.modelization.storages.upperRuleCurve": "Courbe guide supérieure", - "study.modelization.storages.injectionNominalCapacity": "Soutirage (MW)", - "study.modelization.storages.injectionNominalCapacity.info": "Capacité de soutirage du stock depuis le réseau (MW)", - "study.modelization.storages.withdrawalNominalCapacity": "Injection (MW)", - "study.modelization.storages.withdrawalNominalCapacity.info": "Capacité d'injection du stock vers le réseau (MW)", + "study.modelization.storages.injectionNominalCapacity": "Injection (MW)", + "study.modelization.storages.injectionNominalCapacity.info": "Capacité d’injection dans le stock depuis le réseau (MW)", + "study.modelization.storages.withdrawalNominalCapacity": "Soutirage (MW)", + "study.modelization.storages.withdrawalNominalCapacity.info": "Capacité de soutirage du stock vers le réseau (MW)", "study.modelization.storages.reservoirCapacity": "Stock (MWh)", "study.modelization.storages.reservoirCapacity.info": "Stock (MWh)", "study.modelization.storages.efficiency": "Efficacité (%)", diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Matrix.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Matrix.tsx index bb31f7390c..7bc86cd47a 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Matrix.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Matrix.tsx @@ -56,7 +56,7 @@ function Matrix({ study, areaId, storageId }: Props) { study={study} url={`input/st-storage/series/${areaId}/${storageId}/pmax_injection`} computStats={MatrixStats.NOCOL} - title={t("study.modelization.storages.chargeCapacity")} + title={t("study.modelization.storages.injectionCapacity")} /> } right={ @@ -64,7 +64,7 @@ function Matrix({ study, areaId, storageId }: Props) { study={study} url={`input/st-storage/series/${areaId}/${storageId}/pmax_withdrawal`} computStats={MatrixStats.NOCOL} - title={t("study.modelization.storages.dischargeCapacity")} + title={t("study.modelization.storages.withdrawalCapacity")} /> } sx={{ From 04f078628cbdd83dc893501aed4b5bea1849e786 Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Wed, 28 Feb 2024 11:00:57 +0100 Subject: [PATCH 053/248] feat(ui-utils): add `validateString` utility --- webapp/src/utils/validationUtils.ts | 80 +++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 webapp/src/utils/validationUtils.ts diff --git a/webapp/src/utils/validationUtils.ts b/webapp/src/utils/validationUtils.ts new file mode 100644 index 0000000000..438236f617 --- /dev/null +++ b/webapp/src/utils/validationUtils.ts @@ -0,0 +1,80 @@ +import { t } from "i18next"; + +interface ValidationOptions { + pattern?: RegExp; + existingEntries?: string[]; + excludedEntries?: string[]; + isCaseSensitive?: boolean; + min?: number; + max?: number; +} + +/** + * Validates a single string value against specified options. + * + * @param {string} value - The string to validate, leading/trailing spaces will be trimmed. + * @param {ValidationOptions} [options] - Customizable options for validation. + * - `pattern`: RegExp for matching the string. Default to alphanumeric, spaces and "&()_-" pattern. + * - `existingEntries`: Array of strings to check for duplicates. Optional, case-insensitive by default. + * - `excludedEntries`: Array of strings that are explicitly not allowed. + * - `isCaseSensitive`: Default to case-insensitive comparison with `existingEntries`. e.g: "A" and "a" are considered the same. + * - `min`: Minimum length required. Defaults to 0. + * - `max`: Maximum allowed length. Defaults to 50. + * @returns {string | true} - True if validation is successful, or a localized error message if it fails. + */ +export const validateString = ( + value: string, + options?: ValidationOptions, +): string | true => { + const { + pattern, + existingEntries = [], + excludedEntries = [], + isCaseSensitive, + min, + max, + } = { + pattern: /^[a-zA-Z0-9_\-() &]+$/, + isCaseSensitive: false, + min: 0, + max: 50, + ...options, + }; + + const trimmedValue = value.trim(); + + if (!trimmedValue) { + return t("form.field.required"); + } + + if (!pattern.test(trimmedValue)) { + return t("form.field.specialChars", { 0: "&()_-" }); + } + + if (trimmedValue.length < min) { + return t("form.field.minValue", { 0: min }); + } + + if (trimmedValue.length > max) { + return t("form.field.maxValue", { 0: max }); + } + + const normalize = (entry: string) => + isCaseSensitive ? entry.trim() : entry.toLowerCase().trim(); + + const comparisonArray = existingEntries.map(normalize); + + const comparisonValue = normalize(trimmedValue); + + if (comparisonArray.includes(comparisonValue)) { + return t("form.field.duplicate", { 0: value }); + } + + const normalizedExcludedValues = excludedEntries.map(normalize); + + if (normalizedExcludedValues.includes(comparisonValue)) { + return t("form.field.notAllowedValue", { 0: value }); + } + + return true; +}; From 7740146564983a00a9e247d410612158390dcb08 Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Wed, 28 Feb 2024 09:55:38 +0100 Subject: [PATCH 054/248] feat(ui-map): add areas validation on `CreateAreaDialog` --- .../explore/Modelization/Map/CreateAreaDialog.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/CreateAreaDialog.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/CreateAreaDialog.tsx index cd11395262..50354330b6 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/CreateAreaDialog.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/CreateAreaDialog.tsx @@ -3,16 +3,23 @@ import AddCircleIcon from "@mui/icons-material/AddCircle"; import FormDialog from "../../../../../common/dialogs/FormDialog"; import StringFE from "../../../../../common/fieldEditors/StringFE"; import { SubmitHandlerPlus } from "../../../../../common/Form/types"; +import useAppSelector from "../../../../../../redux/hooks/useAppSelector"; +import { getAreas } from "../../../../../../redux/selectors"; +import { validateString } from "../../../../../../utils/validationUtils"; interface Props { + studyId: string; open: boolean; onClose: () => void; createArea: (name: string) => void; } function CreateAreaDialog(props: Props) { - const { open, onClose, createArea } = props; + const { studyId, open, onClose, createArea } = props; const [t] = useTranslation(); + const existingAreas = useAppSelector((state) => + getAreas(state, studyId).map((area) => area.name), + ); const defaultValues = { name: "", @@ -48,8 +55,8 @@ function CreateAreaDialog(props: Props) { control={control} fullWidth rules={{ - required: true, - validate: (val) => val.trim().length > 0, + validate: (v) => + validateString(v, { existingEntries: existingAreas }), }} /> )} From b7e51caf6ac5580d688f5cb01f036f809eebbaf5 Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Wed, 28 Feb 2024 09:57:03 +0100 Subject: [PATCH 055/248] fix(ui-map): add missing api error return --- .../explore/Modelization/Map/CreateAreaDialog.tsx | 2 +- .../App/Singlestudy/explore/Modelization/Map/index.tsx | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/CreateAreaDialog.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/CreateAreaDialog.tsx index 50354330b6..a05e2b9262 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/CreateAreaDialog.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/CreateAreaDialog.tsx @@ -30,7 +30,7 @@ function CreateAreaDialog(props: Props) { //////////////////////////////////////////////////////////////// const handleSubmit = (data: SubmitHandlerPlus) => { - createArea(data.values.name); + return createArea(data.values.name.trim()); }; //////////////////////////////////////////////////////////////// diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/index.tsx index 4ca4ccdf1c..db03961ef4 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/index.tsx @@ -116,10 +116,13 @@ function Map() { //////////////////////////////////////////////////////////////// const handleCreateArea = async (name: string) => { - setOpenDialog(false); try { if (study) { - dispatch(createStudyMapNode({ studyId: study.id, name })); + return dispatch(createStudyMapNode({ studyId: study.id, name })) + .unwrap() + .then(() => { + setOpenDialog(false); + }); } } catch (e) { enqueueErrorSnackbar(t("study.error.createArea"), e as AxiosError); @@ -206,6 +209,7 @@ function Map() { /> {openDialog && ( Date: Wed, 6 Mar 2024 13:33:36 +0100 Subject: [PATCH 056/248] refactor(ui): replace existing validation logic with `validateString` utility --- webapp/public/locales/en/main.json | 11 +- webapp/public/locales/fr/main.json | 11 +- .../dialog/GroupFormDialog/GroupForm.tsx | 31 ++- .../Users/dialog/UserFormDialog/UserForm.tsx | 47 +++-- .../InformationView/CreateVariantDialog.tsx | 12 +- .../App/Singlestudy/PropertiesDialog.tsx | 3 +- .../BindingConstraints/AddDialog.tsx | 11 +- .../Modelization/Map/CreateAreaDialog.tsx | 2 +- .../Districts/CreateDistrictDialog.tsx | 17 +- .../MapConfig/Layers/CreateLayerDialog.tsx | 14 +- .../MapConfig/Layers/UpdateLayerDialog.tsx | 31 +-- .../dialogs/TableTemplateFormDialog.tsx | 26 ++- .../Candidates/CreateCandidateDialog.tsx | 20 +- .../explore/Xpansion/Candidates/index.tsx | 1 + .../common/GroupedDataTable/CreateDialog.tsx | 16 +- .../GroupedDataTable/DuplicateDialog.tsx | 16 +- webapp/src/utils/validationUtils.ts | 188 ++++++++++++++---- 17 files changed, 300 insertions(+), 157 deletions(-) diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index ad3911dcc8..26965fd8b3 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -27,6 +27,7 @@ "global.group": "Group", "global.variants": "Variants management", "global.password": "Password", + "global.confirmPassword": "Confirm password", "global.create": "Create", "global.open": "Open", "global.name": "Name", @@ -117,7 +118,14 @@ "form.field.minValue": "The minimum value is {{0}}", "form.field.maxValue": "The maximum value is {{0}}", "form.field.notAllowedValue": "Not allowed value", - "form.field.specialChars": "Special characters allowed: {{0}}", + "form.field.allowedChars": "Special characters allowed: {{0}}", + "form.field.specialCharsNotAllowed": "Special characters are not allowed", + "form.field.spacesNotAllowed": "Spaces are not allowed", + "form.field.requireLowercase": "Password must contain at least one lowercase letter.", + "form.field.requireUppercase": "Password must contain at least one uppercase letter.", + "form.field.requireDigit": "Password must contain at least one digit.", + "form.field.requireSpecialChars": "Password must contain at least one special character.", + "form.field.requireMinimumLength": "Password must be at least 8 characters long.", "matrix.graphSelector": "Columns", "matrix.message.importHint": "Click or drag and drop a matrix here", "matrix.importNewMatrix": "Import a new matrix", @@ -186,6 +194,7 @@ "settings.error.groupRolesSave": "Role(s) for group '{{0}}' not saved", "settings.error.tokenSave": "'{{0}}' token not saved", "settings.error.updateMaintenance": "Maintenance mode not updated", + "settings.error.passwordMismatch": "Passwords do not match", "launcher.additionalModes": "Additional modes", "launcher.autoUnzip": "Automatically unzip", "launcher.xpress": "Xpress (>= 8.3)", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 4429c67f41..85edfce9bb 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -27,6 +27,7 @@ "global.group": "Groupe", "global.variants": "Gestion des variantes", "global.password": "Mot de passe", + "global.confirmPassword": "Confirmer le mot de passe", "global.create": "Créer", "global.open": "Ouvrir", "global.name": "Nom", @@ -117,7 +118,14 @@ "form.field.minValue": "La valeur minimum est {{0}}", "form.field.maxValue": "La valeur maximum est {{0}}", "form.field.notAllowedValue": "Valeur non autorisée", - "form.field.specialChars": "Caractères spéciaux autorisés: {{0}}", + "form.field.allowedChars": "Caractères spéciaux autorisés: {{0}}", + "form.field.specialCharsNotAllowed": "Les caractères spéciaux ne sont pas autorisés", + "form.field.spacesNotAllowed": "Les espaces ne sont pas autorisés", + "form.field.requireLowercase": "Le mot de passe doit contenir au moins une lettre minuscule.", + "form.field.requireUppercase": "Le mot de passe doit contenir au moins une lettre majuscule.", + "form.field.requireDigit": "Le mot de passe doit contenir au moins un chiffre.", + "form.field.requireSpecialChars": "Le mot de passe doit contenir au moins un caractère spécial.", + "form.field.requireMinimumLength": "Le mot de passe doit comporter au moins 8 caractères.", "matrix.graphSelector": "Colonnes", "matrix.message.importHint": "Cliquer ou glisser une matrice ici", "matrix.importNewMatrix": "Import d'une nouvelle matrice", @@ -186,6 +194,7 @@ "settings.error.groupRolesSave": "Role(s) pour le groupe '{{0}}' non sauvegardé", "settings.error.tokenSave": "Token '{{0}}' non sauvegardé", "settings.error.updateMaintenance": "Erreur lors du changement du status de maintenance", + "settings.error.passwordMismatch": "Les mots de passe ne correspondent pas", "launcher.additionalModes": "Mode additionnels", "launcher.autoUnzip": "Dézippage automatique", "launcher.xpress": "Xpress (>= 8.3)", diff --git a/webapp/src/components/App/Settings/Groups/dialog/GroupFormDialog/GroupForm.tsx b/webapp/src/components/App/Settings/Groups/dialog/GroupFormDialog/GroupForm.tsx index 6a360e2d5e..362866eca5 100644 --- a/webapp/src/components/App/Settings/Groups/dialog/GroupFormDialog/GroupForm.tsx +++ b/webapp/src/components/App/Settings/Groups/dialog/GroupFormDialog/GroupForm.tsx @@ -31,10 +31,11 @@ import { import { RoleType, UserDTO } from "../../../../../../common/types"; import { roleToString, sortByName } from "../../../../../../services/utils"; import usePromise from "../../../../../../hooks/usePromise"; -import { getUsers } from "../../../../../../services/api/user"; +import { getGroups, getUsers } from "../../../../../../services/api/user"; import { getAuthUser } from "../../../../../../redux/selectors"; import useAppSelector from "../../../../../../redux/hooks/useAppSelector"; import { UseFormReturnPlus } from "../../../../../common/Form/types"; +import { validateString } from "../../../../../../utils/validationUtils"; function GroupForm(props: UseFormReturnPlus) { const { @@ -44,15 +45,23 @@ function GroupForm(props: UseFormReturnPlus) { formState: { errors, defaultValues }, } = props; + const { t } = useTranslation(); + const authUser = useAppSelector(getAuthUser); const userLabelId = useRef(uuidv4()).current; + const [selectedUser, setSelectedUser] = useState(); + const { data: users, isLoading: isUsersLoading } = usePromise(getUsers); + const { data: groups } = usePromise(getGroups); + + const existingGroups = useMemo( + () => groups?.map((group) => group.name), + [groups], + ); + const { fields, append, remove } = useFieldArray({ control, name: "permissions", }); - const [selectedUser, setSelectedUser] = useState(); - const { data: users, isLoading: isUsersLoading } = usePromise(getUsers); - const { t } = useTranslation(); - const authUser = useAppSelector(getAuthUser); + const allowToAddPermission = selectedUser && !getValues("permissions").some( @@ -63,6 +72,7 @@ function GroupForm(props: UseFormReturnPlus) { if (!users) { return []; } + return sortByName( users.filter( (user) => @@ -101,12 +111,11 @@ function GroupForm(props: UseFormReturnPlus) { } fullWidth {...register("name", { - required: t("form.field.required") as string, - validate: (value) => { - if (RESERVED_GROUP_NAMES.includes(value)) { - return t("form.field.notAllowedValue") as string; - } - }, + validate: (v) => + validateString(v, { + existingValues: existingGroups, + excludedValues: RESERVED_GROUP_NAMES, + }) || undefined, })} /> {/* Permissions */} diff --git a/webapp/src/components/App/Settings/Users/dialog/UserFormDialog/UserForm.tsx b/webapp/src/components/App/Settings/Users/dialog/UserFormDialog/UserForm.tsx index 43d7f915ef..884b7a118b 100644 --- a/webapp/src/components/App/Settings/Users/dialog/UserFormDialog/UserForm.tsx +++ b/webapp/src/components/App/Settings/Users/dialog/UserFormDialog/UserForm.tsx @@ -31,16 +31,18 @@ import { import { GroupDTO, RoleType } from "../../../../../../common/types"; import { roleToString, sortByName } from "../../../../../../services/utils"; import usePromise from "../../../../../../hooks/usePromise"; -import { getGroups } from "../../../../../../services/api/user"; +import { getGroups, getUsers } from "../../../../../../services/api/user"; import { UserFormDialogProps } from "."; import { UseFormReturnPlus } from "../../../../../common/Form/types"; +import { + validatePassword, + validateString, +} from "../../../../../../utils/validationUtils"; interface Props extends UseFormReturnPlus { onlyPermissions?: UserFormDialogProps["onlyPermissions"]; } -const PASSWORD_MIN_LENGTH = 8; - function UserForm(props: Props) { const { control, @@ -50,14 +52,19 @@ function UserForm(props: Props) { onlyPermissions, } = props; + const { t } = useTranslation(); const groupLabelId = useRef(uuidv4()).current; + const [selectedGroup, setSelectedGroup] = useState(); + const { data: groups, isLoading: isGroupsLoading } = usePromise(getGroups); + const { data: users } = usePromise(getUsers); + + const existingUsers = useMemo(() => users?.map(({ name }) => name), [users]); + const { fields, append, remove } = useFieldArray({ control, name: "permissions", }); - const [selectedGroup, setSelectedGroup] = useState(); - const { data: groups, isLoading: isGroupsLoading } = usePromise(getGroups); - const { t } = useTranslation(); + const commonTextFieldProps = { required: true, sx: { mx: 0 }, @@ -104,12 +111,11 @@ function UserForm(props: Props) { helperText={errors.username?.message?.toString()} {...commonTextFieldProps} {...register("username", { - required: t("form.field.required") as string, - validate: (value) => { - if (RESERVED_USER_NAMES.includes(value)) { - return t("form.field.notAllowedValue") as string; - } - }, + validate: (v) => + validateString(v, { + existingValues: existingUsers, + excludedValues: RESERVED_USER_NAMES, + }) || undefined, })} /> validatePassword(v) || undefined, + })} + /> + validatePassword(v, getValues("password")), })} /> diff --git a/webapp/src/components/App/Singlestudy/HomeView/InformationView/CreateVariantDialog.tsx b/webapp/src/components/App/Singlestudy/HomeView/InformationView/CreateVariantDialog.tsx index 68d570a302..5e7c1213f7 100644 --- a/webapp/src/components/App/Singlestudy/HomeView/InformationView/CreateVariantDialog.tsx +++ b/webapp/src/components/App/Singlestudy/HomeView/InformationView/CreateVariantDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; import AddCircleIcon from "@mui/icons-material/AddCircle"; @@ -10,6 +10,7 @@ import StringFE from "../../../../common/fieldEditors/StringFE"; import Fieldset from "../../../../common/Fieldset"; import SelectFE from "../../../../common/fieldEditors/SelectFE"; import { SubmitHandlerPlus } from "../../../../common/Form/types"; +import { validateString } from "../../../../../utils/validationUtils"; interface Props { parentId: string; @@ -25,6 +26,11 @@ function CreateVariantDialog(props: Props) { const [sourceList, setSourceList] = useState([]); const defaultValues = { name: "", sourceId: parentId }; + const existingVariants = useMemo( + () => sourceList.map((variant) => variant.name), + [sourceList], + ); + useEffect(() => { setSourceList(createListFromTree(tree)); }, [tree]); @@ -67,8 +73,8 @@ function CreateVariantDialog(props: Props) { name="name" control={control} rules={{ - required: true, - validate: (val) => val.trim().length > 0, + validate: (v) => + validateString(v, { existingValues: existingVariants }), }} /> val.trim().length > 0 }} + rules={{ validate: (v) => validateString(v) }} sx={{ mx: 0 }} fullWidth /> diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/AddDialog.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/AddDialog.tsx index 06056b3b4e..21e174ff96 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/AddDialog.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/AddDialog.tsx @@ -14,6 +14,7 @@ import SelectFE from "../../../../../common/fieldEditors/SelectFE"; import StringFE from "../../../../../common/fieldEditors/StringFE"; import SwitchFE from "../../../../../common/fieldEditors/SwitchFE"; import { StudyMetadata } from "../../../../../../common/types"; +import { validateString } from "../../../../../../utils/validationUtils"; interface Props { studyId: StudyMetadata["id"]; @@ -102,14 +103,8 @@ function AddDialog({ studyId, existingConstraints, open, onClose }: Props) { label={t("global.name")} control={control} rules={{ - validate: (v) => { - if (v.trim().length <= 0) { - return t("form.field.required"); - } - if (existingConstraints.includes(v.trim().toLowerCase())) { - return t("form.field.duplicate", { 0: v }); - } - }, + validate: (v) => + validateString(v, { existingValues: existingConstraints }), }} /> - validateString(v, { existingEntries: existingAreas }), + validateString(v, { existingValues: existingAreas }), }} /> )} diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Districts/CreateDistrictDialog.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Districts/CreateDistrictDialog.tsx index e6ddb89687..f8b4191ac2 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Districts/CreateDistrictDialog.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Districts/CreateDistrictDialog.tsx @@ -12,6 +12,7 @@ import useAppDispatch from "../../../../../../../../redux/hooks/useAppDispatch"; import { createStudyMapDistrict } from "../../../../../../../../redux/ducks/studyMaps"; import useAppSelector from "../../../../../../../../redux/hooks/useAppSelector"; import { getStudyMapDistrictsById } from "../../../../../../../../redux/selectors"; +import { validateString } from "../../../../../../../../utils/validationUtils"; interface Props { open: boolean; @@ -32,10 +33,7 @@ function CreateDistrictDialog(props: Props) { const districtsById = useAppSelector(getStudyMapDistrictsById); const existingDistricts = useMemo( - () => - Object.values(districtsById).map((district) => - district.name.toLowerCase(), - ), + () => Object.values(districtsById).map(({ name }) => name), [districtsById], ); @@ -81,15 +79,8 @@ function CreateDistrictDialog(props: Props) { control={control} fullWidth rules={{ - required: { value: true, message: t("form.field.required") }, - validate: (v) => { - if (v.trim().length <= 0) { - return false; - } - if (existingDistricts.includes(v.toLowerCase())) { - return `The District "${v}" already exists`; - } - }, + validate: (v) => + validateString(v, { existingValues: existingDistricts }), }} sx={{ m: 0 }} /> diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/CreateLayerDialog.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/CreateLayerDialog.tsx index 48071a7b57..46c01c2b45 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/CreateLayerDialog.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/CreateLayerDialog.tsx @@ -12,6 +12,7 @@ import useAppDispatch from "../../../../../../../../redux/hooks/useAppDispatch"; import useEnqueueErrorSnackbar from "../../../../../../../../hooks/useEnqueueErrorSnackbar"; import useAppSelector from "../../../../../../../../redux/hooks/useAppSelector"; import { getStudyMapLayersById } from "../../../../../../../../redux/selectors"; +import { validateString } from "../../../../../../../../utils/validationUtils"; interface Props { open: boolean; @@ -31,7 +32,7 @@ function CreateLayerDialog(props: Props) { const layersById = useAppSelector(getStudyMapLayersById); const existingLayers = useMemo( - () => Object.values(layersById).map((layer) => layer.name.toLowerCase()), + () => Object.values(layersById).map(({ name }) => name), [layersById], ); @@ -73,15 +74,8 @@ function CreateLayerDialog(props: Props) { control={control} fullWidth rules={{ - required: { value: true, message: t("form.field.required") }, - validate: (v) => { - if (v.trim().length <= 0) { - return false; - } - if (existingLayers.includes(v.toLowerCase())) { - return `The layer "${v}" already exists`; - } - }, + validate: (v) => + validateString(v, { existingValues: existingLayers }), }} /> )} diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/UpdateLayerDialog.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/UpdateLayerDialog.tsx index 1160b309bc..aa1c97b4eb 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/UpdateLayerDialog.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/UpdateLayerDialog.tsx @@ -17,6 +17,7 @@ import { updateStudyMapLayer, } from "../../../../../../../../redux/ducks/studyMaps"; import useAppDispatch from "../../../../../../../../redux/hooks/useAppDispatch"; +import { validateString } from "../../../../../../../../utils/validationUtils"; interface Props { open: boolean; @@ -44,7 +45,7 @@ function UpdateLayerDialog(props: Props) { })); const existingLayers = useMemo( - () => Object.values(layersById).map((layer) => layer.name.toLowerCase()), + () => Object.values(layersById).map(({ name }) => name), [layersById], ); @@ -56,6 +57,7 @@ function UpdateLayerDialog(props: Props) { data: SubmitHandlerPlus, ) => { const { layerId, name } = data.values; + if (layerId && name) { return dispatch(updateStudyMapLayer({ studyId: study.id, layerId, name })) .unwrap() @@ -67,6 +69,7 @@ function UpdateLayerDialog(props: Props) { if (layerId) { dispatch(deleteStudyMapLayer({ studyId: study.id, layerId })); } + setOpenConfirmationModal(false); onClose(); }; @@ -86,7 +89,7 @@ function UpdateLayerDialog(props: Props) { defaultValues, }} > - {({ control, setValue, getValues }) => ( + {({ control, getValues, reset }) => (
- setValue("name", layersById[String(e.target.value)].name) - } + onChange={({ target: { value } }) => { + reset({ + layerId: value as string, + name: layersById[value as string].name, + }); + }} /> { - if (v.trim().length <= 0) { - return false; - } - if (existingLayers.includes(v.toLowerCase())) { - return `The Layer "${v}" already exists`; - } - }, + validate: (v) => + validateString(v, { + existingValues: existingLayers, + // Excludes the current layer's original name to allow edits without false duplicates. + editedValue: layersById[getValues("layerId")].name, + }), }} disabled={getValues("layerId") === ""} sx={{ mx: 0 }} diff --git a/webapp/src/components/App/Singlestudy/explore/TableModeList/dialogs/TableTemplateFormDialog.tsx b/webapp/src/components/App/Singlestudy/explore/TableModeList/dialogs/TableTemplateFormDialog.tsx index e5cbbd9a92..8517268e45 100644 --- a/webapp/src/components/App/Singlestudy/explore/TableModeList/dialogs/TableTemplateFormDialog.tsx +++ b/webapp/src/components/App/Singlestudy/explore/TableModeList/dialogs/TableTemplateFormDialog.tsx @@ -9,6 +9,8 @@ import SelectFE from "../../../../../common/fieldEditors/SelectFE"; import StringFE from "../../../../../common/fieldEditors/StringFE"; import { getTableColumnsForType, type TableTemplate } from "../utils"; import { TABLE_MODE_TYPES } from "../../../../../../services/api/studies/tableMode/constants"; +import { validateString } from "../../../../../../utils/validationUtils"; +import { useMemo } from "react"; export interface TableTemplateFormDialogProps extends Pick< @@ -23,6 +25,15 @@ function TableTemplateFormDialog(props: TableTemplateFormDialogProps) { props; const { t } = useTranslation(); + const existingTables = useMemo( + () => templates.map(({ name }) => name), + [templates], + ); + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + return ( { - const id = getValues("id"); - const hasDuplicate = templates.find( - (tp) => tp.id !== id && tp.name.trim() === value.trim(), - ); - if (hasDuplicate) { - return t("form.field.notAllowedValue") as string; - } - }, - required: true, + validate: (v) => + validateString(v, { + existingValues: existingTables, + editedValue: config?.defaultValues?.name, + }), }} /> void; onSave: (candidate: XpansionCandidate) => void; + candidates: XpansionCandidate[]; } function CreateCandidateDialog(props: PropType) { - const { open, links, onClose, onSave } = props; + const { open, links, onClose, onSave, candidates } = props; const [t] = useTranslation(); const [isToggled, setIsToggled] = useState(true); + const existingCandidates = useMemo( + () => candidates.map(({ name }) => name), + [candidates], + ); + //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// @@ -70,7 +77,14 @@ function CreateCandidateDialog(props: PropType) { label={t("global.name")} name="name" control={control} - rules={{ required: t("form.field.required") }} + rules={{ + validate: (v) => + validateString(v, { + existingValues: existingCandidates, + allowSpaces: false, + allowedChars: "&_*", + }), + }} sx={{ mx: 0 }} /> )} {!!capacityViewDialog && ( diff --git a/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx b/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx index d85cd669fd..5c8313a352 100644 --- a/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx +++ b/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx @@ -7,6 +7,7 @@ import { SubmitHandlerPlus } from "../Form/types"; import SelectFE from "../fieldEditors/SelectFE"; import { nameToId } from "../../../services/utils"; import { TRow } from "./utils"; +import { validateString } from "../../../utils/validationUtils"; interface Props { open: boolean; @@ -65,19 +66,8 @@ function CreateDialog({ control={control} fullWidth rules={{ - required: { value: true, message: t("form.field.required") }, - validate: (v) => { - const regex = /^[a-zA-Z0-9_\-() &]+$/; - if (!regex.test(v.trim())) { - return t("form.field.specialChars", { 0: "&()_-" }); - } - if (v.trim().length <= 0) { - return t("form.field.required"); - } - if (existingNames.includes(v.trim().toLowerCase())) { - return t("form.field.duplicate", { 0: v }); - } - }, + validate: (v) => + validateString(v, { existingValues: existingNames }), }} sx={{ m: 0 }} /> diff --git a/webapp/src/components/common/GroupedDataTable/DuplicateDialog.tsx b/webapp/src/components/common/GroupedDataTable/DuplicateDialog.tsx index 93daa1a3bc..34664b8f3a 100644 --- a/webapp/src/components/common/GroupedDataTable/DuplicateDialog.tsx +++ b/webapp/src/components/common/GroupedDataTable/DuplicateDialog.tsx @@ -4,6 +4,7 @@ import Fieldset from "../Fieldset"; import FormDialog from "../dialogs/FormDialog"; import { SubmitHandlerPlus } from "../Form/types"; import StringFE from "../fieldEditors/StringFE"; +import { validateString } from "../../../utils/validationUtils"; interface Props { open: boolean; @@ -51,19 +52,8 @@ function DuplicateDialog(props: Props) { control={control} fullWidth rules={{ - required: { value: true, message: t("form.field.required") }, - validate: (v) => { - const regex = /^[a-zA-Z0-9_\-() &]+$/; - if (!regex.test(v.trim())) { - return t("form.field.specialChars", { 0: "&()_-" }); - } - if (v.trim().length <= 0) { - return t("form.field.required"); - } - if (existingNames.includes(v.trim().toLowerCase())) { - return t("form.field.duplicate", { 0: v }); - } - }, + validate: (v) => + validateString(v, { existingValues: existingNames }), }} sx={{ m: 0 }} /> diff --git a/webapp/src/utils/validationUtils.ts b/webapp/src/utils/validationUtils.ts index 438236f617..656d8e17f7 100644 --- a/webapp/src/utils/validationUtils.ts +++ b/webapp/src/utils/validationUtils.ts @@ -1,45 +1,59 @@ import { t } from "i18next"; +//////////////////////////////////////////////////////////////// +// Types +//////////////////////////////////////////////////////////////// + interface ValidationOptions { - pattern?: RegExp; - existingEntries?: string[]; - excludedEntries?: string[]; + existingValues?: string[]; + excludedValues?: string[]; isCaseSensitive?: boolean; + allowSpecialChars?: boolean; + allowedChars?: string; + allowSpaces?: boolean; + editedValue?: string; min?: number; max?: number; } +//////////////////////////////////////////////////////////////// +// Validators +//////////////////////////////////////////////////////////////// + /** - * Validates a single string value against specified options. + * Validates a single string value against specified criteria. + * + * Validates the input string against a variety of checks including length restrictions, + * character validations, and uniqueness against provided arrays of existing and excluded values. * - * @param {string} value - The string to validate, leading/trailing spaces will be trimmed. - * @param {ValidationOptions} [options] - Customizable options for validation. - * - `pattern`: RegExp for matching the string. Default to alphanumeric, spaces and "&()_-" pattern. - * - `existingEntries`: Array of strings to check for duplicates. Optional, case-insensitive by default. - * - `excludedEntries`: Array of strings that are explicitly not allowed. - * - `isCaseSensitive`: Default to case-insensitive comparison with `existingEntries`. e.g: "A" and "a" are considered the same. - * - `min`: Minimum length required. Defaults to 0. - * - `max`: Maximum allowed length. Defaults to 50. - * @returns {string | true} - True if validation is successful, or a localized error message if it fails. + * @param value - The string to validate. Leading and trailing spaces will be trimmed. + * @param options - Configuration options for validation. + * @param options.existingValues - An array of strings to check against for duplicates. Comparison is case-insensitive by default. + * @param options.excludedValues - An array of strings that the value should not match. + * @param options.isCaseSensitive - Whether the comparison with `existingValues` and `excludedValues` is case-sensitive. Defaults to false. + * @param options.allowSpecialChars - Flags if special characters are permitted in the value. + * @param options.allowedChars - A string representing additional allowed characters outside the typical alphanumeric scope. + * @param options.allowSpaces - Flags if spaces are allowed in the value. + * @param options.editedValue - The current value being edited, to exclude it from duplicate checks. + * @param options.min - Minimum length required for the string. Defaults to 0. + * @param options.max - Maximum allowed length for the string. Defaults to 255. + * @returns True if validation is successful, or a localized error message if it fails. */ -export const validateString = ( +export function validateString( value: string, options?: ValidationOptions, -): string | true => { +): string | true { const { - pattern, - existingEntries = [], - excludedEntries = [], - isCaseSensitive, - min, - max, - } = { - pattern: /^[a-zA-Z0-9_\-() &]+$/, - isCaseSensitive: false, - min: 0, - max: 50, - ...options, - }; + existingValues = [], + excludedValues = [], + isCaseSensitive = false, + allowSpecialChars = true, + allowSpaces = true, + allowedChars = "&()_-", + editedValue = "", + min = 0, + max = 255, + } = options || {}; const trimmedValue = value.trim(); @@ -47,8 +61,8 @@ export const validateString = ( return t("form.field.required"); } - if (!pattern.test(trimmedValue)) { - return t("form.field.specialChars", { 0: "&()_-" }); + if (!allowSpaces && trimmedValue.includes(" ")) { + return t("form.field.spacesNotAllowed"); } if (trimmedValue.length < min) { @@ -59,22 +73,120 @@ export const validateString = ( return t("form.field.maxValue", { 0: max }); } - const normalize = (entry: string) => - isCaseSensitive ? entry.trim() : entry.toLowerCase().trim(); + // Compiles a regex pattern based on allowed characters and flags. + const allowedCharsPattern = new RegExp( + generatePattern(allowSpaces, allowSpecialChars, allowedChars), + ); + + // Validates the string against the allowed characters regex. + if (!allowedCharsPattern.test(trimmedValue)) { + return allowSpecialChars + ? t("form.field.allowedChars", { 0: allowedChars }) + : t("form.field.specialCharsNotAllowed"); + } - const comparisonArray = existingEntries.map(normalize); + // Normalize the value for comparison, based on case sensitivity option. + const normalize = (v: string) => + isCaseSensitive ? v.trim() : v.toLowerCase().trim(); + // Prepare the value for duplicate and exclusion checks. const comparisonValue = normalize(trimmedValue); - if (comparisonArray.includes(comparisonValue)) { - return t("form.field.duplicate", { 0: value }); + // Some forms requires to keep the original value while updating other fields. + if (normalize(editedValue) === comparisonValue) { + return true; } - const normalizedExcludedValues = excludedEntries.map(normalize); + // Check for duplication against existing values. + if (existingValues.map(normalize).includes(comparisonValue)) { + return t("form.field.duplicate", { 0: value }); + } - if (normalizedExcludedValues.includes(comparisonValue)) { + // Check for inclusion in the list of excluded values. + if (excludedValues.map(normalize).includes(comparisonValue)) { return t("form.field.notAllowedValue", { 0: value }); } return true; -}; +} + +/** + * Validates a password string for strong security criteria. + * + * @param password - The password to validate. + * @param confirmPassword - An optional second password to compare against the first for matching. + * @returns True if validation is successful, or a localized error message if it fails. + */ +export function validatePassword( + password: string, + confirmPassword?: string, +): string | true { + const trimmedPassword = password.trim(); + + if (!trimmedPassword) { + return t("form.field.required"); + } + + if (!/(?=.*[a-z])/.test(trimmedPassword)) { + return t("form.field.requireLowercase"); + } + + if (!/(?=.*[A-Z])/.test(trimmedPassword)) { + return t("form.field.requireUppercase"); + } + + if (!/(?=.*\d)/.test(trimmedPassword)) { + return t("form.field.requireDigit"); + } + + if (!/(?=.*[^\w\s])/.test(trimmedPassword)) { + return t("form.field.requireSpecialChars"); + } + + if (trimmedPassword.length < 8) { + return t("form.field.minValue", { 0: 8 }); + } + + if (trimmedPassword.length > 30) { + return t("form.field.maxValue", { 0: 30 }); + } + + if ( + confirmPassword !== undefined && + trimmedPassword !== confirmPassword.trim() + ) { + return t("settings.error.passwordMismatch"); + } + + return true; +} + +//////////////////////////////////////////////////////////////// +// Utils +//////////////////////////////////////////////////////////////// + +// Function to escape special characters in allowedChars +const escapeSpecialChars = (chars: string) => + chars.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&"); + +/** + * Generates a regular expression pattern for string validation based on specified criteria. + * This pattern includes considerations for allowing spaces, special characters, and any additional + * characters specified in `allowedChars`. + * + * @param allowSpaces - Indicates if spaces are permitted in the string. + * @param allowSpecialChars - Indicates if special characters are permitted. + * @param allowedChars - Specifies additional characters to allow in the string. + * @returns The regular expression pattern as a string. + */ +function generatePattern( + allowSpaces: boolean, + allowSpecialChars: boolean, + allowedChars: string, +): string { + const basePattern = "^[a-zA-Z0-9"; + const spacePattern = allowSpaces ? " " : ""; + const specialCharsPattern = + allowSpecialChars && allowedChars ? escapeSpecialChars(allowedChars) : ""; + return basePattern + spacePattern + specialCharsPattern + "]*$"; +} From d380bf7a269dda837253984cfe46071f3941a91e Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Tue, 12 Mar 2024 07:35:02 +0100 Subject: [PATCH 057/248] fix(ui-utils): optimize `validatePassword` regex to mitigate potential backtracking issues --- webapp/src/utils/validationUtils.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/webapp/src/utils/validationUtils.ts b/webapp/src/utils/validationUtils.ts index 656d8e17f7..3d771356e6 100644 --- a/webapp/src/utils/validationUtils.ts +++ b/webapp/src/utils/validationUtils.ts @@ -127,28 +127,28 @@ export function validatePassword( return t("form.field.required"); } - if (!/(?=.*[a-z])/.test(trimmedPassword)) { - return t("form.field.requireLowercase"); + if (trimmedPassword.length < 8) { + return t("form.field.minValue", { 0: 8 }); } - if (!/(?=.*[A-Z])/.test(trimmedPassword)) { - return t("form.field.requireUppercase"); + if (trimmedPassword.length > 50) { + return t("form.field.maxValue", { 0: 50 }); } - if (!/(?=.*\d)/.test(trimmedPassword)) { - return t("form.field.requireDigit"); + if (!/[a-z]/.test(trimmedPassword)) { + return t("form.field.requireLowercase"); } - if (!/(?=.*[^\w\s])/.test(trimmedPassword)) { - return t("form.field.requireSpecialChars"); + if (!/[A-Z]/.test(trimmedPassword)) { + return t("form.field.requireUppercase"); } - if (trimmedPassword.length < 8) { - return t("form.field.minValue", { 0: 8 }); + if (!/\d/.test(trimmedPassword)) { + return t("form.field.requireDigit"); } - if (trimmedPassword.length > 30) { - return t("form.field.maxValue", { 0: 30 }); + if (!/[^\w\s]/.test(trimmedPassword)) { + return t("form.field.requireSpecialChars"); } if ( From c59d8104e1254b6b4dd7215bcb94efb2d5684fed Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Wed, 13 Mar 2024 14:33:03 +0100 Subject: [PATCH 058/248] refactor(ui): polish `validatePassword` and add minor optimizations --- webapp/public/locales/en/main.json | 15 ++--- webapp/public/locales/fr/main.json | 15 ++--- .../dialog/GroupFormDialog/GroupForm.tsx | 2 +- .../Users/dialog/UserFormDialog/UserForm.tsx | 10 +-- .../Candidates/CreateCandidateDialog.tsx | 2 +- webapp/src/utils/validationUtils.ts | 64 ++++++++----------- 6 files changed, 49 insertions(+), 59 deletions(-) diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 26965fd8b3..4c540dfb4b 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -27,7 +27,6 @@ "global.group": "Group", "global.variants": "Variants management", "global.password": "Password", - "global.confirmPassword": "Confirm password", "global.create": "Create", "global.open": "Open", "global.name": "Name", @@ -118,14 +117,13 @@ "form.field.minValue": "The minimum value is {{0}}", "form.field.maxValue": "The maximum value is {{0}}", "form.field.notAllowedValue": "Not allowed value", - "form.field.allowedChars": "Special characters allowed: {{0}}", + "form.field.specialChars": "Special characters allowed: {{0}}", "form.field.specialCharsNotAllowed": "Special characters are not allowed", "form.field.spacesNotAllowed": "Spaces are not allowed", - "form.field.requireLowercase": "Password must contain at least one lowercase letter.", - "form.field.requireUppercase": "Password must contain at least one uppercase letter.", - "form.field.requireDigit": "Password must contain at least one digit.", - "form.field.requireSpecialChars": "Password must contain at least one special character.", - "form.field.requireMinimumLength": "Password must be at least 8 characters long.", + "form.field.requireLowercase": "Must contain at least one lowercase letter.", + "form.field.requireUppercase": "Must contain at least one uppercase letter.", + "form.field.requireDigit": "Must contain at least one digit.", + "form.field.requireSpecialChars": "Must contain at least one special character.", "matrix.graphSelector": "Columns", "matrix.message.importHint": "Click or drag and drop a matrix here", "matrix.importNewMatrix": "Import a new matrix", @@ -194,7 +192,8 @@ "settings.error.groupRolesSave": "Role(s) for group '{{0}}' not saved", "settings.error.tokenSave": "'{{0}}' token not saved", "settings.error.updateMaintenance": "Maintenance mode not updated", - "settings.error.passwordMismatch": "Passwords do not match", + "settings.user.form.confirmPassword":"Confirm password", + "settings.user.form.error.passwordMismatch": "Passwords do not match", "launcher.additionalModes": "Additional modes", "launcher.autoUnzip": "Automatically unzip", "launcher.xpress": "Xpress (>= 8.3)", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 85edfce9bb..2e79cffd7e 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -27,7 +27,6 @@ "global.group": "Groupe", "global.variants": "Gestion des variantes", "global.password": "Mot de passe", - "global.confirmPassword": "Confirmer le mot de passe", "global.create": "Créer", "global.open": "Ouvrir", "global.name": "Nom", @@ -118,14 +117,13 @@ "form.field.minValue": "La valeur minimum est {{0}}", "form.field.maxValue": "La valeur maximum est {{0}}", "form.field.notAllowedValue": "Valeur non autorisée", - "form.field.allowedChars": "Caractères spéciaux autorisés: {{0}}", + "form.field.specialChars": "Caractères spéciaux autorisés: {{0}}", "form.field.specialCharsNotAllowed": "Les caractères spéciaux ne sont pas autorisés", "form.field.spacesNotAllowed": "Les espaces ne sont pas autorisés", - "form.field.requireLowercase": "Le mot de passe doit contenir au moins une lettre minuscule.", - "form.field.requireUppercase": "Le mot de passe doit contenir au moins une lettre majuscule.", - "form.field.requireDigit": "Le mot de passe doit contenir au moins un chiffre.", - "form.field.requireSpecialChars": "Le mot de passe doit contenir au moins un caractère spécial.", - "form.field.requireMinimumLength": "Le mot de passe doit comporter au moins 8 caractères.", + "form.field.requireLowercase": "Doit contenir au moins une lettre minuscule.", + "form.field.requireUppercase": "Doit contenir au moins une lettre majuscule.", + "form.field.requireDigit": "Doit contenir au moins un chiffre.", + "form.field.requireSpecialChars": "Doit contenir au moins un caractère spécial.", "matrix.graphSelector": "Colonnes", "matrix.message.importHint": "Cliquer ou glisser une matrice ici", "matrix.importNewMatrix": "Import d'une nouvelle matrice", @@ -194,7 +192,8 @@ "settings.error.groupRolesSave": "Role(s) pour le groupe '{{0}}' non sauvegardé", "settings.error.tokenSave": "Token '{{0}}' non sauvegardé", "settings.error.updateMaintenance": "Erreur lors du changement du status de maintenance", - "settings.error.passwordMismatch": "Les mots de passe ne correspondent pas", + "settings.user.form.confirmPassword": "Confirmer le mot de passe", + "settings.user.form.error.passwordMismatch": "Les mots de passe ne correspondent pas", "launcher.additionalModes": "Mode additionnels", "launcher.autoUnzip": "Dézippage automatique", "launcher.xpress": "Xpress (>= 8.3)", diff --git a/webapp/src/components/App/Settings/Groups/dialog/GroupFormDialog/GroupForm.tsx b/webapp/src/components/App/Settings/Groups/dialog/GroupFormDialog/GroupForm.tsx index 362866eca5..e17dee147b 100644 --- a/webapp/src/components/App/Settings/Groups/dialog/GroupFormDialog/GroupForm.tsx +++ b/webapp/src/components/App/Settings/Groups/dialog/GroupFormDialog/GroupForm.tsx @@ -115,7 +115,7 @@ function GroupForm(props: UseFormReturnPlus) { validateString(v, { existingValues: existingGroups, excludedValues: RESERVED_GROUP_NAMES, - }) || undefined, + }), })} /> {/* Permissions */} diff --git a/webapp/src/components/App/Settings/Users/dialog/UserFormDialog/UserForm.tsx b/webapp/src/components/App/Settings/Users/dialog/UserFormDialog/UserForm.tsx index 884b7a118b..59acfc6dc5 100644 --- a/webapp/src/components/App/Settings/Users/dialog/UserFormDialog/UserForm.tsx +++ b/webapp/src/components/App/Settings/Users/dialog/UserFormDialog/UserForm.tsx @@ -115,7 +115,7 @@ function UserForm(props: Props) { validateString(v, { existingValues: existingUsers, excludedValues: RESERVED_USER_NAMES, - }) || undefined, + }), })} /> validatePassword(v) || undefined, + validate: (v) => validatePassword(v), })} /> validatePassword(v, getValues("password")), + validate: (v) => + v === getValues("password") || + t("settings.user.form.error.passwordMismatch"), })} /> diff --git a/webapp/src/components/App/Singlestudy/explore/Xpansion/Candidates/CreateCandidateDialog.tsx b/webapp/src/components/App/Singlestudy/explore/Xpansion/Candidates/CreateCandidateDialog.tsx index 58f7451a20..eca88218e4 100644 --- a/webapp/src/components/App/Singlestudy/explore/Xpansion/Candidates/CreateCandidateDialog.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Xpansion/Candidates/CreateCandidateDialog.tsx @@ -82,7 +82,7 @@ function CreateCandidateDialog(props: PropType) { validateString(v, { existingValues: existingCandidates, allowSpaces: false, - allowedChars: "&_*", + specialChars: "&_*", }), }} sx={{ mx: 0 }} diff --git a/webapp/src/utils/validationUtils.ts b/webapp/src/utils/validationUtils.ts index 3d771356e6..94f1f95c30 100644 --- a/webapp/src/utils/validationUtils.ts +++ b/webapp/src/utils/validationUtils.ts @@ -9,7 +9,7 @@ interface ValidationOptions { excludedValues?: string[]; isCaseSensitive?: boolean; allowSpecialChars?: boolean; - allowedChars?: string; + specialChars?: string; allowSpaces?: boolean; editedValue?: string; min?: number; @@ -27,16 +27,16 @@ interface ValidationOptions { * character validations, and uniqueness against provided arrays of existing and excluded values. * * @param value - The string to validate. Leading and trailing spaces will be trimmed. - * @param options - Configuration options for validation. - * @param options.existingValues - An array of strings to check against for duplicates. Comparison is case-insensitive by default. - * @param options.excludedValues - An array of strings that the value should not match. - * @param options.isCaseSensitive - Whether the comparison with `existingValues` and `excludedValues` is case-sensitive. Defaults to false. - * @param options.allowSpecialChars - Flags if special characters are permitted in the value. - * @param options.allowedChars - A string representing additional allowed characters outside the typical alphanumeric scope. - * @param options.allowSpaces - Flags if spaces are allowed in the value. - * @param options.editedValue - The current value being edited, to exclude it from duplicate checks. - * @param options.min - Minimum length required for the string. Defaults to 0. - * @param options.max - Maximum allowed length for the string. Defaults to 255. + * @param options - Configuration options for validation. (Optional) + * @param [options.existingValues=[]] - An array of strings to check against for duplicates. Comparison is case-insensitive by default. + * @param [options.excludedValues=[]] - An array of strings that the value should not match. + * @param [options.isCaseSensitive=false] - Whether the comparison with `existingValues` and `excludedValues` is case-sensitive. Defaults to false. + * @param [options.allowSpecialChars=true] - Flags if special characters are permitted in the value. + * @param [options.specialChars="&()_-"] - A string representing additional allowed characters outside the typical alphanumeric scope. + * @param [options.allowSpaces=true] - Flags if spaces are allowed in the value. + * @param [options.editedValue=""] - The current value being edited, to exclude it from duplicate checks. + * @param [options.min=0] - Minimum length required for the string. Defaults to 0. + * @param [options.max=255] - Maximum allowed length for the string. Defaults to 255. * @returns True if validation is successful, or a localized error message if it fails. */ export function validateString( @@ -49,7 +49,7 @@ export function validateString( isCaseSensitive = false, allowSpecialChars = true, allowSpaces = true, - allowedChars = "&()_-", + specialChars = "&()_-", editedValue = "", min = 0, max = 255, @@ -74,15 +74,15 @@ export function validateString( } // Compiles a regex pattern based on allowed characters and flags. - const allowedCharsPattern = new RegExp( - generatePattern(allowSpaces, allowSpecialChars, allowedChars), + const specialCharsPattern = new RegExp( + generatePattern(allowSpaces, allowSpecialChars, specialChars), ); // Validates the string against the allowed characters regex. - if (!allowedCharsPattern.test(trimmedValue)) { - return allowSpecialChars - ? t("form.field.allowedChars", { 0: allowedChars }) - : t("form.field.specialCharsNotAllowed"); + if (!specialCharsPattern.test(trimmedValue)) { + return specialChars === "" || !allowSpecialChars + ? t("form.field.specialCharsNotAllowed") + : t("form.field.specialChars", { 0: specialChars }); } // Normalize the value for comparison, based on case sensitivity option. @@ -114,13 +114,9 @@ export function validateString( * Validates a password string for strong security criteria. * * @param password - The password to validate. - * @param confirmPassword - An optional second password to compare against the first for matching. * @returns True if validation is successful, or a localized error message if it fails. */ -export function validatePassword( - password: string, - confirmPassword?: string, -): string | true { +export function validatePassword(password: string): string | true { const trimmedPassword = password.trim(); if (!trimmedPassword) { @@ -151,13 +147,6 @@ export function validatePassword( return t("form.field.requireSpecialChars"); } - if ( - confirmPassword !== undefined && - trimmedPassword !== confirmPassword.trim() - ) { - return t("settings.error.passwordMismatch"); - } - return true; } @@ -165,28 +154,29 @@ export function validatePassword( // Utils //////////////////////////////////////////////////////////////// -// Function to escape special characters in allowedChars -const escapeSpecialChars = (chars: string) => - chars.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&"); +// Escape special characters in specialChars +function escapeSpecialChars(chars: string) { + return chars.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&"); +} /** * Generates a regular expression pattern for string validation based on specified criteria. * This pattern includes considerations for allowing spaces, special characters, and any additional - * characters specified in `allowedChars`. + * characters specified in `specialChars`. * * @param allowSpaces - Indicates if spaces are permitted in the string. * @param allowSpecialChars - Indicates if special characters are permitted. - * @param allowedChars - Specifies additional characters to allow in the string. + * @param specialChars - Specifies additional characters to allow in the string. * @returns The regular expression pattern as a string. */ function generatePattern( allowSpaces: boolean, allowSpecialChars: boolean, - allowedChars: string, + specialChars: string, ): string { const basePattern = "^[a-zA-Z0-9"; const spacePattern = allowSpaces ? " " : ""; const specialCharsPattern = - allowSpecialChars && allowedChars ? escapeSpecialChars(allowedChars) : ""; + allowSpecialChars && specialChars ? escapeSpecialChars(specialChars) : ""; return basePattern + spacePattern + specialCharsPattern + "]*$"; } From b750874ed532cb17c862132ddd8392abf08fda94 Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Mon, 11 Mar 2024 14:43:35 +0100 Subject: [PATCH 059/248] fix(ui-launcher): add default value in options for `time_limit` --- webapp/src/components/App/Studies/LauncherDialog.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/webapp/src/components/App/Studies/LauncherDialog.tsx b/webapp/src/components/App/Studies/LauncherDialog.tsx index 1419bfe6ac..5beae94382 100644 --- a/webapp/src/components/App/Studies/LauncherDialog.tsx +++ b/webapp/src/components/App/Studies/LauncherDialog.tsx @@ -39,7 +39,8 @@ import { convertVersions } from "../../../services/utils"; import UsePromiseCond from "../../common/utils/UsePromiseCond"; import SwitchFE from "../../common/fieldEditors/SwitchFE"; -const LAUNCH_LOAD_DEFAULT = 22; +const DEFAULT_NB_CPU = 22; +const DEFAULT_TIME_LIMIT = 240 * 3600; // 240 hours in seconds interface Props { open: boolean; @@ -53,8 +54,9 @@ function LauncherDialog(props: Props) { const { enqueueSnackbar } = useSnackbar(); const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); const [options, setOptions] = useState({ - nb_cpu: LAUNCH_LOAD_DEFAULT, + nb_cpu: DEFAULT_NB_CPU, auto_unzip: true, + time_limit: DEFAULT_TIME_LIMIT, }); const [solverVersion, setSolverVersion] = useState(); const [isLaunching, setIsLaunching] = useState(false); From 86c806c52d3c09e5bc3296918b374604dafb7058 Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Wed, 13 Mar 2024 12:20:47 +0100 Subject: [PATCH 060/248] refactor(ui-launcher): optimize `time_limit` parsing --- .../components/App/Studies/LauncherDialog.tsx | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/webapp/src/components/App/Studies/LauncherDialog.tsx b/webapp/src/components/App/Studies/LauncherDialog.tsx index 5beae94382..7e610e2f88 100644 --- a/webapp/src/components/App/Studies/LauncherDialog.tsx +++ b/webapp/src/components/App/Studies/LauncherDialog.tsx @@ -38,6 +38,7 @@ import CheckBoxFE from "../../common/fieldEditors/CheckBoxFE"; import { convertVersions } from "../../../services/utils"; import UsePromiseCond from "../../common/utils/UsePromiseCond"; import SwitchFE from "../../common/fieldEditors/SwitchFE"; +import moment from "moment"; const DEFAULT_NB_CPU = 22; const DEFAULT_TIME_LIMIT = 240 * 3600; // 240 hours in seconds @@ -172,12 +173,16 @@ function LauncherDialog(props: Props) { // Utils //////////////////////////////////////////////////////////////// - const timeLimitParse = (value: string): number => { - try { - return parseInt(value, 10) * 3600; - } catch { - return 48 * 3600; - } + /** + * Parses an hour value from a string and converts it to seconds. + * If the input is invalid, returns a default value. + * + * @param hourString - A string representing the number of hours. + * @returns The equivalent number of seconds, or a default value for invalid inputs. + */ + const parseHoursToSeconds = (hourString: string): number => { + const seconds = moment.duration(hourString, "hours").asSeconds(); + return seconds > 0 ? seconds : DEFAULT_TIME_LIMIT; }; //////////////////////////////////////////////////////////////// @@ -267,9 +272,10 @@ function LauncherDialog(props: Props) { label={t("study.timeLimit")} type="number" variant="filled" - value={(options.time_limit ?? 864000) / 3600} // 240 hours default + // Convert from seconds to hours the displayed value + value={(options.time_limit ?? DEFAULT_TIME_LIMIT) / 3600} onChange={(e) => - handleChange("time_limit", timeLimitParse(e.target.value)) + handleChange("time_limit", parseHoursToSeconds(e.target.value)) } InputLabelProps={{ shrink: true, From 1f687f7e4fd1921e3c3295d8373a54a4ca21cfbc Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Mon, 11 Mar 2024 14:47:20 +0100 Subject: [PATCH 061/248] feat(api-launcher): add default values for `time_limit` and `nb_cpu` --- antarest/launcher/model.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/antarest/launcher/model.py b/antarest/launcher/model.py index a8f856269d..b96759dfdd 100644 --- a/antarest/launcher/model.py +++ b/antarest/launcher/model.py @@ -25,13 +25,14 @@ class LauncherParametersDTO(BaseModel): adequacy_patch: t.Optional[t.Dict[str, t.Any]] = None nb_cpu: t.Optional[int] = None post_processing: bool = False - time_limit: t.Optional[int] = None # 3600 ≤ time_limit < 864000 (10 days) + time_limit: t.Optional[int] = 240 * 3600 # Default value set to 240 hours (in seconds) xpansion: t.Union[XpansionParametersDTO, bool, None] = None xpansion_r_version: bool = False archive_output: bool = True auto_unzip: bool = True output_suffix: t.Optional[str] = None other_options: t.Optional[str] = None + # add extensions field here @classmethod From baba0d974348048a062042d7877a3897e9e0d47c Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Wed, 13 Mar 2024 15:12:06 +0100 Subject: [PATCH 062/248] test(launcher): update slurm tests default value for `time_limit` --- tests/launcher/test_slurm_launcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/launcher/test_slurm_launcher.py b/tests/launcher/test_slurm_launcher.py index a3a8cfe90b..a8530907f0 100644 --- a/tests/launcher/test_slurm_launcher.py +++ b/tests/launcher/test_slurm_launcher.py @@ -37,7 +37,7 @@ def launcher_config(tmp_path: Path) -> Config: "key_password": "password", "password": "password", "default_wait_time": 10, - "default_time_limit": 20, + "default_time_limit": 860400, # 240 hours (in seconds) "default_json_db_name": "antares.db", "slurm_script_path": "/path/to/slurm/launcher.sh", "partition": "fake_partition", From bd1a69b995d3b0a3c286633df95a1d05c97dd04c Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Thu, 14 Mar 2024 12:06:11 +0100 Subject: [PATCH 063/248] feat(api-launcher): simplify reading of SLURM launcher parameters --- .../adapters/slurm_launcher/slurm_launcher.py | 25 +++++++------------ tests/launcher/test_slurm_launcher.py | 8 +++--- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/antarest/launcher/adapters/slurm_launcher/slurm_launcher.py b/antarest/launcher/adapters/slurm_launcher/slurm_launcher.py index d2ce3ad1ec..6aba6f21b3 100644 --- a/antarest/launcher/adapters/slurm_launcher/slurm_launcher.py +++ b/antarest/launcher/adapters/slurm_launcher/slurm_launcher.py @@ -513,22 +513,15 @@ def _apply_params(self, launcher_params: LauncherParametersDTO) -> argparse.Name ): other_options.append("xpansion_sensitivity") - time_limit = launcher_params.time_limit - if time_limit is not None: - if MIN_TIME_LIMIT > time_limit: - logger.warning( - f"Invalid slurm launcher time limit ({time_limit})," - f" should be higher than {MIN_TIME_LIMIT}. Using min limit." - ) - launcher_args.time_limit = MIN_TIME_LIMIT - elif time_limit >= MAX_TIME_LIMIT: - logger.warning( - f"Invalid slurm launcher time limit ({time_limit})," - f" should be lower than {MAX_TIME_LIMIT}. Using max limit." - ) - launcher_args.time_limit = MAX_TIME_LIMIT - 3600 - else: - launcher_args.time_limit = time_limit + # The `time_limit` parameter could be `None`, in that case, the default value is used. + time_limit = launcher_params.time_limit or MIN_TIME_LIMIT + time_limit = min(max(time_limit, MIN_TIME_LIMIT), MAX_TIME_LIMIT) + if launcher_args.time_limit != time_limit: + logger.warning( + f"Invalid slurm launcher time_limit ({time_limit})," + f" should be between {MIN_TIME_LIMIT} and {MAX_TIME_LIMIT}" + ) + launcher_args.time_limit = time_limit post_processing = launcher_params.post_processing if post_processing is not None: diff --git a/tests/launcher/test_slurm_launcher.py b/tests/launcher/test_slurm_launcher.py index a8530907f0..e1c69f63d4 100644 --- a/tests/launcher/test_slurm_launcher.py +++ b/tests/launcher/test_slurm_launcher.py @@ -10,7 +10,6 @@ from antareslauncher.data_repo.data_repo_tinydb import DataRepoTinydb from antareslauncher.main import MainParameters from antareslauncher.study_dto import StudyDTO -from sqlalchemy.orm import Session # type: ignore from antarest.core.config import Config, LauncherConfig, NbCoresConfig, SlurmConfig from antarest.launcher.adapters.abstractlauncher import LauncherInitException @@ -37,7 +36,7 @@ def launcher_config(tmp_path: Path) -> Config: "key_password": "password", "password": "password", "default_wait_time": 10, - "default_time_limit": 860400, # 240 hours (in seconds) + "default_time_limit": MAX_TIME_LIMIT, "default_json_db_name": "antares.db", "slurm_script_path": "/path/to/slurm/launcher.sh", "partition": "fake_partition", @@ -203,11 +202,14 @@ def test_extra_parameters(launcher_config: Config) -> None: launcher_params = apply_params(LauncherParametersDTO(nb_cpu=999)) assert launcher_params.n_cpu == slurm_config.nb_cores.default # out of range + launcher_params = apply_params(LauncherParametersDTO.construct(time_limit=None)) + assert launcher_params.time_limit == MIN_TIME_LIMIT + launcher_params = apply_params(LauncherParametersDTO(time_limit=10)) assert launcher_params.time_limit == MIN_TIME_LIMIT launcher_params = apply_params(LauncherParametersDTO(time_limit=999999999)) - assert launcher_params.time_limit == MAX_TIME_LIMIT - 3600 + assert launcher_params.time_limit == MAX_TIME_LIMIT launcher_params = apply_params(LauncherParametersDTO(time_limit=99999)) assert launcher_params.time_limit == 99999 From 60cca11f880b21122bb91b349037b769f622aa8e Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Thu, 14 Mar 2024 12:06:44 +0100 Subject: [PATCH 064/248] style(api-launcher): correct typing of `time_limit` parameter in model --- antarest/launcher/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/antarest/launcher/model.py b/antarest/launcher/model.py index b96759dfdd..3bd3427a07 100644 --- a/antarest/launcher/model.py +++ b/antarest/launcher/model.py @@ -25,7 +25,7 @@ class LauncherParametersDTO(BaseModel): adequacy_patch: t.Optional[t.Dict[str, t.Any]] = None nb_cpu: t.Optional[int] = None post_processing: bool = False - time_limit: t.Optional[int] = 240 * 3600 # Default value set to 240 hours (in seconds) + time_limit: int = 240 * 3600 # Default value set to 240 hours (in seconds) xpansion: t.Union[XpansionParametersDTO, bool, None] = None xpansion_r_version: bool = False archive_output: bool = True From a36b236edc3ddfd874bfe3cbeb163239c911991d Mon Sep 17 00:00:00 2001 From: MartinBelthle <102529366+MartinBelthle@users.noreply.github.com> Date: Thu, 14 Mar 2024 22:58:01 +0100 Subject: [PATCH 065/248] feat(backend): support v8.7's upgrader, clusters and binding constraints (#1818) ANT-902 --- antarest/core/exceptions.py | 10 + antarest/matrixstore/service.py | 50 +- .../business/areas/thermal_management.py | 8 +- .../business/binding_constraint_management.py | 432 ++++++++--- .../study/business/table_mode_management.py | 9 +- antarest/study/model.py | 3 +- antarest/study/storage/rawstudy/ini_writer.py | 1 - .../filesystem/common/area_matrix_list.py | 38 +- .../model/filesystem/config/thermal.py | 87 ++- .../bindingconstraints/bindingcontraints.py | 65 +- .../simulation/ts_numbers/ts_numbers.py | 14 + .../study/storage/study_upgrader/__init__.py | 8 +- .../storage/study_upgrader/upgrader_860.py | 10 + .../storage/study_upgrader/upgrader_870.py | 59 ++ .../variantstudy/business/command_reverter.py | 34 +- .../binding_constraint/__init__.py | 2 +- .../binding_constraint/series_after_v87.py | 7 + .../{series.py => series_before_v87.py} | 0 .../business/matrix_constants_generator.py | 50 +- .../storage/variantstudy/business/utils.py | 14 +- .../business/utils_binding_constraint.py | 36 +- .../storage/variantstudy/command_factory.py | 11 +- .../command/create_binding_constraint.py | 246 ++++-- .../variantstudy/model/command/remove_area.py | 55 +- .../command/remove_binding_constraint.py | 6 +- .../command/update_binding_constraint.py | 45 +- .../storage/variantstudy/model/dbmodel.py | 2 +- .../variantstudy/variant_study_service.py | 10 +- antarest/study/web/study_data_blueprint.py | 25 +- resources/empty_study_870.zip | Bin 0 -> 64653 bytes .../test_synthesis/raw_study.synthesis.json | 180 ++++- .../variant_study.synthesis.json | 180 ++++- .../test_binding_constraints.py | 719 +++++++++++++----- .../study_data_blueprint/test_thermal.py | 206 ++--- tests/integration/test_integration.py | 3 + .../business/assets/little_study_700.zip | Bin 260020 -> 131440 bytes .../business/test_study_version_upgrader.py | 45 +- .../filesystem/config/test_config_files.py | 23 +- .../study_upgrader/test_upgrade_870.py | 31 + .../little_study_860.expected.zip | Bin 0 -> 124012 bytes .../little_study_860.zip | Bin 0 -> 123446 bytes .../little_study_860.expected.zip | Bin 0 -> 126978 bytes .../nominal_case/little_study_860.zip | Bin 0 -> 126416 bytes .../business/areas/test_thermal_management.py | 48 ++ .../test_matrix_constants_generator.py | 19 +- .../variantstudy/model/test_dbmodel.py | 2 +- tests/variantstudy/conftest.py | 12 + .../test_manage_binding_constraints.py | 7 +- tests/variantstudy/test_command_factory.py | 19 +- 49 files changed, 2029 insertions(+), 802 deletions(-) create mode 100644 antarest/study/storage/study_upgrader/upgrader_870.py create mode 100644 antarest/study/storage/variantstudy/business/matrix_constants/binding_constraint/series_after_v87.py rename antarest/study/storage/variantstudy/business/matrix_constants/binding_constraint/{series.py => series_before_v87.py} (100%) create mode 100644 resources/empty_study_870.zip create mode 100644 tests/storage/study_upgrader/test_upgrade_870.py create mode 100644 tests/storage/study_upgrader/upgrade_870/empty_binding_constraints/little_study_860.expected.zip create mode 100644 tests/storage/study_upgrader/upgrade_870/empty_binding_constraints/little_study_860.zip create mode 100644 tests/storage/study_upgrader/upgrade_870/nominal_case/little_study_860.expected.zip create mode 100644 tests/storage/study_upgrader/upgrade_870/nominal_case/little_study_860.zip diff --git a/antarest/core/exceptions.py b/antarest/core/exceptions.py index 361ba53644..dd9cd45a5e 100644 --- a/antarest/core/exceptions.py +++ b/antarest/core/exceptions.py @@ -221,6 +221,16 @@ def __init__(self, message: str) -> None: super().__init__(HTTPStatus.BAD_REQUEST, message) +class InvalidFieldForVersionError(HTTPException): + def __init__(self, message: str) -> None: + super().__init__(HTTPStatus.UNPROCESSABLE_ENTITY, message) + + +class IncoherenceBetweenMatricesLength(HTTPException): + def __init__(self, message: str) -> None: + super().__init__(HTTPStatus.UNPROCESSABLE_ENTITY, message) + + class MissingDataError(HTTPException): def __init__(self, message: str) -> None: super().__init__(HTTPStatus.NOT_FOUND, message) diff --git a/antarest/matrixstore/service.py b/antarest/matrixstore/service.py index c0a9d91788..732216c4b3 100644 --- a/antarest/matrixstore/service.py +++ b/antarest/matrixstore/service.py @@ -3,11 +3,11 @@ import json import logging import tempfile +import typing as t import zipfile from abc import ABC, abstractmethod from datetime import datetime from pathlib import Path -from typing import List, Optional, Sequence, Tuple, Union import numpy as np import py7zr @@ -58,11 +58,11 @@ def __init__(self, matrix_content_repository: MatrixContentRepository) -> None: self.matrix_content_repository = matrix_content_repository @abstractmethod - def create(self, data: Union[List[List[MatrixData]], npt.NDArray[np.float64]]) -> str: + def create(self, data: t.Union[t.List[t.List[MatrixData]], npt.NDArray[np.float64]]) -> str: raise NotImplementedError() @abstractmethod - def get(self, matrix_id: str) -> Optional[MatrixDTO]: + def get(self, matrix_id: str) -> t.Optional[MatrixDTO]: raise NotImplementedError() @abstractmethod @@ -73,12 +73,32 @@ def exists(self, matrix_id: str) -> bool: def delete(self, matrix_id: str) -> None: raise NotImplementedError() + def get_matrix_id(self, matrix: t.Union[t.List[t.List[float]], str]) -> str: + """ + Get the matrix ID from a matrix or a matrix link. + + Args: + matrix: The matrix or matrix link to get the ID from. + + Returns: + The matrix ID. + + Raises: + TypeError: If the provided matrix is neither a matrix nor a link to a matrix. + """ + if isinstance(matrix, str): + return matrix.lstrip("matrix://") + elif isinstance(matrix, list): + return self.create(matrix) + else: + raise TypeError(f"Invalid type for matrix: {type(matrix)}") + class SimpleMatrixService(ISimpleMatrixService): def __init__(self, matrix_content_repository: MatrixContentRepository): super().__init__(matrix_content_repository=matrix_content_repository) - def create(self, data: Union[List[List[MatrixData]], npt.NDArray[np.float64]]) -> str: + def create(self, data: t.Union[t.List[t.List[MatrixData]], npt.NDArray[np.float64]]) -> str: return self.matrix_content_repository.save(data) def get(self, matrix_id: str) -> MatrixDTO: @@ -119,7 +139,7 @@ def __init__( self.config = config @staticmethod - def _from_dto(dto: MatrixDTO) -> Tuple[Matrix, MatrixContent]: + def _from_dto(dto: MatrixDTO) -> t.Tuple[Matrix, MatrixContent]: matrix = Matrix( id=dto.id, width=dto.width, @@ -131,7 +151,7 @@ def _from_dto(dto: MatrixDTO) -> Tuple[Matrix, MatrixContent]: return matrix, content - def create(self, data: Union[List[List[MatrixData]], npt.NDArray[np.float64]]) -> str: + def create(self, data: t.Union[t.List[t.List[MatrixData]], npt.NDArray[np.float64]]) -> str: """ Creates a new matrix object with the specified data. @@ -168,7 +188,7 @@ def create(self, data: Union[List[List[MatrixData]], npt.NDArray[np.float64]]) - self.repo.save(matrix) return matrix_id - def create_by_importation(self, file: UploadFile, is_json: bool = False) -> List[MatrixInfoDTO]: + def create_by_importation(self, file: UploadFile, is_json: bool = False) -> t.List[MatrixInfoDTO]: """ Imports a matrix from a TSV or JSON file or a collection of matrices from a ZIP file. @@ -191,7 +211,7 @@ def create_by_importation(self, file: UploadFile, is_json: bool = False) -> List if file.content_type == "application/zip": with contextlib.closing(f): buffer = io.BytesIO(f.read()) - matrix_info: List[MatrixInfoDTO] = [] + matrix_info: t.List[MatrixInfoDTO] = [] if file.filename.endswith("zip"): with zipfile.ZipFile(buffer) as zf: for info in zf.infolist(): @@ -237,7 +257,7 @@ def get_dataset( self, id: str, params: RequestParameters, - ) -> Optional[MatrixDataSet]: + ) -> t.Optional[MatrixDataSet]: if not params.user: raise UserHasNotPermissionError() dataset = self.repo_dataset.get(id) @@ -250,7 +270,7 @@ def get_dataset( def create_dataset( self, dataset_info: MatrixDataSetUpdateDTO, - matrices: List[MatrixInfoDTO], + matrices: t.List[MatrixInfoDTO], params: RequestParameters, ) -> MatrixDataSet: if not params.user: @@ -296,10 +316,10 @@ def update_dataset( def list( self, - dataset_name: Optional[str], + dataset_name: t.Optional[str], filter_own: bool, params: RequestParameters, - ) -> List[MatrixDataSetDTO]: + ) -> t.List[MatrixDataSetDTO]: """ List matrix user metadata @@ -337,7 +357,7 @@ def delete_dataset(self, id: str, params: RequestParameters) -> str: self.repo_dataset.delete(id) return id - def get(self, matrix_id: str) -> Optional[MatrixDTO]: + def get(self, matrix_id: str) -> t.Optional[MatrixDTO]: """ Get a matrix object from the database and the matrix content repository. @@ -414,7 +434,7 @@ def check_access_permission( raise UserHasNotPermissionError() return access - def create_matrix_files(self, matrix_ids: Sequence[str], export_path: Path) -> str: + def create_matrix_files(self, matrix_ids: t.Sequence[str], export_path: Path) -> str: with tempfile.TemporaryDirectory(dir=self.config.storage.tmp_dir) as tmpdir: stopwatch = StopWatch() for mid in matrix_ids: @@ -461,7 +481,7 @@ def download_dataset( def download_matrix_list( self, - matrix_list: Sequence[str], + matrix_list: t.Sequence[str], dataset_name: str, params: RequestParameters, ) -> FileDownloadTaskDTO: diff --git a/antarest/study/business/areas/thermal_management.py b/antarest/study/business/areas/thermal_management.py index f44ad7ba10..5a106e7fa7 100644 --- a/antarest/study/business/areas/thermal_management.py +++ b/antarest/study/business/areas/thermal_management.py @@ -8,8 +8,8 @@ from antarest.study.model import Study from antarest.study.storage.rawstudy.model.filesystem.config.model import transform_name_to_id from antarest.study.storage.rawstudy.model.filesystem.config.thermal import ( - Thermal860Config, - Thermal860Properties, + Thermal870Config, + Thermal870Properties, ThermalConfigType, create_thermal_config, ) @@ -32,7 +32,7 @@ @camel_case_model -class ThermalClusterInput(Thermal860Properties, metaclass=AllOptionalMetaclass, use_none=True): +class ThermalClusterInput(Thermal870Properties, metaclass=AllOptionalMetaclass, use_none=True): """ Model representing the data structure required to edit an existing thermal cluster within a study. """ @@ -72,7 +72,7 @@ def to_config(self, study_version: t.Union[str, int]) -> ThermalConfigType: @camel_case_model -class ThermalClusterOutput(Thermal860Config, metaclass=AllOptionalMetaclass, use_none=True): +class ThermalClusterOutput(Thermal870Config, metaclass=AllOptionalMetaclass, use_none=True): """ Model representing the output data structure to display the details of a thermal cluster within a study. """ diff --git a/antarest/study/business/binding_constraint_management.py b/antarest/study/business/binding_constraint_management.py index 55112da1da..910c30dc51 100644 --- a/antarest/study/business/binding_constraint_management.py +++ b/antarest/study/business/binding_constraint_management.py @@ -1,32 +1,46 @@ -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Union, cast from pydantic import BaseModel, validator from antarest.core.exceptions import ( BindingConstraintNotFoundError, + CommandApplicationError, ConstraintAlreadyExistError, ConstraintIdNotFoundError, DuplicateConstraintName, + IncoherenceBetweenMatricesLength, InvalidConstraintName, + InvalidFieldForVersionError, MissingDataError, NoConstraintError, ) -from antarest.matrixstore.model import MatrixData from antarest.study.business.utils import execute_or_add_commands from antarest.study.model import Study from antarest.study.storage.rawstudy.model.filesystem.config.binding_constraint import BindingConstraintFrequency from antarest.study.storage.rawstudy.model.filesystem.config.model import transform_name_to_id +from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy from antarest.study.storage.storage_service import StudyStorageService -from antarest.study.storage.variantstudy.business.matrix_constants.binding_constraint.series import ( - default_bc_hourly, - default_bc_weekly_daily, +from antarest.study.storage.variantstudy.business.matrix_constants.binding_constraint.series_after_v87 import ( + default_bc_hourly as default_bc_hourly_87, +) +from antarest.study.storage.variantstudy.business.matrix_constants.binding_constraint.series_after_v87 import ( + default_bc_weekly_daily as default_bc_weekly_daily_87, +) +from antarest.study.storage.variantstudy.business.matrix_constants.binding_constraint.series_before_v87 import ( + default_bc_hourly as default_bc_hourly_86, +) +from antarest.study.storage.variantstudy.business.matrix_constants.binding_constraint.series_before_v87 import ( + default_bc_weekly_daily as default_bc_weekly_daily_86, ) -from antarest.study.storage.variantstudy.model.command.common import BindingConstraintOperator from antarest.study.storage.variantstudy.model.command.create_binding_constraint import ( + BindingConstraintMatrices, BindingConstraintProperties, + BindingConstraintProperties870, CreateBindingConstraint, ) +from antarest.study.storage.variantstudy.model.command.remove_binding_constraint import RemoveBindingConstraint from antarest.study.storage.variantstudy.model.command.update_binding_constraint import UpdateBindingConstraint +from antarest.study.storage.variantstudy.model.dbmodel import VariantStudy class AreaLinkDTO(BaseModel): @@ -102,23 +116,24 @@ class UpdateBindingConstProps(BaseModel): value: Any -class BindingConstraintPropertiesWithName(BindingConstraintProperties): +class BindingConstraintCreation(BindingConstraintMatrices, BindingConstraintProperties870): name: str + coeffs: Dict[str, List[float]] -class BindingConstraintDTO(BaseModel): +class BindingConstraintConfig(BindingConstraintProperties): id: str name: str - enabled: bool = True - time_step: BindingConstraintFrequency - operator: BindingConstraintOperator - values: Optional[Union[List[List[MatrixData]], str]] = None - comments: Optional[str] = None - filter_year_by_year: Optional[str] = None - filter_synthesis: Optional[str] = None constraints: Optional[List[ConstraintTermDTO]] +class BindingConstraintConfig870(BindingConstraintConfig): + group: Optional[str] = None + + +BindingConstraintConfigType = Union[BindingConstraintConfig870, BindingConstraintConfig] + + class BindingConstraintManager: def __init__( self, @@ -127,7 +142,7 @@ def __init__( self.storage_service = storage_service @staticmethod - def parse_constraint(key: str, value: str, char: str, new_config: BindingConstraintDTO) -> bool: + def parse_constraint(key: str, value: str, char: str, new_config: BindingConstraintConfigType) -> bool: split = key.split(char) if len(split) == 2: value1 = split[0] @@ -163,20 +178,24 @@ def parse_constraint(key: str, value: str, char: str, new_config: BindingConstra return False @staticmethod - def process_constraint( - constraint_value: Dict[str, Any], - ) -> BindingConstraintDTO: - new_config: BindingConstraintDTO = BindingConstraintDTO( - id=constraint_value["id"], - name=constraint_value["name"], - enabled=constraint_value["enabled"], - time_step=constraint_value["type"], - operator=constraint_value["operator"], - comments=constraint_value.get("comments", None), - filter_year_by_year=constraint_value.get("filter-year-by-year", ""), - filter_synthesis=constraint_value.get("filter-synthesis", ""), - constraints=None, - ) + def process_constraint(constraint_value: Dict[str, Any], version: int) -> BindingConstraintConfigType: + args = { + "id": constraint_value["id"], + "name": constraint_value["name"], + "enabled": constraint_value["enabled"], + "time_step": constraint_value["type"], + "operator": constraint_value["operator"], + "comments": constraint_value.get("comments", None), + "filter_year_by_year": constraint_value.get("filter-year-by-year", ""), + "filter_synthesis": constraint_value.get("filter-synthesis", ""), + "constraints": None, + } + if version < 870: + new_config: BindingConstraintConfigType = BindingConstraintConfig(**args) + else: + args["group"] = constraint_value.get("group") + new_config = BindingConstraintConfig870(**args) + for key, value in constraint_value.items(): if BindingConstraintManager.parse_constraint(key, value, "%", new_config): continue @@ -186,7 +205,7 @@ def process_constraint( @staticmethod def constraints_to_coeffs( - constraint: BindingConstraintDTO, + constraint: BindingConstraintConfigType, ) -> Dict[str, List[float]]: coeffs: Dict[str, List[float]] = {} if constraint.constraints is not None: @@ -200,42 +219,57 @@ def constraints_to_coeffs( def get_binding_constraint( self, study: Study, constraint_id: Optional[str] - ) -> Union[BindingConstraintDTO, List[BindingConstraintDTO], None]: + ) -> Union[BindingConstraintConfigType, List[BindingConstraintConfigType], None]: storage_service = self.storage_service.get_storage(study) file_study = storage_service.get_raw(study) config = file_study.tree.get(["input", "bindingconstraints", "bindingconstraints"]) config_values = list(config.values()) + study_version = int(study.version) if constraint_id: try: index = [value["id"] for value in config_values].index(constraint_id) config_value = config_values[index] - return BindingConstraintManager.process_constraint(config_value) + return BindingConstraintManager.process_constraint(config_value, study_version) except ValueError: return None binding_constraint = [] for config_value in config_values: - new_config = BindingConstraintManager.process_constraint(config_value) + new_config = BindingConstraintManager.process_constraint(config_value, study_version) binding_constraint.append(new_config) return binding_constraint def create_binding_constraint( self, study: Study, - data: BindingConstraintPropertiesWithName, + data: BindingConstraintCreation, ) -> None: bc_id = transform_name_to_id(data.name) + version = int(study.version) if not bc_id: raise InvalidConstraintName(f"Invalid binding constraint name: {data.name}.") - file_study = self.storage_service.get_storage(study).get_raw(study) - binding_constraints = self.get_binding_constraint(study, None) - existing_ids = {bc.id for bc in binding_constraints} # type: ignore - - if bc_id in existing_ids: + if bc_id in {bc.id for bc in self.get_binding_constraint(study, None)}: # type: ignore raise DuplicateConstraintName(f"A binding constraint with the same name already exists: {bc_id}.") + if data.group and version < 870: + raise InvalidFieldForVersionError( + f"You cannot specify a group as your study version is older than v8.7: {data.group}" + ) + + if version >= 870 and not data.group: + data.group = "default" + + matrix_terms_list = {"eq": data.equal_term_matrix, "lt": data.less_term_matrix, "gt": data.greater_term_matrix} + file_study = self.storage_service.get_storage(study).get_raw(study) + if version >= 870: + if data.values is not None: + raise InvalidFieldForVersionError("You cannot fill 'values' as it refers to the matrix before v8.7") + check_matrices_coherence(file_study, data.group or "default", bc_id, matrix_terms_list, {}) + elif any(matrix_terms_list.values()): + raise InvalidFieldForVersionError("You cannot fill a 'matrix_term' as these values refer to v8.7+ studies") + command = CreateBindingConstraint( name=data.name, enabled=data.enabled, @@ -243,11 +277,20 @@ def create_binding_constraint( operator=data.operator, coeffs=data.coeffs, values=data.values, + less_term_matrix=data.less_term_matrix, + equal_term_matrix=data.equal_term_matrix, + greater_term_matrix=data.greater_term_matrix, filter_year_by_year=data.filter_year_by_year, filter_synthesis=data.filter_synthesis, comments=data.comments or "", + group=data.group, command_context=self.storage_service.variant_study_service.command_factory.command_context, ) + + # Validates the matrices. Needed when the study is a variant because we only append the command to the list + if isinstance(study, VariantStudy): + command.validates_and_fills_matrices(specific_matrices=None, version=version, create=True) + execute_or_add_commands(study, file_study, [command], self.storage_service) def update_binding_constraint( @@ -258,87 +301,55 @@ def update_binding_constraint( ) -> None: file_study = self.storage_service.get_storage(study).get_raw(study) constraint = self.get_binding_constraint(study, binding_constraint_id) - if not isinstance(constraint, BindingConstraintDTO): + study_version = int(study.version) + if not isinstance(constraint, BindingConstraintConfig) and not isinstance( + constraint, BindingConstraintConfig870 + ): raise BindingConstraintNotFoundError(study.id) - if data.key == "time_step" and data.value != constraint.time_step: - # The user changed the time step, we need to update the matrix accordingly - matrix = { - BindingConstraintFrequency.HOURLY.value: default_bc_hourly, - BindingConstraintFrequency.DAILY.value: default_bc_weekly_daily, - BindingConstraintFrequency.WEEKLY.value: default_bc_weekly_daily, - }[data.value].tolist() - else: - # The user changed another property, we keep the matrix as it is - matrix = constraint.values + if study_version >= 870: + validates_matrices_coherence(file_study, binding_constraint_id, constraint.group or "default", data) # type: ignore - command = UpdateBindingConstraint( - id=constraint.id, - enabled=data.value if data.key == "enabled" else constraint.enabled, - time_step=data.value if data.key == "time_step" else constraint.time_step, - operator=data.value if data.key == "operator" else constraint.operator, - coeffs=BindingConstraintManager.constraints_to_coeffs(constraint), - values=matrix, - filter_year_by_year=data.value if data.key == "filterByYear" else constraint.filter_year_by_year, - filter_synthesis=data.value if data.key == "filterSynthesis" else constraint.filter_synthesis, - comments=data.value if data.key == "comments" else constraint.comments, - command_context=self.storage_service.variant_study_service.command_factory.command_context, - ) - execute_or_add_commands(study, file_study, [command], self.storage_service) + args = { + "id": binding_constraint_id, + "enabled": data.value if data.key == "enabled" else constraint.enabled, + "time_step": data.value if data.key == "time_step" else constraint.time_step, + "operator": data.value if data.key == "operator" else constraint.operator, + "coeffs": BindingConstraintManager.constraints_to_coeffs(constraint), + "filter_year_by_year": data.value if data.key == "filterByYear" else constraint.filter_year_by_year, + "filter_synthesis": data.value if data.key == "filterSynthesis" else constraint.filter_synthesis, + "comments": data.value if data.key == "comments" else constraint.comments, + "command_context": self.storage_service.variant_study_service.command_factory.command_context, + } - @staticmethod - def find_constraint_term_id(constraints_term: List[ConstraintTermDTO], constraint_term_id: str) -> int: - try: - index = [elm.id for elm in constraints_term].index(constraint_term_id) - return index - except ValueError: - return -1 + args = _fill_group_value(data, constraint, study_version, args) + args = _fill_matrices_according_to_version(data, study_version, args) - def add_new_constraint_term( - self, - study: Study, - binding_constraint_id: str, - constraint_term: ConstraintTermDTO, - ) -> None: - file_study = self.storage_service.get_storage(study).get_raw(study) - constraint = self.get_binding_constraint(study, binding_constraint_id) - if not isinstance(constraint, BindingConstraintDTO): - raise BindingConstraintNotFoundError(study.id) - - if constraint_term.data is None: - raise MissingDataError("Add new constraint term : data is missing") + if data.key == "time_step" and data.value != constraint.time_step: + # The user changed the time step, we need to update the matrix accordingly + args = _replace_matrices_according_to_frequency_and_version(data, study_version, args) - constraint_id = constraint_term.data.generate_id() - constraints_term = constraint.constraints or [] - if BindingConstraintManager.find_constraint_term_id(constraints_term, constraint_id) >= 0: - raise ConstraintAlreadyExistError(study.id) + command = UpdateBindingConstraint(**args) + # Validates the matrices. Needed when the study is a variant because we only append the command to the list + if isinstance(study, VariantStudy): + updated_matrix = None + if data.key in ["less_term_matrix", "equal_term_matrix", "greater_term_matrix"]: + updated_matrix = [data.key] + command.validates_and_fills_matrices(specific_matrices=updated_matrix, version=study_version, create=False) - constraints_term.append( - ConstraintTermDTO( - id=constraint_id, - weight=constraint_term.weight if constraint_term.weight is not None else 0.0, - offset=constraint_term.offset, - data=constraint_term.data, - ) - ) - coeffs = {} - for term in constraints_term: - coeffs[term.id] = [term.weight] - if term.offset is not None: - coeffs[term.id].append(term.offset) + execute_or_add_commands(study, file_study, [command], self.storage_service) - command = UpdateBindingConstraint( - id=constraint.id, - enabled=constraint.enabled, - time_step=constraint.time_step, - operator=constraint.operator, - coeffs=coeffs, - values=constraint.values, - comments=constraint.comments, - filter_year_by_year=constraint.filter_year_by_year, - filter_synthesis=constraint.filter_synthesis, + def remove_binding_constraint(self, study: Study, binding_constraint_id: str) -> None: + command = RemoveBindingConstraint( + id=binding_constraint_id, command_context=self.storage_service.variant_study_service.command_factory.command_context, ) + file_study = self.storage_service.get_storage(study).get_raw(study) + + # Needed when the study is a variant because we only append the command to the list + if isinstance(study, VariantStudy) and not self.get_binding_constraint(study, binding_constraint_id): + raise CommandApplicationError("Binding constraint not found") + execute_or_add_commands(study, file_study, [command], self.storage_service) def update_constraint_term( @@ -350,7 +361,7 @@ def update_constraint_term( file_study = self.storage_service.get_storage(study).get_raw(study) constraint = self.get_binding_constraint(study, binding_constraint_id) - if not isinstance(constraint, BindingConstraintDTO): + if not isinstance(constraint, BindingConstraintConfig) and not isinstance(constraint, BindingConstraintConfig): raise BindingConstraintNotFoundError(study.id) constraint_terms = constraint.constraints # existing constraint terms @@ -361,7 +372,7 @@ def update_constraint_term( if term_id is None: raise ConstraintIdNotFoundError(study.id) - term_id_index = BindingConstraintManager.find_constraint_term_id(constraint_terms, term_id) + term_id_index = find_constraint_term_id(constraint_terms, term_id) if term_id_index < 0: raise ConstraintIdNotFoundError(study.id) @@ -386,7 +397,6 @@ def update_constraint_term( time_step=constraint.time_step, operator=constraint.operator, coeffs=coeffs, - values=constraint.values, filter_year_by_year=constraint.filter_year_by_year, filter_synthesis=constraint.filter_synthesis, comments=constraint.comments, @@ -394,6 +404,52 @@ def update_constraint_term( ) execute_or_add_commands(study, file_study, [command], self.storage_service) + def add_new_constraint_term( + self, + study: Study, + binding_constraint_id: str, + constraint_term: ConstraintTermDTO, + ) -> None: + file_study = self.storage_service.get_storage(study).get_raw(study) + constraint = self.get_binding_constraint(study, binding_constraint_id) + if not isinstance(constraint, BindingConstraintConfig) and not isinstance(constraint, BindingConstraintConfig): + raise BindingConstraintNotFoundError(study.id) + + if constraint_term.data is None: + raise MissingDataError("Add new constraint term : data is missing") + + constraint_id = constraint_term.data.generate_id() + constraints_term = constraint.constraints or [] + if find_constraint_term_id(constraints_term, constraint_id) >= 0: + raise ConstraintAlreadyExistError(study.id) + + constraints_term.append( + ConstraintTermDTO( + id=constraint_id, + weight=constraint_term.weight if constraint_term.weight is not None else 0.0, + offset=constraint_term.offset, + data=constraint_term.data, + ) + ) + coeffs = {} + for term in constraints_term: + coeffs[term.id] = [term.weight] + if term.offset is not None: + coeffs[term.id].append(term.offset) + + command = UpdateBindingConstraint( + id=constraint.id, + enabled=constraint.enabled, + time_step=constraint.time_step, + operator=constraint.operator, + coeffs=coeffs, + comments=constraint.comments, + filter_year_by_year=constraint.filter_year_by_year, + filter_synthesis=constraint.filter_synthesis, + command_context=self.storage_service.variant_study_service.command_factory.command_context, + ) + execute_or_add_commands(study, file_study, [command], self.storage_service) + # FIXME create a dedicated delete service def remove_constraint_term( self, @@ -402,3 +458,151 @@ def remove_constraint_term( term_id: str, ) -> None: return self.update_constraint_term(study, binding_constraint_id, term_id) + + +def _fill_group_value( + data: UpdateBindingConstProps, constraint: BindingConstraintConfigType, version: int, args: Dict[str, Any] +) -> Dict[str, Any]: + if version < 870: + if data.key == "group": + raise InvalidFieldForVersionError( + f"You cannot specify a group as your study version is older than v8.7: {data.value}" + ) + else: + # cast to 870 to use the attribute group + constraint = cast(BindingConstraintConfig870, constraint) + args["group"] = data.value if data.key == "group" else constraint.group + return args + + +def _fill_matrices_according_to_version( + data: UpdateBindingConstProps, version: int, args: Dict[str, Any] +) -> Dict[str, Any]: + if data.key == "values": + if version >= 870: + raise InvalidFieldForVersionError("You cannot fill 'values' as it refers to the matrix before v8.7") + args["values"] = data.value + return args + for matrix in ["less_term_matrix", "equal_term_matrix", "greater_term_matrix"]: + if data.key == matrix: + if version < 870: + raise InvalidFieldForVersionError( + "You cannot fill a 'matrix_term' as these values refer to v8.7+ studies" + ) + args[matrix] = data.value + return args + return args + + +def _replace_matrices_according_to_frequency_and_version( + data: UpdateBindingConstProps, version: int, args: Dict[str, Any] +) -> Dict[str, Any]: + if version < 870: + matrix = { + BindingConstraintFrequency.HOURLY.value: default_bc_hourly_86, + BindingConstraintFrequency.DAILY.value: default_bc_weekly_daily_86, + BindingConstraintFrequency.WEEKLY.value: default_bc_weekly_daily_86, + }[data.value].tolist() + args["values"] = matrix + else: + matrix = { + BindingConstraintFrequency.HOURLY.value: default_bc_hourly_87, + BindingConstraintFrequency.DAILY.value: default_bc_weekly_daily_87, + BindingConstraintFrequency.WEEKLY.value: default_bc_weekly_daily_87, + }[data.value].tolist() + args["less_term_matrix"] = matrix + args["equal_term_matrix"] = matrix + args["greater_term_matrix"] = matrix + return args + + +def find_constraint_term_id(constraints_term: List[ConstraintTermDTO], constraint_term_id: str) -> int: + try: + index = [elm.id for elm in constraints_term].index(constraint_term_id) + return index + except ValueError: + return -1 + + +def get_binding_constraint_of_a_given_group(file_study: FileStudy, group_id: str) -> List[str]: + config = file_study.tree.get(["input", "bindingconstraints", "bindingconstraints"]) + config_values = list(config.values()) + return [bd["id"] for bd in config_values if bd["group"] == group_id] + + +def check_matrices_coherence( + file_study: FileStudy, + group_id: str, + binding_constraint_id: str, + matrix_terms: Dict[str, Any], + matrix_to_avoid: Dict[str, str], +) -> None: + given_number_of_cols = set() + for term_str, term_data in matrix_terms.items(): + if term_data: + nb_cols = len(term_data[0]) + if nb_cols > 1: + given_number_of_cols.add(nb_cols) + if len(given_number_of_cols) > 1: + raise IncoherenceBetweenMatricesLength( + f"The matrices of {binding_constraint_id} must have the same number of columns, currently {given_number_of_cols}" + ) + if len(given_number_of_cols) == 1: + given_size = list(given_number_of_cols)[0] + for bd_id in get_binding_constraint_of_a_given_group(file_study, group_id): + for term in list(matrix_terms.keys()): + if ( + bd_id not in matrix_to_avoid or matrix_to_avoid[bd_id] != term + ): # avoids to check the matrix that will be replaced + matrix_file = file_study.tree.get(url=["input", "bindingconstraints", f"{bd_id}_{term}"]) + column_size = len(matrix_file["data"][0]) + if column_size > 1 and column_size != given_size: + raise IncoherenceBetweenMatricesLength( + f"The matrices of the group {group_id} do not have the same number of columns" + ) + + +def validates_matrices_coherence( + file_study: FileStudy, binding_constraint_id: str, group: str, data: UpdateBindingConstProps +) -> None: + if data.key == "group": + matrix_terms = { + "eq": get_matrix_data(file_study, binding_constraint_id, "eq"), + "lt": get_matrix_data(file_study, binding_constraint_id, "lt"), + "gt": get_matrix_data(file_study, binding_constraint_id, "gt"), + } + check_matrices_coherence(file_study, data.value, binding_constraint_id, matrix_terms, {}) + + if data.key in ["less_term_matrix", "equal_term_matrix", "greater_term_matrix"]: + if isinstance(data.value, str): + raise NotImplementedError( + f"We do not currently handle binding constraint update for {data.key} with a string value. Please provide a matrix" + ) + if data.key == "less_term_matrix": + term_to_avoid = "lt" + matrix_terms = { + "lt": data.value, + "eq": get_matrix_data(file_study, binding_constraint_id, "eq"), + "gt": get_matrix_data(file_study, binding_constraint_id, "gt"), + } + elif data.key == "greater_term_matrix": + term_to_avoid = "gt" + matrix_terms = { + "gt": data.value, + "eq": get_matrix_data(file_study, binding_constraint_id, "eq"), + "lt": get_matrix_data(file_study, binding_constraint_id, "lt"), + } + else: + term_to_avoid = "eq" + matrix_terms = { + "eq": data.value, + "gt": get_matrix_data(file_study, binding_constraint_id, "gt"), + "lt": get_matrix_data(file_study, binding_constraint_id, "lt"), + } + check_matrices_coherence( + file_study, group, binding_constraint_id, matrix_terms, {binding_constraint_id: term_to_avoid} + ) + + +def get_matrix_data(file_study: FileStudy, binding_constraint_id: str, keyword: str) -> List[Any]: + return file_study.tree.get(url=["input", "bindingconstraints", f"{binding_constraint_id}_{keyword}"])["data"] # type: ignore diff --git a/antarest/study/business/table_mode_management.py b/antarest/study/business/table_mode_management.py index 43808e8248..260d258a12 100644 --- a/antarest/study/business/table_mode_management.py +++ b/antarest/study/business/table_mode_management.py @@ -112,6 +112,7 @@ class BindingConstraintColumns(FormFieldsBaseModel): type: Optional[BindingConstraintFrequency] operator: Optional[BindingConstraintOperator] enabled: Optional[StrictBool] + group: Optional[StrictStr] class ColumnInfo(TypedDict): @@ -342,6 +343,10 @@ class PathVars(TypedDict, total=False): "path": f"{BINDING_CONSTRAINT_PATH}/enabled", "default_value": True, }, + "group": { + "path": f"{BINDING_CONSTRAINT_PATH}/group", + "default_value": None, + }, }, } @@ -477,7 +482,9 @@ def set_table_data( if current_binding: col_values = columns.dict(exclude_none=True) - current_binding_dto = BindingConstraintManager.process_constraint(current_binding) + current_binding_dto = BindingConstraintManager.process_constraint( + current_binding, int(study.version) + ) commands.append( UpdateBindingConstraint( diff --git a/antarest/study/model.py b/antarest/study/model.py index e4e5e8ba42..40737debad 100644 --- a/antarest/study/model.py +++ b/antarest/study/model.py @@ -45,9 +45,10 @@ "840": "empty_study_840.zip", "850": "empty_study_850.zip", "860": "empty_study_860.zip", + "870": "empty_study_870.zip", } -NEW_DEFAULT_STUDY_VERSION: str = "860" +NEW_DEFAULT_STUDY_VERSION: str = "870" class StudyGroup(Base): # type:ignore diff --git a/antarest/study/storage/rawstudy/ini_writer.py b/antarest/study/storage/rawstudy/ini_writer.py index 9b348f1b27..3a5c1198d6 100644 --- a/antarest/study/storage/rawstudy/ini_writer.py +++ b/antarest/study/storage/rawstudy/ini_writer.py @@ -27,7 +27,6 @@ def _write_line( # type:ignore self, section_name, key, value ) if value is not None or not self._allow_no_value: # type:ignore - # value = IniConfigParser.format_value(value) value = delimiter + str(value).replace("\n", "\n\t") else: value = "" diff --git a/antarest/study/storage/rawstudy/model/filesystem/common/area_matrix_list.py b/antarest/study/storage/rawstudy/model/filesystem/common/area_matrix_list.py index 1d39a72a66..0191b866d1 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/common/area_matrix_list.py +++ b/antarest/study/storage/rawstudy/model/filesystem/common/area_matrix_list.py @@ -1,4 +1,4 @@ -from typing import Any, Callable, Dict, Optional +import typing as t from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfig from antarest.study.storage.rawstudy.model.filesystem.context import ContextServer @@ -44,8 +44,8 @@ def __init__( config: FileStudyTreeConfig, *, prefix: str = "", - matrix_class: Callable[..., INode[Any, Any, Any]] = InputSeriesMatrix, - additional_matrix_params: Optional[Dict[str, Any]] = None, + matrix_class: t.Callable[..., INode[t.Any, t.Any, t.Any]] = InputSeriesMatrix, + additional_matrix_params: t.Optional[t.Dict[str, t.Any]] = None, ): super().__init__(context, config) self.prefix = prefix @@ -77,7 +77,7 @@ def __init__( context: ContextServer, config: FileStudyTreeConfig, area: str, - matrix_class: Callable[[ContextServer, FileStudyTreeConfig], INode[Any, Any, Any]], + matrix_class: t.Callable[[ContextServer, FileStudyTreeConfig], INode[t.Any, t.Any, t.Any]], ): super().__init__(context, config) self.area = area @@ -91,13 +91,31 @@ def build(self) -> TREE: return children +class BindingConstraintMatrixList(FolderNode): + def __init__( + self, + context: ContextServer, + config: FileStudyTreeConfig, + matrix_class: t.Callable[[ContextServer, FileStudyTreeConfig], INode[t.Any, t.Any, t.Any]], + ): + super().__init__(context, config) + self.matrix_class = matrix_class + + def build(self) -> TREE: + """Builds the folder structure and creates child nodes representing each matrix file.""" + return { + file.stem: self.matrix_class(self.context, self.config.next_file(file.name)) + for file in self.config.path.glob("*.txt") + } + + class ThermalMatrixList(FolderNode): def __init__( self, context: ContextServer, config: FileStudyTreeConfig, area: str, - matrix_class: Callable[[ContextServer, FileStudyTreeConfig], INode[Any, Any, Any]], + matrix_class: t.Callable[[ContextServer, FileStudyTreeConfig], INode[t.Any, t.Any, t.Any]], ): super().__init__(context, config) self.area = area @@ -119,19 +137,19 @@ def __init__( self, context: ContextServer, config: FileStudyTreeConfig, - klass: Callable[ + klass: t.Callable[ [ ContextServer, FileStudyTreeConfig, str, - Callable[ + t.Callable[ [ContextServer, FileStudyTreeConfig], - INode[Any, Any, Any], + INode[t.Any, t.Any, t.Any], ], ], - INode[Any, Any, Any], + INode[t.Any, t.Any, t.Any], ], - matrix_class: Callable[[ContextServer, FileStudyTreeConfig], INode[Any, Any, Any]], + matrix_class: t.Callable[[ContextServer, FileStudyTreeConfig], INode[t.Any, t.Any, t.Any]], ): super().__init__(context, config) self.klass = klass diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/thermal.py b/antarest/study/storage/rawstudy/model/filesystem/config/thermal.py index e9a663e9ac..f2a810025a 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/config/thermal.py +++ b/antarest/study/storage/rawstudy/model/filesystem/config/thermal.py @@ -7,14 +7,16 @@ from antarest.study.storage.rawstudy.model.filesystem.config.identifier import IgnoreCaseIdentifier __all__ = ( - "LocalTSGenerationBehavior", "LawOption", + "LocalTSGenerationBehavior", + "Thermal860Config", + "Thermal870Config", + "Thermal870Properties", "ThermalClusterGroup", - "ThermalProperties", - "Thermal860Properties", "ThermalConfig", - "Thermal860Config", "ThermalConfigType", + "ThermalCostGeneration", + "ThermalProperties", "create_thermal_config", ) @@ -34,7 +36,7 @@ class LocalTSGenerationBehavior(EnumIgnoreCase): FORCE_NO_GENERATION = "force no generation" FORCE_GENERATION = "force generation" - def __repr__(self) -> str: + def __repr__(self) -> str: # pragma: no cover return f"{self.__class__.__name__}.{self.name}" @@ -47,7 +49,7 @@ class LawOption(EnumIgnoreCase): UNIFORM = "uniform" GEOMETRIC = "geometric" - def __repr__(self) -> str: + def __repr__(self) -> str: # pragma: no cover return f"{self.__class__.__name__}.{self.name}" @@ -68,7 +70,7 @@ class ThermalClusterGroup(EnumIgnoreCase): OTHER3 = "Other 3" OTHER4 = "Other 4" - def __repr__(self) -> str: + def __repr__(self) -> str: # pragma: no cover return f"{self.__class__.__name__}.{self.name}" @classmethod @@ -87,6 +89,16 @@ def _missing_(cls, value: object) -> t.Optional["ThermalClusterGroup"]: return t.cast(t.Optional["ThermalClusterGroup"], super()._missing_(value)) +class ThermalCostGeneration(EnumIgnoreCase): + """ + Specifies how to generate thermal cluster cost. + The value `SetManually` is used by default. + """ + + SET_MANUALLY = "SetManually" + USE_COST_TIME_SERIES = "useCostTimeseries" + + class ThermalProperties(ClusterProperties): """ Thermal cluster configuration model. @@ -262,6 +274,31 @@ class Thermal860Properties(ThermalProperties): ) +# noinspection SpellCheckingInspection +class Thermal870Properties(Thermal860Properties): + """ + Thermal cluster configuration model for study in version 8.7 or above. + """ + + cost_generation: ThermalCostGeneration = Field( + default=ThermalCostGeneration.SET_MANUALLY, + alias="costgeneration", + description="Cost generation option", + ) + efficiency: float = Field( + default=100.0, + ge=0, + le=100, + description="Efficiency (%)", + ) + # Even if `variableomcost` is a cost it could be negative. + variable_o_m_cost: float = Field( + default=0.0, + description="Operating and Maintenance Cost (€/MWh)", + alias="variableomcost", + ) + + class ThermalConfig(ThermalProperties, IgnoreCaseIdentifier): """ Thermal properties with section ID. @@ -285,7 +322,7 @@ class ThermalConfig(ThermalProperties, IgnoreCaseIdentifier): class Thermal860Config(Thermal860Properties, IgnoreCaseIdentifier): """ - Thermal properties for study in version 8.6 or above. + Thermal properties for study in version 860 Usage: @@ -305,9 +342,37 @@ class Thermal860Config(Thermal860Properties, IgnoreCaseIdentifier): """ +class Thermal870Config(Thermal870Properties, IgnoreCaseIdentifier): + """ + Thermal properties for study in version 8.7 or above. + + Usage: + + >>> from antarest.study.storage.rawstudy.model.filesystem.config.thermal import Thermal870Config + + >>> cl = Thermal870Config(name="cluster 01!", group="Nuclear", co2=123, nh3=456, efficiency=97) + >>> cl.id + 'cluster 01' + >>> cl.group == ThermalClusterGroup.NUCLEAR + True + >>> cl.co2 + 123.0 + >>> cl.nh3 + 456.0 + >>> cl.op1 + 0.0 + >>> cl.efficiency + 97.0 + >>> cl.variable_o_m_cost + 0.0 + >>> cl.cost_generation == ThermalCostGeneration.SET_MANUALLY + True + """ + + # NOTE: In the following Union, it is important to place the most specific type first, # because the type matching generally occurs sequentially from left to right within the union. -ThermalConfigType = t.Union[Thermal860Config, ThermalConfig] +ThermalConfigType = t.Union[Thermal870Config, Thermal860Config, ThermalConfig] def create_thermal_config(study_version: t.Union[str, int], **kwargs: t.Any) -> ThermalConfigType: @@ -325,7 +390,9 @@ def create_thermal_config(study_version: t.Union[str, int], **kwargs: t.Any) -> ValueError: If the study version is not supported. """ version = int(study_version) - if version >= 860: + if version >= 870: + return Thermal870Config(**kwargs) + elif version == 860: return Thermal860Config(**kwargs) else: return ThermalConfig(**kwargs) diff --git a/antarest/study/storage/rawstudy/model/filesystem/root/input/bindingconstraints/bindingcontraints.py b/antarest/study/storage/rawstudy/model/filesystem/root/input/bindingconstraints/bindingcontraints.py index 69fe669183..8ef625200c 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/root/input/bindingconstraints/bindingcontraints.py +++ b/antarest/study/storage/rawstudy/model/filesystem/root/input/bindingconstraints/bindingcontraints.py @@ -6,9 +6,17 @@ from antarest.study.storage.rawstudy.model.filesystem.root.input.bindingconstraints.bindingconstraints_ini import ( BindingConstraintsIni, ) -from antarest.study.storage.variantstudy.business.matrix_constants.binding_constraint.series import ( - default_bc_hourly, - default_bc_weekly_daily, +from antarest.study.storage.variantstudy.business.matrix_constants.binding_constraint.series_after_v87 import ( + default_bc_hourly as default_bc_hourly_87, +) +from antarest.study.storage.variantstudy.business.matrix_constants.binding_constraint.series_after_v87 import ( + default_bc_weekly_daily as default_bc_weekly_daily_87, +) +from antarest.study.storage.variantstudy.business.matrix_constants.binding_constraint.series_before_v87 import ( + default_bc_hourly as default_bc_hourly_86, +) +from antarest.study.storage.variantstudy.business.matrix_constants.binding_constraint.series_before_v87 import ( + default_bc_weekly_daily as default_bc_weekly_daily_86, ) @@ -19,23 +27,40 @@ class BindingConstraints(FolderNode): """ def build(self) -> TREE: - default_matrices = { - BindingConstraintFrequency.HOURLY: default_bc_hourly, - BindingConstraintFrequency.DAILY: default_bc_weekly_daily, - BindingConstraintFrequency.WEEKLY: default_bc_weekly_daily, - } - children: TREE = { - binding.id: InputSeriesMatrix( - self.context, - self.config.next_file(f"{binding.id}.txt"), - freq=MatrixFrequency(binding.time_step), - nb_columns=3, - default_empty=default_matrices[binding.time_step], - ) - for binding in self.config.bindings - } - - # noinspection SpellCheckingInspection + cfg = self.config + if cfg.version < 870: + default_matrices = { + BindingConstraintFrequency.HOURLY: default_bc_hourly_86, + BindingConstraintFrequency.DAILY: default_bc_weekly_daily_86, + BindingConstraintFrequency.WEEKLY: default_bc_weekly_daily_86, + } + children: TREE = { + binding.id: InputSeriesMatrix( + self.context, + self.config.next_file(f"{binding.id}.txt"), + freq=MatrixFrequency(binding.time_step), + nb_columns=3, + default_empty=default_matrices[binding.time_step], + ) + for binding in self.config.bindings + } + else: + default_matrices = { + BindingConstraintFrequency.HOURLY: default_bc_hourly_87, + BindingConstraintFrequency.DAILY: default_bc_weekly_daily_87, + BindingConstraintFrequency.WEEKLY: default_bc_weekly_daily_87, + } + children = {} + for binding in self.config.bindings: + for term in ["lt", "gt", "eq"]: + matrix_id = f"{binding.id}_{term}" + children[matrix_id] = InputSeriesMatrix( + self.context, + self.config.next_file(f"{matrix_id}.txt"), + freq=MatrixFrequency(binding.time_step), + nb_columns=1 if term in ["lt", "gt"] else None, + default_empty=default_matrices[binding.time_step], + ) children["bindingconstraints"] = BindingConstraintsIni( self.context, self.config.next_file("bindingconstraints.ini") ) diff --git a/antarest/study/storage/rawstudy/model/filesystem/root/output/simulation/ts_numbers/ts_numbers.py b/antarest/study/storage/rawstudy/model/filesystem/root/output/simulation/ts_numbers/ts_numbers.py index 5c977c1dd4..2a74e256f9 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/root/output/simulation/ts_numbers/ts_numbers.py +++ b/antarest/study/storage/rawstudy/model/filesystem/root/output/simulation/ts_numbers/ts_numbers.py @@ -1,6 +1,7 @@ from antarest.study.storage.rawstudy.model.filesystem.common.area_matrix_list import ( AreaMatrixList, AreaMultipleMatrixList, + BindingConstraintMatrixList, ThermalMatrixList, ) from antarest.study.storage.rawstudy.model.filesystem.folder_node import FolderNode @@ -10,17 +11,24 @@ ) +# noinspection SpellCheckingInspection class OutputSimulationTsNumbers(FolderNode): """ Represents a folder structure, which contains several time series folders (one for each generator type: "hydro", "load", "solar" and "wind") and a specific folder structure for the thermal clusters (one for each area). + Since v8.7, it also contains a folder for the binding constraints. + Example of tree structure: .. code-block:: text output/20230323-1540adq/ts-numbers/ + ├── bindingconstraints + │ ├── group_1.txt + │ ├── group_2.txt + │ └── [...] ├── hydro │ ├── at.txt │ ├── ch.txt @@ -77,4 +85,10 @@ def build(self) -> TREE: TsNumbersVector, ), } + if self.config.version >= 870: + children["bindingconstraints"] = BindingConstraintMatrixList( + self.context, + self.config.next_file("bindingconstraints"), + matrix_class=TsNumbersVector, + ) return children diff --git a/antarest/study/storage/study_upgrader/__init__.py b/antarest/study/storage/study_upgrader/__init__.py index 3f53def629..1ff75f64be 100644 --- a/antarest/study/storage/study_upgrader/__init__.py +++ b/antarest/study/storage/study_upgrader/__init__.py @@ -7,6 +7,7 @@ from http import HTTPStatus from http.client import HTTPException from pathlib import Path +from typing import Callable, List, NamedTuple from antarest.core.exceptions import StudyValidationError @@ -19,6 +20,7 @@ from .upgrader_840 import upgrade_840 from .upgrader_850 import upgrade_850 from .upgrader_860 import upgrade_860 +from .upgrader_870 import upgrade_870 logger = logging.getLogger(__name__) @@ -44,6 +46,7 @@ class UpgradeMethod(t.NamedTuple): UpgradeMethod("830", "840", upgrade_840, [_GENERAL_DATA_PATH]), UpgradeMethod("840", "850", upgrade_850, [_GENERAL_DATA_PATH]), UpgradeMethod("850", "860", upgrade_860, [Path("input"), _GENERAL_DATA_PATH]), + UpgradeMethod("860", "870", upgrade_870, [Path("input/thermal"), Path("input/bindingconstraints")]), ] @@ -273,6 +276,5 @@ def should_study_be_denormalized(src_version: str, target_version: str) -> bool: if curr_version == old and curr_version != target_version: list_of_upgrades.append(new) curr_version = new - # For now, the only upgrade that impacts study matrices is the upgrade from v8.1 to v8.2 - # In a near future, the upgrade from v8.6 to v8.7 will also require denormalization - return "820" in list_of_upgrades + # These upgrades alter matrices so the study needs to be denormalized + return "820" in list_of_upgrades or "870" in list_of_upgrades diff --git a/antarest/study/storage/study_upgrader/upgrader_860.py b/antarest/study/storage/study_upgrader/upgrader_860.py index 23ea05f178..4d6f873f0d 100644 --- a/antarest/study/storage/study_upgrader/upgrader_860.py +++ b/antarest/study/storage/study_upgrader/upgrader_860.py @@ -10,6 +10,16 @@ def upgrade_860(study_path: Path) -> None: + """ + Upgrade the study configuration to version 860. + + NOTE: + The file `study.antares` is not upgraded here. + + Args: + study_path: path to the study directory. + """ + reader = IniReader(DUPLICATE_KEYS) data = reader.read(study_path / GENERAL_DATA_PATH) data["adequacy patch"]["enable-first-step "] = True diff --git a/antarest/study/storage/study_upgrader/upgrader_870.py b/antarest/study/storage/study_upgrader/upgrader_870.py new file mode 100644 index 0000000000..a2afc4bd1f --- /dev/null +++ b/antarest/study/storage/study_upgrader/upgrader_870.py @@ -0,0 +1,59 @@ +import typing as t +from pathlib import Path + +import numpy as np +import numpy.typing as npt +import pandas as pd + +from antarest.study.storage.rawstudy.ini_reader import IniReader +from antarest.study.storage.rawstudy.ini_writer import IniWriter + + +# noinspection SpellCheckingInspection +def upgrade_870(study_path: Path) -> None: + """ + Upgrade the study configuration to version 870. + + NOTE: + The file `study.antares` is not upgraded here. + + Args: + study_path: path to the study directory. + """ + + # Split existing binding constraints in 3 different files + binding_constraints_path = study_path / "input" / "bindingconstraints" + binding_constraints_files = binding_constraints_path.glob("*.txt") + for file in binding_constraints_files: + name = file.stem + if file.stat().st_size == 0: + lt, gt, eq = pd.Series(), pd.Series(), pd.Series() + else: + df = pd.read_csv(file, sep="\t", header=None) + lt, gt, eq = df.iloc[:, 0], df.iloc[:, 1], df.iloc[:, 2] + for term, suffix in zip([lt, gt, eq], ["lt", "gt", "eq"]): + # noinspection PyTypeChecker + np.savetxt( + binding_constraints_path / f"{name}_{suffix}.txt", + t.cast(npt.NDArray[np.float64], term.values), + delimiter="\t", + fmt="%.6f", + ) + file.unlink() + + # Add property group for every section in .ini file + ini_file_path = binding_constraints_path / "bindingconstraints.ini" + data = IniReader().read(ini_file_path) + for section in data: + data[section]["group"] = "default" + IniWriter().write(data, ini_file_path) + + # Add properties for thermal clusters in .ini file + ini_files = study_path.glob("input/thermal/clusters/*/list.ini") + for ini_file_path in ini_files: + data = IniReader().read(ini_file_path) + for section in data: + data[section]["costgeneration"] = "SetManually" + data[section]["efficiency"] = 100 + data[section]["variableomcost"] = 0 + IniWriter().write(data, ini_file_path) diff --git a/antarest/study/storage/variantstudy/business/command_reverter.py b/antarest/study/storage/variantstudy/business/command_reverter.py index 1ac83c2704..fa4de66c06 100644 --- a/antarest/study/storage/variantstudy/business/command_reverter.py +++ b/antarest/study/storage/variantstudy/business/command_reverter.py @@ -5,7 +5,6 @@ from antarest.study.storage.rawstudy.model.filesystem.config.model import transform_name_to_id from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy from antarest.study.storage.rawstudy.model.filesystem.folder_node import ChildNotFoundError -from antarest.study.storage.variantstudy.business.utils import strip_matrix_protocol from antarest.study.storage.variantstudy.model.command.common import CommandName from antarest.study.storage.variantstudy.model.command.create_area import CreateArea from antarest.study.storage.variantstudy.model.command.create_binding_constraint import CreateBindingConstraint @@ -100,20 +99,25 @@ def _revert_update_binding_constraint( if isinstance(command, UpdateBindingConstraint) and command.id == base_command.id: return [command] elif isinstance(command, CreateBindingConstraint) and transform_name_to_id(command.name) == base_command.id: - return [ - UpdateBindingConstraint( - id=base_command.id, - enabled=command.enabled, - time_step=command.time_step, - operator=command.operator, - coeffs=command.coeffs, - values=strip_matrix_protocol(command.values), - filter_year_by_year=command.filter_year_by_year, - filter_synthesis=command.filter_synthesis, - comments=command.comments, - command_context=command.command_context, - ) - ] + args = { + "id": base_command.id, + "enabled": command.enabled, + "time_step": command.time_step, + "operator": command.operator, + "coeffs": command.coeffs, + "filter_year_by_year": command.filter_year_by_year, + "filter_synthesis": command.filter_synthesis, + "comments": command.comments, + "command_context": command.command_context, + } + + matrix_service = command.command_context.matrix_service + for matrix_name in ["values", "less_term_matrix", "equal_term_matrix", "greater_term_matrix"]: + matrix = command.__getattribute__(matrix_name) + if matrix is not None: + args[matrix_name] = matrix_service.get_matrix_id(matrix) + + return [UpdateBindingConstraint(**args)] return base_command.get_command_extractor().extract_binding_constraint(base, base_command.id) diff --git a/antarest/study/storage/variantstudy/business/matrix_constants/binding_constraint/__init__.py b/antarest/study/storage/variantstudy/business/matrix_constants/binding_constraint/__init__.py index 0a1b9046e5..679232c20b 100644 --- a/antarest/study/storage/variantstudy/business/matrix_constants/binding_constraint/__init__.py +++ b/antarest/study/storage/variantstudy/business/matrix_constants/binding_constraint/__init__.py @@ -1 +1 @@ -from . import series +from . import series_after_v87, series_before_v87 # noqa: F401 diff --git a/antarest/study/storage/variantstudy/business/matrix_constants/binding_constraint/series_after_v87.py b/antarest/study/storage/variantstudy/business/matrix_constants/binding_constraint/series_after_v87.py new file mode 100644 index 0000000000..30c23a7dfe --- /dev/null +++ b/antarest/study/storage/variantstudy/business/matrix_constants/binding_constraint/series_after_v87.py @@ -0,0 +1,7 @@ +import numpy as np + +default_bc_hourly = np.zeros((8784, 1), dtype=np.float64) +default_bc_hourly.flags.writeable = False + +default_bc_weekly_daily = np.zeros((366, 1), dtype=np.float64) +default_bc_weekly_daily.flags.writeable = False diff --git a/antarest/study/storage/variantstudy/business/matrix_constants/binding_constraint/series.py b/antarest/study/storage/variantstudy/business/matrix_constants/binding_constraint/series_before_v87.py similarity index 100% rename from antarest/study/storage/variantstudy/business/matrix_constants/binding_constraint/series.py rename to antarest/study/storage/variantstudy/business/matrix_constants/binding_constraint/series_before_v87.py diff --git a/antarest/study/storage/variantstudy/business/matrix_constants_generator.py b/antarest/study/storage/variantstudy/business/matrix_constants_generator.py index 6a4dc233d4..4c75f6a6eb 100644 --- a/antarest/study/storage/variantstudy/business/matrix_constants_generator.py +++ b/antarest/study/storage/variantstudy/business/matrix_constants_generator.py @@ -35,11 +35,11 @@ ONES_SCENARIO_MATRIX = "ones_scenario_matrix" # Binding constraint aliases -BINDING_CONSTRAINT_HOURLY = "empty_2nd_member_hourly" -"""2D-matrix of shape (8784, 3), filled-in with zeros for hourly binding constraints.""" +BINDING_CONSTRAINT_HOURLY_v86 = "empty_2nd_member_hourly_v86" +BINDING_CONSTRAINT_DAILY_WEEKLY_v86 = "empty_2nd_member_daily_or_weekly_v86" -BINDING_CONSTRAINT_WEEKLY_DAILY = "empty_2nd_member_weekly_daily" -"""2D-matrix of shape (366, 3), filled-in with zeros for weekly/daily binding constraints.""" +BINDING_CONSTRAINT_HOURLY_v87 = "empty_2nd_member_hourly_v87" +BINDING_CONSTRAINT_DAILY_WEEKLY_v87 = "empty_2nd_member_daily_or_weekly_v87" # Short-term storage aliases ST_STORAGE_PMAX_INJECTION = ONES_SCENARIO_MATRIX @@ -95,15 +95,23 @@ def init_constant_matrices( self.hashes[RESERVES_TS] = self.matrix_service.create(FIXED_4_COLUMNS) self.hashes[MISCGEN_TS] = self.matrix_service.create(FIXED_8_COLUMNS) - # Binding constraint matrices - series = matrix_constants.binding_constraint.series - self.hashes[BINDING_CONSTRAINT_HOURLY] = self.matrix_service.create(series.default_bc_hourly) - self.hashes[BINDING_CONSTRAINT_WEEKLY_DAILY] = self.matrix_service.create(series.default_bc_weekly_daily) + # Binding constraint matrices + series_before_87 = matrix_constants.binding_constraint.series_before_v87 + self.hashes[BINDING_CONSTRAINT_HOURLY_v86] = self.matrix_service.create(series_before_87.default_bc_hourly) + self.hashes[BINDING_CONSTRAINT_DAILY_WEEKLY_v86] = self.matrix_service.create( + series_before_87.default_bc_weekly_daily + ) - # Some short-term storage matrices use np.ones((8760, 1)) - self.hashes[ONES_SCENARIO_MATRIX] = self.matrix_service.create( - matrix_constants.st_storage.series.pmax_injection - ) + series_after_87 = matrix_constants.binding_constraint.series_after_v87 + self.hashes[BINDING_CONSTRAINT_HOURLY_v87] = self.matrix_service.create(series_after_87.default_bc_hourly) + self.hashes[BINDING_CONSTRAINT_DAILY_WEEKLY_v87] = self.matrix_service.create( + series_after_87.default_bc_weekly_daily + ) + + # Some short-term storage matrices use np.ones((8760, 1)) + self.hashes[ONES_SCENARIO_MATRIX] = self.matrix_service.create( + matrix_constants.st_storage.series.pmax_injection + ) def get_hydro_max_power(self, version: int) -> str: if version > 650: @@ -157,17 +165,21 @@ def get_default_reserves(self) -> str: def get_default_miscgen(self) -> str: return MATRIX_PROTOCOL_PREFIX + self.hashes[MISCGEN_TS] - def get_binding_constraint_hourly(self) -> str: + def get_binding_constraint_hourly_86(self) -> str: """2D-matrix of shape (8784, 3), filled-in with zeros.""" - return MATRIX_PROTOCOL_PREFIX + self.hashes[BINDING_CONSTRAINT_HOURLY] + return MATRIX_PROTOCOL_PREFIX + self.hashes[BINDING_CONSTRAINT_HOURLY_v86] - def get_binding_constraint_daily(self) -> str: + def get_binding_constraint_daily_weekly_86(self) -> str: """2D-matrix of shape (366, 3), filled-in with zeros.""" - return MATRIX_PROTOCOL_PREFIX + self.hashes[BINDING_CONSTRAINT_WEEKLY_DAILY] + return MATRIX_PROTOCOL_PREFIX + self.hashes[BINDING_CONSTRAINT_DAILY_WEEKLY_v86] + + def get_binding_constraint_hourly_87(self) -> str: + """2D-matrix of shape (8784, 1), filled-in with zeros.""" + return MATRIX_PROTOCOL_PREFIX + self.hashes[BINDING_CONSTRAINT_HOURLY_v87] - def get_binding_constraint_weekly(self) -> str: - """2D-matrix of shape (366, 3), filled-in with zeros, same as daily.""" - return MATRIX_PROTOCOL_PREFIX + self.hashes[BINDING_CONSTRAINT_WEEKLY_DAILY] + def get_binding_constraint_daily_weekly_87(self) -> str: + """2D-matrix of shape (8784, 1), filled-in with zeros.""" + return MATRIX_PROTOCOL_PREFIX + self.hashes[BINDING_CONSTRAINT_DAILY_WEEKLY_v87] def get_st_storage_pmax_injection(self) -> str: """2D-matrix of shape (8760, 1), filled-in with ones.""" diff --git a/antarest/study/storage/variantstudy/business/utils.py b/antarest/study/storage/variantstudy/business/utils.py index 933c72bed7..75396ccbc6 100644 --- a/antarest/study/storage/variantstudy/business/utils.py +++ b/antarest/study/storage/variantstudy/business/utils.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Optional, Sequence, Union +import typing as t from antarest.core.model import JSON from antarest.matrixstore.model import MatrixData @@ -9,7 +9,7 @@ from antarest.study.storage.variantstudy.model.model import CommandDTO -def validate_matrix(matrix: Union[List[List[MatrixData]], str], values: Dict[str, Any]) -> str: +def validate_matrix(matrix: t.Union[t.List[t.List[MatrixData]], str], values: t.Dict[str, t.Any]) -> str: """ Validates the matrix, stores the matrix array in the matrices repository, and returns a reference to the stored array. @@ -62,7 +62,7 @@ def remove_none_args(command_dto: CommandDTO) -> CommandDTO: return command_dto -def strip_matrix_protocol(matrix_uri: Union[List[List[float]], str, None]) -> str: +def strip_matrix_protocol(matrix_uri: t.Union[t.List[t.List[float]], str, None]) -> str: assert isinstance(matrix_uri, str) if matrix_uri.startswith(MATRIX_PROTOCOL_PREFIX): return matrix_uri[len(MATRIX_PROTOCOL_PREFIX) :] @@ -89,13 +89,13 @@ def decode(alias: str, study: FileStudy) -> str: def transform_command_to_dto( - commands: Sequence[ICommand], - ref_commands: Optional[Sequence[CommandDTO]] = None, + commands: t.Sequence[ICommand], + ref_commands: t.Optional[t.Sequence[CommandDTO]] = None, force_aggregate: bool = False, -) -> List[CommandDTO]: +) -> t.List[CommandDTO]: if len(commands) <= 1: return [command.to_dto() for command in commands] - commands_dto: List[CommandDTO] = [] + commands_dto: t.List[CommandDTO] = [] ref_commands_dto = ref_commands if ref_commands is not None else [command.to_dto() for command in commands] prev_command = commands[0] cur_dto_index = 0 diff --git a/antarest/study/storage/variantstudy/business/utils_binding_constraint.py b/antarest/study/storage/variantstudy/business/utils_binding_constraint.py index f1ef937cdf..0779f7e048 100644 --- a/antarest/study/storage/variantstudy/business/utils_binding_constraint.py +++ b/antarest/study/storage/variantstudy/business/utils_binding_constraint.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Literal, Mapping, Optional, Sequence, Union +import typing as t from antarest.core.model import JSON from antarest.matrixstore.model import MatrixData @@ -17,15 +17,20 @@ def apply_binding_constraint( new_key: str, bd_id: str, name: str, - comments: Optional[str], + comments: t.Optional[str], enabled: bool, freq: BindingConstraintFrequency, operator: BindingConstraintOperator, - coeffs: Dict[str, List[float]], - values: Optional[Union[List[List[MatrixData]], str]], - filter_year_by_year: Optional[str] = None, - filter_synthesis: Optional[str] = None, + coeffs: t.Dict[str, t.List[float]], + values: t.Union[t.List[t.List[MatrixData]], str, None], + less_term_matrix: t.Union[t.List[t.List[MatrixData]], str, None], + greater_term_matrix: t.Union[t.List[t.List[MatrixData]], str, None], + equal_term_matrix: t.Union[t.List[t.List[MatrixData]], str, None], + filter_year_by_year: t.Optional[str] = None, + filter_synthesis: t.Optional[str] = None, + group: t.Optional[str] = None, ) -> CommandOutput: + version = study_data.config.version binding_constraints[new_key] = { "name": name, "id": bd_id, @@ -33,7 +38,9 @@ def apply_binding_constraint( "type": freq.value, "operator": operator.value, } - if study_data.config.version >= 830: + if group: + binding_constraints[new_key]["group"] = group + if version >= 830: if filter_year_by_year: binding_constraints[new_key]["filter-year-by-year"] = filter_year_by_year if filter_synthesis: @@ -76,14 +83,25 @@ def apply_binding_constraint( if values: if not isinstance(values, str): # pragma: no cover raise TypeError(repr(values)) - study_data.tree.save(values, ["input", "bindingconstraints", bd_id]) + if version < 870: + study_data.tree.save(values, ["input", "bindingconstraints", bd_id]) + for matrix_term, matrix_name, matrix_alias in zip( + [less_term_matrix, greater_term_matrix, equal_term_matrix], + ["less_term_matrix", "greater_term_matrix", "equal_term_matrix"], + ["lt", "gt", "eq"], + ): + if matrix_term: + if not isinstance(matrix_term, str): # pragma: no cover + raise TypeError(repr(matrix_term)) + if version >= 870: + study_data.tree.save(matrix_term, ["input", "bindingconstraints", f"{bd_id}_{matrix_alias}"]) return CommandOutput(status=True) def parse_bindings_coeffs_and_save_into_config( bd_id: str, study_data_config: FileStudyTreeConfig, - coeffs: Mapping[str, Union[Literal["hourly", "daily", "weekly"], Sequence[float]]], + coeffs: t.Mapping[str, t.Union[t.Literal["hourly", "daily", "weekly"], t.Sequence[float]]], ) -> None: if bd_id not in [bind.id for bind in study_data_config.bindings]: areas_set = set() diff --git a/antarest/study/storage/variantstudy/command_factory.py b/antarest/study/storage/variantstudy/command_factory.py index 5fe5c7d9cb..5cf298b15e 100644 --- a/antarest/study/storage/variantstudy/command_factory.py +++ b/antarest/study/storage/variantstudy/command_factory.py @@ -74,14 +74,11 @@ def __init__( patch_service=patch_service, ) - def _to_single_command(self, action: str, args: JSON) -> ICommand: + def _to_single_command(self, action: str, args: JSON, version: int) -> ICommand: """Convert a single CommandDTO to ICommand.""" if action in COMMAND_MAPPING: command_class = COMMAND_MAPPING[action] - return command_class( - **args, - command_context=self.command_context, - ) # type: ignore + return command_class(**args, command_context=self.command_context, version=version) # type: ignore raise NotImplementedError(action) def to_command(self, command_dto: CommandDTO) -> List[ICommand]: @@ -99,9 +96,9 @@ def to_command(self, command_dto: CommandDTO) -> List[ICommand]: """ args = command_dto.args if isinstance(args, dict): - return [self._to_single_command(command_dto.action, args)] + return [self._to_single_command(command_dto.action, args, command_dto.version)] elif isinstance(args, list): - return [self._to_single_command(command_dto.action, argument) for argument in args] + return [self._to_single_command(command_dto.action, argument, command_dto.version) for argument in args] raise NotImplementedError() def to_commands(self, cmd_dto_list: List[CommandDTO]) -> List[ICommand]: 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 79783e1bc9..495b48e557 100644 --- a/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py +++ b/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py @@ -1,15 +1,15 @@ +import typing as t from abc import ABCMeta -from typing import Any, Dict, List, Optional, Tuple, Union, cast import numpy as np -from pydantic import BaseModel, Field, validator +from pydantic import BaseModel, Extra, Field, root_validator from antarest.matrixstore.model import MatrixData from antarest.study.storage.rawstudy.model.filesystem.config.binding_constraint import BindingConstraintFrequency from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfig, transform_name_to_id from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy from antarest.study.storage.variantstudy.business.matrix_constants_generator import GeneratorMatrixConstants -from antarest.study.storage.variantstudy.business.utils import strip_matrix_protocol, validate_matrix +from antarest.study.storage.variantstudy.business.utils import validate_matrix from antarest.study.storage.variantstudy.business.utils_binding_constraint import ( apply_binding_constraint, parse_bindings_coeffs_and_save_into_config, @@ -27,25 +27,28 @@ "CreateBindingConstraint", "check_matrix_values", "BindingConstraintProperties", + "BindingConstraintProperties870", + "BindingConstraintMatrices", ) -MatrixType = List[List[MatrixData]] +MatrixType = t.List[t.List[MatrixData]] -def check_matrix_values(time_step: BindingConstraintFrequency, values: MatrixType) -> None: +def check_matrix_values(time_step: BindingConstraintFrequency, values: MatrixType, version: int) -> None: """ Check the binding constraint's matrix values for the specified time step. Args: time_step: The frequency of the binding constraint: "hourly", "daily" or "weekly". values: The binding constraint's 2nd member matrix. + version: Study version. Raises: ValueError: If the matrix shape does not match the expected shape for the given time step. If the matrix values contain NaN (Not-a-Number). """ - # Matrice shapes for binding constraints are different from usual shapes, + # Matrix shapes for binding constraints are different from usual shapes, # because we need to take leap years into account, which contains 366 days and 8784 hours. # Also, we use the same matrices for "weekly" and "daily" frequencies, # because the solver calculates the weekly matrix from the daily matrix. @@ -57,30 +60,77 @@ def check_matrix_values(time_step: BindingConstraintFrequency, values: MatrixTyp } # Check the matrix values and create the corresponding matrix link array = np.array(values, dtype=np.float64) - if array.shape != shapes[time_step]: - raise ValueError(f"Invalid matrix shape {array.shape}, expected {shapes[time_step]}") + expected_shape = shapes[time_step] + actual_shape = array.shape + if version < 870: + if actual_shape != expected_shape: + raise ValueError(f"Invalid matrix shape {actual_shape}, expected {expected_shape}") + elif actual_shape[0] != expected_shape[0]: + raise ValueError(f"Invalid matrix length {actual_shape[0]}, expected {expected_shape[0]}") if np.isnan(array).any(): raise ValueError("Matrix values cannot contain NaN") -class BindingConstraintProperties(BaseModel): - # todo: add the `name` attribute because it should also be updated - # It would lead to an API change as update_binding_constraint currently does not have it +class BindingConstraintProperties(BaseModel, extra=Extra.forbid): enabled: bool = True time_step: BindingConstraintFrequency operator: BindingConstraintOperator - coeffs: Dict[str, List[float]] - values: Optional[Union[MatrixType, str]] = Field(None, description="2nd member matrix") - filter_year_by_year: Optional[str] = None - filter_synthesis: Optional[str] = None - comments: Optional[str] = None + filter_year_by_year: t.Optional[str] = None + filter_synthesis: t.Optional[str] = None + comments: t.Optional[str] = None -class AbstractBindingConstraintCommand(BindingConstraintProperties, ICommand, metaclass=ABCMeta): +class BindingConstraintProperties870(BindingConstraintProperties): + group: t.Optional[str] = None + + +class BindingConstraintMatrices(BaseModel, extra=Extra.forbid): + """ + Class used to store the matrices of a binding constraint. + """ + + values: t.Optional[t.Union[MatrixType, str]] = Field( + None, + description="2nd member matrix for studies before v8.7", + ) + less_term_matrix: t.Optional[t.Union[MatrixType, str]] = Field( + None, + description="less term matrix for v8.7+ studies", + ) + greater_term_matrix: t.Optional[t.Union[MatrixType, str]] = Field( + None, + description="greater term matrix for v8.7+ studies", + ) + equal_term_matrix: t.Optional[t.Union[MatrixType, str]] = Field( + None, + description="equal term matrix for v8.7+ studies", + ) + + @root_validator(pre=True) + def check_matrices( + cls, values: t.Dict[str, t.Optional[t.Union[MatrixType, str]]] + ) -> t.Dict[str, t.Optional[t.Union[MatrixType, str]]]: + values_matrix = values.get("values") or None + less_term_matrix = values.get("less_term_matrix") or None + greater_term_matrix = values.get("greater_term_matrix") or None + equal_term_matrix = values.get("equal_term_matrix") or None + if values_matrix and (less_term_matrix or greater_term_matrix or equal_term_matrix): + raise ValueError( + "You cannot fill 'values' (matrix before v8.7) and a matrix term:" + " 'less_term_matrix', 'greater_term_matrix' or 'equal_term_matrix' (matrices since v8.7)" + ) + return values + + +class AbstractBindingConstraintCommand( + BindingConstraintProperties870, BindingConstraintMatrices, ICommand, metaclass=ABCMeta +): """ Abstract class for binding constraint commands. """ + coeffs: t.Dict[str, t.List[float]] + def to_dto(self) -> CommandDTO: args = { "enabled": self.enabled, @@ -91,61 +141,93 @@ def to_dto(self) -> CommandDTO: "filter_year_by_year": self.filter_year_by_year, "filter_synthesis": self.filter_synthesis, } - if self.values is not None: - args["values"] = strip_matrix_protocol(self.values) - return CommandDTO( - action=self.command_name.value, - args=args, - ) - def get_inner_matrices(self) -> List[str]: - if self.values is not None: - if not isinstance(self.values, str): # pragma: no cover - raise TypeError(repr(self.values)) - return [strip_matrix_protocol(self.values)] - return [] + # The `group` attribute is only available for studies since v8.7 + if self.group: + args["group"] = self.group + matrix_service = self.command_context.matrix_service + for matrix_name in ["values", "less_term_matrix", "greater_term_matrix", "equal_term_matrix"]: + matrix_attr = getattr(self, matrix_name, None) + if matrix_attr is not None: + args[matrix_name] = matrix_service.get_matrix_id(matrix_attr) -class CreateBindingConstraint(AbstractBindingConstraintCommand): - """ - Command used to create a binding constraint. - """ - - command_name = CommandName.CREATE_BINDING_CONSTRAINT - version: int = 1 + return CommandDTO(action=self.command_name.value, args=args, version=self.version) - # Properties of the `CREATE_BINDING_CONSTRAINT` command: - name: str + def get_inner_matrices(self) -> t.List[str]: + matrix_service = self.command_context.matrix_service + return [ + matrix_service.get_matrix_id(matrix) + for matrix in [ + self.values, + self.less_term_matrix, + self.greater_term_matrix, + self.equal_term_matrix, + ] + if matrix is not None + ] - @validator("values", always=True) - def validate_series( - cls, - v: Optional[Union[MatrixType, str]], - values: Dict[str, Any], - ) -> Optional[Union[MatrixType, str]]: + def get_corresponding_matrices( + self, v: t.Optional[t.Union[MatrixType, str]], version: int, create: bool + ) -> t.Optional[str]: constants: GeneratorMatrixConstants - constants = values["command_context"].generator_matrix_constants - time_step = values["time_step"] + constants = self.command_context.generator_matrix_constants + time_step = self.time_step + if v is None: - # Use an already-registered default matrix + if not create: + # The matrix is not updated + return None + # Use already-registered default matrix methods = { - BindingConstraintFrequency.HOURLY: constants.get_binding_constraint_hourly, - BindingConstraintFrequency.DAILY: constants.get_binding_constraint_daily, - BindingConstraintFrequency.WEEKLY: constants.get_binding_constraint_weekly, + "before_v87": { + BindingConstraintFrequency.HOURLY: constants.get_binding_constraint_hourly_86, + BindingConstraintFrequency.DAILY: constants.get_binding_constraint_daily_weekly_86, + BindingConstraintFrequency.WEEKLY: constants.get_binding_constraint_daily_weekly_86, + }, + "after_v87": { + BindingConstraintFrequency.HOURLY: constants.get_binding_constraint_hourly_87, + BindingConstraintFrequency.DAILY: constants.get_binding_constraint_daily_weekly_87, + BindingConstraintFrequency.WEEKLY: constants.get_binding_constraint_daily_weekly_87, + }, } - method = methods[time_step] - return method() + return methods["before_v87"][time_step]() if version < 870 else methods["after_v87"][time_step]() if isinstance(v, str): # Check the matrix link - return validate_matrix(v, values) + return validate_matrix(v, {"command_context": self.command_context}) if isinstance(v, list): - check_matrix_values(time_step, v) - return validate_matrix(v, values) + check_matrix_values(time_step, v, version) + return validate_matrix(v, {"command_context": self.command_context}) # Invalid datatype # pragma: no cover raise TypeError(repr(v)) - def _apply_config(self, study_data_config: FileStudyTreeConfig) -> Tuple[CommandOutput, Dict[str, Any]]: + def validates_and_fills_matrices( + self, *, specific_matrices: t.Optional[t.List[str]], version: int, create: bool + ) -> None: + if version < 870: + self.values = self.get_corresponding_matrices(self.values, version, create) + elif specific_matrices: + for matrix in specific_matrices: + setattr(self, matrix, self.get_corresponding_matrices(getattr(self, matrix), version, create)) + else: + self.less_term_matrix = self.get_corresponding_matrices(self.less_term_matrix, version, create) + self.greater_term_matrix = self.get_corresponding_matrices(self.greater_term_matrix, version, create) + self.equal_term_matrix = self.get_corresponding_matrices(self.equal_term_matrix, version, create) + + +class CreateBindingConstraint(AbstractBindingConstraintCommand): + """ + Command used to create a binding constraint. + """ + + command_name = CommandName.CREATE_BINDING_CONSTRAINT + version: int = 1 + + # Properties of the `CREATE_BINDING_CONSTRAINT` command: + name: str + + def _apply_config(self, study_data_config: FileStudyTreeConfig) -> t.Tuple[CommandOutput, t.Dict[str, t.Any]]: bd_id = transform_name_to_id(self.name) parse_bindings_coeffs_and_save_into_config(bd_id, study_data_config, self.coeffs) return CommandOutput(status=True), {} @@ -154,6 +236,8 @@ def _apply(self, study_data: FileStudy) -> CommandOutput: binding_constraints = study_data.tree.get(["input", "bindingconstraints", "bindingconstraints"]) new_key = len(binding_constraints) bd_id = transform_name_to_id(self.name) + self.validates_and_fills_matrices(specific_matrices=None, version=study_data.config.version, create=True) + return apply_binding_constraint( study_data, binding_constraints, @@ -166,8 +250,12 @@ def _apply(self, study_data: FileStudy) -> CommandOutput: self.operator, self.coeffs, self.values, + self.less_term_matrix, + self.greater_term_matrix, + self.equal_term_matrix, self.filter_year_by_year, self.filter_synthesis, + self.group, ) def to_dto(self) -> CommandDTO: @@ -192,24 +280,38 @@ def match(self, other: ICommand, equal: bool = False) -> bool: and self.coeffs == other.coeffs and self.values == other.values and self.comments == other.comments + and self.less_term_matrix == other.less_term_matrix + and self.greater_term_matrix == other.greater_term_matrix + and self.equal_term_matrix == other.equal_term_matrix + and self.group == other.group + and self.filter_synthesis == other.filter_synthesis + and self.filter_year_by_year == other.filter_year_by_year ) - def _create_diff(self, other: "ICommand") -> List["ICommand"]: - other = cast(CreateBindingConstraint, other) + def _create_diff(self, other: "ICommand") -> t.List["ICommand"]: from antarest.study.storage.variantstudy.model.command.update_binding_constraint import UpdateBindingConstraint + other = t.cast(CreateBindingConstraint, other) bd_id = transform_name_to_id(self.name) - return [ - UpdateBindingConstraint( - id=bd_id, - enabled=other.enabled, - time_step=other.time_step, - operator=other.operator, - coeffs=other.coeffs, - values=strip_matrix_protocol(other.values) if self.values != other.values else None, - filter_year_by_year=other.filter_year_by_year, - filter_synthesis=other.filter_synthesis, - comments=other.comments, - command_context=other.command_context, - ) - ] + + args = { + "id": bd_id, + "enabled": other.enabled, + "time_step": other.time_step, + "operator": other.operator, + "coeffs": other.coeffs, + "filter_year_by_year": other.filter_year_by_year, + "filter_synthesis": other.filter_synthesis, + "comments": other.comments, + "command_context": other.command_context, + "group": other.group, + } + + matrix_service = self.command_context.matrix_service + for matrix_name in ["values", "less_term_matrix", "equal_term_matrix", "greater_term_matrix"]: + self_matrix = getattr(self, matrix_name) + other_matrix = getattr(other, matrix_name) + if self_matrix != other_matrix: + args[matrix_name] = matrix_service.get_matrix_id(other_matrix) + + return [UpdateBindingConstraint(**args)] diff --git a/antarest/study/storage/variantstudy/model/command/remove_area.py b/antarest/study/storage/variantstudy/model/command/remove_area.py index a7914c627e..a93c3bcd84 100644 --- a/antarest/study/storage/variantstudy/model/command/remove_area.py +++ b/antarest/study/storage/variantstudy/model/command/remove_area.py @@ -38,11 +38,11 @@ def _remove_area_from_links_in_config(self, study_data_config: FileStudyTreeConf del study_data_config.areas[area_name].links[link] def _remove_area_from_sets_in_config(self, study_data_config: FileStudyTreeConfig) -> None: - for id, set in study_data_config.sets.items(): - if set.areas and self.id in set.areas: + for id_, set_ in study_data_config.sets.items(): + if set_.areas and self.id in set_.areas: with contextlib.suppress(ValueError): - set.areas.remove(self.id) - study_data_config.sets[id] = set + set_.areas.remove(self.id) + study_data_config.sets[id_] = set_ def _apply_config(self, study_data_config: FileStudyTreeConfig) -> Tuple[CommandOutput, Dict[str, Any]]: del study_data_config.areas[self.id] @@ -78,15 +78,50 @@ def _remove_area_from_links(self, study_data: FileStudy) -> None: ) def _remove_area_from_binding_constraints(self, study_data: FileStudy) -> None: - binding_constraints = study_data.tree.get(["input", "bindingconstraints", "bindingconstraints"]) + """ + Remove the binding constraints that are related to the area. - id_to_remove = {bc_id for bc_id, bc in binding_constraints.items() for key in bc if self.id in key} + Notes: + A binding constraint has properties, a list of terms (which form a linear equation) and + a right-hand side (which is the matrix of the binding constraint). + The terms are of the form `area1%area2` or `area.cluster` where `area` is the ID of the area + and `cluster` is the ID of the cluster. - for bc_id in id_to_remove: - study_data.tree.delete(["input", "bindingconstraints", binding_constraints[bc_id]["id"]]) - del binding_constraints[bc_id] + When an area is removed, it has an impact on the terms of the binding constraints. + At first, we could decide to remove the terms that are related to the area. + However, this would lead to a linear equation that is not valid anymore. - study_data.tree.save(binding_constraints, ["input", "bindingconstraints", "bindingconstraints"]) + Instead, we decide to remove the binding constraints that are related to the area. + """ + # noinspection SpellCheckingInspection + url = ["input", "bindingconstraints", "bindingconstraints"] + binding_constraints = study_data.tree.get(url) + + # Collect the binding constraints that are related to the area to remove + # by searching the terms that contain the ID of the area. + bc_to_remove = {} + for bc_index, bc in list(binding_constraints.items()): + for key in bc: + # Term IDs are in the form `area1%area2` or `area.cluster` + if "%" in key: + related_areas = key.split("%") + elif "." in key: + related_areas = key.split(".")[:-1] + else: + # This key belongs to the set of properties, it isn't a term ID, so we skip it + continue + if self.id.lower() in related_areas: + bc_to_remove[bc_index] = binding_constraints.pop(bc_index) + break + + matrix_suffixes = ["_lt", "_gt", "_eq"] if study_data.config.version >= 870 else [""] + + for bc_index, bc in bc_to_remove.items(): + for suffix in matrix_suffixes: + # noinspection SpellCheckingInspection + study_data.tree.delete(["input", "bindingconstraints", f"{bc['id']}{suffix}"]) + + study_data.tree.save(binding_constraints, url) def _remove_area_from_hydro_allocation(self, study_data: FileStudy) -> None: """ diff --git a/antarest/study/storage/variantstudy/model/command/remove_binding_constraint.py b/antarest/study/storage/variantstudy/model/command/remove_binding_constraint.py index 961e878443..2bd52825c6 100644 --- a/antarest/study/storage/variantstudy/model/command/remove_binding_constraint.py +++ b/antarest/study/storage/variantstudy/model/command/remove_binding_constraint.py @@ -43,7 +43,11 @@ def _apply(self, study_data: FileStudy) -> CommandOutput: new_binding_constraints, ["input", "bindingconstraints", "bindingconstraints"], ) - study_data.tree.delete(["input", "bindingconstraints", self.id]) + if study_data.config.version < 870: + study_data.tree.delete(["input", "bindingconstraints", self.id]) + else: + for term in ["lt", "gt", "eq"]: + study_data.tree.delete(["input", "bindingconstraints", f"{self.id}_{term}"]) output, _ = self._apply_config(study_data.config) return output 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 ea52dca4c4..70ad16702f 100644 --- a/antarest/study/storage/variantstudy/model/command/update_binding_constraint.py +++ b/antarest/study/storage/variantstudy/model/command/update_binding_constraint.py @@ -1,18 +1,12 @@ -from typing import Any, Dict, List, Optional, Tuple, Union - -from pydantic import validator +from typing import Any, Dict, List, Optional, Tuple from antarest.core.model import JSON from antarest.matrixstore.model import MatrixData from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfig from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy -from antarest.study.storage.variantstudy.business.utils import validate_matrix from antarest.study.storage.variantstudy.business.utils_binding_constraint import apply_binding_constraint from antarest.study.storage.variantstudy.model.command.common import CommandName, CommandOutput -from antarest.study.storage.variantstudy.model.command.create_binding_constraint import ( - AbstractBindingConstraintCommand, - check_matrix_values, -) +from antarest.study.storage.variantstudy.model.command.create_binding_constraint import AbstractBindingConstraintCommand from antarest.study.storage.variantstudy.model.command.icommand import MATCH_SIGNATURE_SEPARATOR, ICommand from antarest.study.storage.variantstudy.model.model import CommandDTO @@ -38,26 +32,6 @@ class UpdateBindingConstraint(AbstractBindingConstraintCommand): # Properties of the `UPDATE_BINDING_CONSTRAINT` command: id: str - @validator("values", always=True) - def validate_series( - cls, - v: Optional[Union[MatrixType, str]], - values: Dict[str, Any], - ) -> Optional[Union[MatrixType, str]]: - time_step = values["time_step"] - if v is None: - # The matrix is not updated - return None - if isinstance(v, str): - # Check the matrix link - return validate_matrix(v, values) - if isinstance(v, list): - check_matrix_values(time_step, v) - return validate_matrix(v, values) - # Invalid datatype - # pragma: no cover - raise TypeError(repr(v)) - def _apply_config(self, study_data: FileStudyTreeConfig) -> Tuple[CommandOutput, Dict[str, Any]]: return CommandOutput(status=True), {} @@ -77,6 +51,11 @@ def _apply(self, study_data: FileStudy) -> CommandOutput: message="Failed to retrieve existing binding constraint", ) + # fmt: off + updated_matrices = [term for term in ["less_term_matrix", "equal_term_matrix", "greater_term_matrix"] if self.__getattribute__(term)] + self.validates_and_fills_matrices(specific_matrices=updated_matrices or None, version=study_data.config.version, create=False) + # fmt: on + return apply_binding_constraint( study_data, binding_constraints, @@ -89,8 +68,12 @@ def _apply(self, study_data: FileStudy) -> CommandOutput: self.operator, self.coeffs, self.values, + self.less_term_matrix, + self.greater_term_matrix, + self.equal_term_matrix, self.filter_year_by_year, self.filter_synthesis, + self.group, ) def to_dto(self) -> CommandDTO: @@ -114,7 +97,13 @@ def match(self, other: ICommand, equal: bool = False) -> bool: and self.operator == other.operator and self.coeffs == other.coeffs and self.values == other.values + and self.less_term_matrix == other.less_term_matrix + and self.greater_term_matrix == other.greater_term_matrix + and self.equal_term_matrix == other.equal_term_matrix and self.comments == other.comments + and self.group == other.group + and self.filter_synthesis == other.filter_synthesis + and self.filter_year_by_year == other.filter_year_by_year ) def _create_diff(self, other: "ICommand") -> List["ICommand"]: diff --git a/antarest/study/storage/variantstudy/model/dbmodel.py b/antarest/study/storage/variantstudy/model/dbmodel.py index 08635253e7..f6874a5495 100644 --- a/antarest/study/storage/variantstudy/model/dbmodel.py +++ b/antarest/study/storage/variantstudy/model/dbmodel.py @@ -55,7 +55,7 @@ class CommandBlock(Base): # type: ignore args: str = Column(String()) def to_dto(self) -> CommandDTO: - return CommandDTO(id=self.id, action=self.command, args=json.loads(self.args)) + return CommandDTO(id=self.id, action=self.command, args=json.loads(self.args), version=self.version) def __str__(self) -> str: return ( diff --git a/antarest/study/storage/variantstudy/variant_study_service.py b/antarest/study/storage/variantstudy/variant_study_service.py index 582e251013..f4b342ad7b 100644 --- a/antarest/study/storage/variantstudy/variant_study_service.py +++ b/antarest/study/storage/variantstudy/variant_study_service.py @@ -176,9 +176,7 @@ def append_commands( # noinspection PyArgumentList new_commands = [ CommandBlock( - command=command.action, - args=json.dumps(command.args), - index=(first_index + i), + command=command.action, args=json.dumps(command.args), index=(first_index + i), version=command.version ) for i, command in enumerate(validated_commands) ] @@ -213,11 +211,7 @@ def replace_commands( validated_commands = transform_command_to_dto(command_objs, commands) # noinspection PyArgumentList study.commands = [ - CommandBlock( - command=command.action, - args=json.dumps(command.args), - index=i, - ) + CommandBlock(command=command.action, args=json.dumps(command.args), index=i, version=command.version) for i, command in enumerate(validated_commands) ] self.invalidate_cache(study, invalidate_self_snapshot=True) diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index bc667f45d5..34067cefcb 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -42,7 +42,7 @@ ThermalManager, ) from antarest.study.business.binding_constraint_management import ( - BindingConstraintPropertiesWithName, + BindingConstraintCreation, ConstraintTermDTO, UpdateBindingConstProps, ) @@ -944,7 +944,7 @@ def update_binding_constraint( response_model=None, ) def create_binding_constraint( - uuid: str, data: BindingConstraintPropertiesWithName, current_user: JWTUser = Depends(auth.get_current_user) + uuid: str, data: BindingConstraintCreation, current_user: JWTUser = Depends(auth.get_current_user) ) -> None: logger.info( f"Creating a new binding constraint for study {uuid}", @@ -954,6 +954,23 @@ def create_binding_constraint( study = study_service.check_study_access(uuid, StudyPermissionType.READ, params) return study_service.binding_constraint_manager.create_binding_constraint(study, data) + @bp.delete( + "/studies/{uuid}/bindingconstraints/{binding_constraint_id}", + tags=[APITag.study_data], + summary="Delete a binding constraint", + response_model=None, + ) + def delete_binding_constraint( + uuid: str, binding_constraint_id: str, current_user: JWTUser = Depends(auth.get_current_user) + ) -> None: + logger.info( + f"Deleting the binding constraint {binding_constraint_id} for study {uuid}", + extra={"user": current_user.id}, + ) + params = RequestParameters(user=current_user) + study = study_service.check_study_access(uuid, StudyPermissionType.WRITE, params) + return study_service.binding_constraint_manager.remove_binding_constraint(study, binding_constraint_id) + @bp.post( "/studies/{uuid}/bindingconstraints/{binding_constraint_id}/term", tags=[APITag.study_data], @@ -1457,7 +1474,7 @@ def create_renewable_cluster( - `cluster_data`: the properties used for creation: "name" and "group". - Returns: The properties of the newly-created renewable clusters. + Returns: The properties of the newly-created renewable cluster. """ logger.info( f"Creating renewable cluster for study '{uuid}' and area '{area_id}'", @@ -1631,7 +1648,7 @@ def create_thermal_cluster( - `cluster_data`: the properties used for creation: "name" and "group". - Returns: The properties of the newly-created thermal clusters. + Returns: The properties of the newly-created thermal cluster. """ logger.info( f"Creating thermal cluster for study '{uuid}' and area '{area_id}'", diff --git a/resources/empty_study_870.zip b/resources/empty_study_870.zip new file mode 100644 index 0000000000000000000000000000000000000000..78381529946171a8e3ac0b1be0c8c301d0b8aeaa GIT binary patch literal 64653 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 ubCjt^V? literal 0 HcmV?d00001 diff --git a/tests/integration/studies_blueprint/assets/test_synthesis/raw_study.synthesis.json b/tests/integration/studies_blueprint/assets/test_synthesis/raw_study.synthesis.json index 1bf6d17f97..04ce3ddbec 100644 --- a/tests/integration/studies_blueprint/assets/test_synthesis/raw_study.synthesis.json +++ b/tests/integration/studies_blueprint/assets/test_synthesis/raw_study.synthesis.json @@ -50,7 +50,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "02_wind_on", @@ -86,7 +89,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "03_wind_off", @@ -122,7 +128,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "04_res", @@ -158,7 +167,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "05_nuclear", @@ -194,7 +206,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "06_coal", @@ -230,7 +245,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "07_gas", @@ -266,7 +284,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "08_non-res", @@ -302,7 +323,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "09_hydro_pump", @@ -338,7 +362,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 } ], "renewables": [], @@ -398,7 +425,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "02_wind_on", @@ -434,7 +464,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "03_wind_off", @@ -470,7 +503,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "04_res", @@ -506,7 +542,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "05_nuclear", @@ -542,7 +581,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "06_coal", @@ -578,7 +620,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "07_gas", @@ -614,7 +659,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "08_non-res", @@ -650,7 +698,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "09_hydro_pump", @@ -686,7 +737,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 } ], "renewables": [], @@ -746,7 +800,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "02_wind_on", @@ -782,7 +839,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "03_wind_off", @@ -818,7 +878,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "04_res", @@ -854,7 +917,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "05_nuclear", @@ -890,7 +956,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "06_coal", @@ -926,7 +995,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "07_gas", @@ -962,7 +1034,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "08_non-res", @@ -998,7 +1073,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "09_hydro_pump", @@ -1034,7 +1112,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 } ], "renewables": [], @@ -1082,7 +1163,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "02_wind_on", @@ -1118,7 +1202,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "03_wind_off", @@ -1154,7 +1241,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "04_res", @@ -1190,7 +1280,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "05_nuclear", @@ -1226,7 +1319,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "06_coal", @@ -1262,7 +1358,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "07_gas", @@ -1298,7 +1397,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "08_non-res", @@ -1334,7 +1436,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "09_hydro_pump", @@ -1370,7 +1475,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 } ], "renewables": [], diff --git a/tests/integration/studies_blueprint/assets/test_synthesis/variant_study.synthesis.json b/tests/integration/studies_blueprint/assets/test_synthesis/variant_study.synthesis.json index 4ef1428925..a77fa18a58 100644 --- a/tests/integration/studies_blueprint/assets/test_synthesis/variant_study.synthesis.json +++ b/tests/integration/studies_blueprint/assets/test_synthesis/variant_study.synthesis.json @@ -50,7 +50,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "02_wind_on", @@ -86,7 +89,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "03_wind_off", @@ -122,7 +128,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "04_res", @@ -158,7 +167,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "05_nuclear", @@ -194,7 +206,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "06_coal", @@ -230,7 +245,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "07_gas", @@ -266,7 +284,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "08_non-res", @@ -302,7 +323,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "09_hydro_pump", @@ -338,7 +362,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 } ], "renewables": [], @@ -398,7 +425,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "02_wind_on", @@ -434,7 +464,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "03_wind_off", @@ -470,7 +503,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "04_res", @@ -506,7 +542,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "05_nuclear", @@ -542,7 +581,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "06_coal", @@ -578,7 +620,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "07_gas", @@ -614,7 +659,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "08_non-res", @@ -650,7 +698,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "09_hydro_pump", @@ -686,7 +737,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 } ], "renewables": [], @@ -746,7 +800,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "02_wind_on", @@ -782,7 +839,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "03_wind_off", @@ -818,7 +878,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "04_res", @@ -854,7 +917,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "05_nuclear", @@ -890,7 +956,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "06_coal", @@ -926,7 +995,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "07_gas", @@ -962,7 +1034,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "08_non-res", @@ -998,7 +1073,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "09_hydro_pump", @@ -1034,7 +1112,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 } ], "renewables": [], @@ -1082,7 +1163,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "02_wind_on", @@ -1118,7 +1202,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "03_wind_off", @@ -1154,7 +1241,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "04_res", @@ -1190,7 +1280,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "05_nuclear", @@ -1226,7 +1319,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "06_coal", @@ -1262,7 +1358,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "07_gas", @@ -1298,7 +1397,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "08_non-res", @@ -1334,7 +1436,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 }, { "id": "09_hydro_pump", @@ -1370,7 +1475,10 @@ "op2": 0.0, "op3": 0.0, "op4": 0.0, - "op5": 0.0 + "op5": 0.0, + "costgeneration": "SetManually", + "efficiency": 100.0, + "variableomcost": 0.0 } ], "renewables": [], diff --git a/tests/integration/study_data_blueprint/test_binding_constraints.py b/tests/integration/study_data_blueprint/test_binding_constraints.py index 93b5237f7f..1597eb3c7d 100644 --- a/tests/integration/study_data_blueprint/test_binding_constraints.py +++ b/tests/integration/study_data_blueprint/test_binding_constraints.py @@ -1,3 +1,4 @@ +import numpy as np import pytest from starlette.testclient import TestClient @@ -67,14 +68,19 @@ class TestBindingConstraints: Test the end points related to binding constraints. """ - def test_lifecycle__nominal(self, client: TestClient, user_access_token: str) -> None: + @pytest.mark.parametrize("study_type", ["raw", "variant"]) + def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, study_type: str) -> None: user_headers = {"Authorization": f"Bearer {user_access_token}"} + # ============================= + # STUDY PREPARATION + # ============================= + # Create a Study res = client.post( "/v1/studies", headers=user_headers, - params={"name": "foo"}, + params={"name": "foo", "version": "860"}, ) assert res.status_code == 201, res.json() study_id = res.json() @@ -137,81 +143,123 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str) -> assert clusters_list[0]["name"] == "Cluster 1" assert clusters_list[0]["group"] == "Nuclear" - # Create Binding Constraints + if study_type == "variant": + # Create Variant + res = client.post( + f"/v1/studies/{study_id}/variants", + headers=user_headers, + params={"name": "Variant 1"}, + ) + assert res.status_code in {200, 201}, res.json() + study_id = res.json() + + # ============================= + # CREATION + # ============================= + + # Create Binding constraints res = client.post( - f"/v1/studies/{study_id}/bindingconstraints", - json={ - "name": "Binding Constraint 1", - "enabled": True, - "time_step": "hourly", - "operator": "less", - "coeffs": {}, - "comments": "", - }, + f"/v1/studies/{study_id}/commands", + json=[ + { + "action": "create_binding_constraint", + "args": { + "name": "binding_constraint_1", + "enabled": True, + "time_step": "hourly", + "operator": "less", + "coeffs": {}, + "comments": "", + }, + } + ], headers=user_headers, ) - assert res.status_code == 200, res.json() + assert res.status_code in {200, 201}, res.json() res = client.post( - f"/v1/studies/{study_id}/bindingconstraints", - json={ - "name": "Binding Constraint 2", - "enabled": True, - "time_step": "hourly", - "operator": "less", - "coeffs": {}, - "comments": "", - }, + f"/v1/studies/{study_id}/commands", + json=[ + { + "action": "create_binding_constraint", + "args": { + "name": "binding_constraint_2", + "enabled": True, + "time_step": "hourly", + "operator": "less", + "coeffs": {}, + "comments": "", + }, + } + ], headers=user_headers, ) - assert res.status_code == 200, res.json() + assert res.status_code in {200, 201}, res.json() - # Asserts that creating 2 binding constraints with the same name raises an Exception + # Creates a binding constraint with the new API res = client.post( f"/v1/studies/{study_id}/bindingconstraints", json={ - "name": "Binding Constraint 1", + "name": "binding_constraint_3", "enabled": True, "time_step": "hourly", "operator": "less", "coeffs": {}, - "comments": "", + "comments": "New API", }, headers=user_headers, ) - assert res.status_code == 409, res.json() + assert res.status_code in {200, 201}, res.json() - # Get Binding Constraint list to check created binding constraints + # Get Binding Constraint list res = client.get(f"/v1/studies/{study_id}/bindingconstraints", headers=user_headers) binding_constraints_list = res.json() + assert res.status_code == 200, res.json() + 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] + # check whole structure expected = [ { - "id": "binding constraint 1", - "name": "Binding Constraint 1", + "comments": "", + "constraints": None, "enabled": True, - "time_step": "hourly", - "operator": "less", - "constraints": None, # terms - "values": None, - "filter_year_by_year": "", "filter_synthesis": "", - "comments": "", + "filter_year_by_year": "", + "id": "binding_constraint_1", + "name": "binding_constraint_1", + "operator": "less", + "time_step": "hourly", }, { - "id": "binding constraint 2", - "name": "Binding Constraint 2", + "comments": "", + "constraints": None, "enabled": True, - "time_step": "hourly", - "operator": "less", - "constraints": None, # terms - "values": None, + "filter_synthesis": "", "filter_year_by_year": "", + "id": "binding_constraint_2", + "name": "binding_constraint_2", + "operator": "less", + "time_step": "hourly", + }, + { + "comments": "New API", + "constraints": None, + "enabled": True, "filter_synthesis": "", - "comments": "", + "filter_year_by_year": "", + "id": "binding_constraint_3", + "name": "binding_constraint_3", + "operator": "less", + "time_step": "hourly", }, ] assert binding_constraints_list == expected + # ============================= + # CONSTRAINT TERM MANAGEMENT + # ============================= + bc_id = binding_constraints_list[0]["id"] # Add binding constraint link term @@ -235,7 +283,8 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str) -> "data": { "area": area1_id, "cluster": cluster_id, - }, # NOTE: cluster_id in term data can be uppercase, but it must be lowercase in the returned ini configuration file + }, + # NOTE: cluster_id in term data can be uppercase, but it must be lowercase in the returned ini configuration file }, headers=user_headers, ) @@ -331,68 +380,21 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str) -> "exception": "RequestValidationError", } - # Create Variant - res = client.post( - f"/v1/studies/{study_id}/variants", - headers=user_headers, - params={"name": "Variant 1"}, - ) - assert res.status_code == 200, res.json() - variant_id = res.json() - - # Create Binding constraints - res = client.post( - f"/v1/studies/{variant_id}/commands", - json=[ - { - "action": "create_binding_constraint", - "args": { - "name": "binding_constraint_3", - "enabled": True, - "time_step": "hourly", - "operator": "less", - "coeffs": {}, - "comments": "", - }, - } - ], - headers=user_headers, - ) - assert res.status_code == 200, res.json() - - res = client.post( - f"/v1/studies/{variant_id}/commands", - json=[ - { - "action": "create_binding_constraint", - "args": { - "name": "binding_constraint_4", - "enabled": True, - "time_step": "hourly", - "operator": "less", - "coeffs": {}, - "comments": "", - }, - } - ], + # Remove Constraint term + res = client.delete( + f"/v1/studies/{study_id}/bindingconstraints/{bc_id}/term/{area1_id}%{area2_id}", headers=user_headers, ) assert res.status_code == 200, res.json() - # Get Binding Constraint list - res = client.get(f"/v1/studies/{variant_id}/bindingconstraints", headers=user_headers) - binding_constraints_list = res.json() - assert res.status_code == 200, res.json() - assert len(binding_constraints_list) == 4 - assert binding_constraints_list[2]["id"] == "binding_constraint_3" - assert binding_constraints_list[3]["id"] == "binding_constraint_4" - - bc_id = binding_constraints_list[2]["id"] + # ============================= + # GENERAL EDITION + # ============================= # Update element of Binding constraint new_comment = "We made it !" res = client.put( - f"v1/studies/{variant_id}/bindingconstraints/{bc_id}", + f"v1/studies/{study_id}/bindingconstraints/{bc_id}", json={"key": "comments", "value": new_comment}, headers=user_headers, ) @@ -400,7 +402,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str) -> # Get Binding Constraint res = client.get( - f"/v1/studies/{variant_id}/bindingconstraints/{bc_id}", + f"/v1/studies/{study_id}/bindingconstraints/{bc_id}", headers=user_headers, ) binding_constraint = res.json() @@ -408,84 +410,62 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str) -> assert res.status_code == 200, res.json() assert comments == new_comment - # Add Binding Constraint term - - res = client.post( - f"/v1/studies/{variant_id}/bindingconstraints/{bc_id}/term", - json={ - "weight": 1, - "offset": 2, - "data": {"area1": area1_id, "area2": area2_id}, - }, - headers=user_headers, - ) - assert res.status_code == 200, res.json() - - # Get Binding Constraint - res = client.get( - f"/v1/studies/{variant_id}/bindingconstraints/{bc_id}", - headers=user_headers, - ) - binding_constraint = res.json() - constraints = binding_constraint["constraints"] - assert res.status_code == 200, res.json() - assert binding_constraint["id"] == bc_id - assert len(constraints) == 1 - assert constraints[0]["id"] == f"{area1_id}%{area2_id}" - assert constraints[0]["weight"] == 1 - assert constraints[0]["offset"] == 2 - assert constraints[0]["data"]["area1"] == area1_id - assert constraints[0]["data"]["area2"] == area2_id - - # Update Constraint term + # The user change the time_step to daily instead of hourly. + # We must check that the matrix is a daily/weekly matrix. res = client.put( - f"/v1/studies/{variant_id}/bindingconstraints/{bc_id}/term", - json={ - "id": f"{area1_id}%{area2_id}", - "weight": 3, - }, + f"/v1/studies/{study_id}/bindingconstraints/{bc_id}", + json={"key": "time_step", "value": "daily"}, headers=user_headers, ) assert res.status_code == 200, res.json() - # Get Binding Constraint + # Check the last command is a change time_step + if study_type == "variant": + res = client.get(f"/v1/studies/{study_id}/commands", headers=user_headers) + commands = res.json() + args = commands[-1]["args"] + assert args["time_step"] == "daily" + assert args["values"] is not None, "We should have a matrix ID (sha256)" + + # Check that the matrix is a daily/weekly matrix res = client.get( - f"/v1/studies/{variant_id}/bindingconstraints/{bc_id}", + f"/v1/studies/{study_id}/raw", + params={"path": f"input/bindingconstraints/{bc_id}", "depth": 1, "formatted": True}, headers=user_headers, ) - binding_constraint = res.json() - constraints = binding_constraint["constraints"] assert res.status_code == 200, res.json() - assert binding_constraint["id"] == bc_id - assert len(constraints) == 1 - assert constraints[0]["id"] == f"{area1_id}%{area2_id}" - assert constraints[0]["weight"] == 3 - assert constraints[0]["offset"] is None - assert constraints[0]["data"]["area1"] == area1_id - assert constraints[0]["data"]["area2"] == area2_id + dataframe = res.json() + assert len(dataframe["index"]) == 366 + assert len(dataframe["columns"]) == 3 # less, equal, greater - # Remove Constraint term - res = client.delete( - f"/v1/studies/{variant_id}/bindingconstraints/{bc_id}/term/{area1_id}%{area2_id}", - headers=user_headers, - ) - assert res.status_code == 200, res.json() + # ============================= + # ERRORS + # ============================= - # Get Binding Constraint - res = client.get( - f"/v1/studies/{variant_id}/bindingconstraints/{bc_id}", + # Assert empty name + res = client.post( + f"/v1/studies/{study_id}/bindingconstraints", + json={ + "name": " ", + "enabled": True, + "time_step": "hourly", + "operator": "less", + "coeffs": {}, + "comments": "New API", + }, headers=user_headers, ) - binding_constraint = res.json() - constraints = binding_constraint["constraints"] - assert res.status_code == 200, res.json() - assert constraints is None + assert res.status_code == 400, res.json() + assert res.json() == { + "description": "Invalid binding constraint name: .", + "exception": "InvalidConstraintName", + } - # Creates a binding constraint with the new API + # Assert invalid special characters res = client.post( - f"/v1/studies/{variant_id}/bindingconstraints", + f"/v1/studies/{study_id}/bindingconstraints", json={ - "name": "binding_constraint_5", + "name": "%%**", "enabled": True, "time_step": "hourly", "operator": "less", @@ -494,93 +474,418 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str) -> }, headers=user_headers, ) - assert res.status_code == 200, res.json() + assert res.status_code == 400, res.json() + assert res.json() == { + "description": "Invalid binding constraint name: %%**.", + "exception": "InvalidConstraintName", + } # Asserts that creating 2 binding constraints with the same name raises an Exception res = client.post( - f"/v1/studies/{variant_id}/bindingconstraints", + f"/v1/studies/{study_id}/bindingconstraints", json={ - "name": "binding_constraint_5", + "name": bc_id, "enabled": True, "time_step": "hourly", "operator": "less", "coeffs": {}, - "comments": "New API", + "comments": "", }, headers=user_headers, ) assert res.status_code == 409, res.json() - assert res.json() == { - "description": "A binding constraint with the same name already exists: binding_constraint_5.", - "exception": "DuplicateConstraintName", - } - # Assert empty name + # Creation with matrices from 2 versions: Should fail res = client.post( - f"/v1/studies/{variant_id}/bindingconstraints", + f"/v1/studies/{study_id}/bindingconstraints", json={ - "name": " ", + "name": "binding_constraint_x", "enabled": True, "time_step": "hourly", "operator": "less", "coeffs": {}, - "comments": "New API", + "comments": "2 types of matrices", + "values": [[]], + "less_term_matrix": [[]], }, headers=user_headers, ) - assert res.status_code == 400, res.json() - assert res.json() == { - "description": "Invalid binding constraint name: .", - "exception": "InvalidConstraintName", - } + assert res.status_code == 422 + description = res.json()["description"] + assert "cannot fill 'values'" in description + assert "'less_term_matrix'" in description + assert "'greater_term_matrix'" in description + assert "'equal_term_matrix'" in description - # Assert invalid special characters + # Creation with wrong matrix according to version: Should fail res = client.post( - f"/v1/studies/{variant_id}/bindingconstraints", + f"/v1/studies/{study_id}/bindingconstraints", json={ - "name": "%%**", + "name": "binding_constraint_x", "enabled": True, "time_step": "hourly", "operator": "less", "coeffs": {}, - "comments": "New API", + "comments": "Incoherent matrix with version", + "less_term_matrix": [[]], }, headers=user_headers, ) - assert res.status_code == 400, res.json() - assert res.json() == { - "description": "Invalid binding constraint name: %%**.", - "exception": "InvalidConstraintName", + assert res.status_code == 422 + description = res.json()["description"] + assert description == "You cannot fill a 'matrix_term' as these values refer to v8.7+ studies" + + # Wrong matrix shape + wrong_matrix = np.ones((352, 3)) + wrong_request_args = { + "name": "binding_constraint_5", + "enabled": True, + "time_step": "daily", + "operator": "less", + "coeffs": {}, + "comments": "Creation with matrix", + "values": wrong_matrix.tolist(), } + res = client.post( + f"/v1/studies/{study_id}/bindingconstraints", + json=wrong_request_args, + headers=user_headers, + ) + assert res.status_code == 500 + exception = res.json()["exception"] + description = res.json()["description"] + assert exception == "ValueError" if study_type == "variant" else "CommandApplicationError" + assert f"Invalid matrix shape {wrong_matrix.shape}, expected (366, 3)" in description - # Asserts that 5 binding constraints have been created - res = client.get(f"/v1/studies/{variant_id}/bindingconstraints", headers=user_headers) - assert res.status_code == 200, res.json() - assert len(res.json()) == 5 + # Delete a fake binding constraint + res = client.delete(f"/v1/studies/{study_id}/bindingconstraints/fake_bc", headers=user_headers) + assert res.status_code == 500 + assert res.json()["exception"] == "CommandApplicationError" + assert res.json()["description"] == "Binding constraint not found" - # The user change the time_step to daily instead of hourly. - # We must check that the matrix is a daily/weekly matrix. + # Add a group before v8.7 + grp_name = "random_grp" res = client.put( - f"/v1/studies/{variant_id}/bindingconstraints/{bc_id}", - json={"key": "time_step", "value": "daily"}, + f"/v1/studies/{study_id}/bindingconstraints/binding_constraint_2", + json={"key": "group", "value": grp_name}, + headers=user_headers, + ) + assert res.status_code == 422 + assert res.json()["exception"] == "InvalidFieldForVersionError" + assert ( + res.json()["description"] + == f"You cannot specify a group as your study version is older than v8.7: {grp_name}" + ) + + # Update with a matrix from v8.7 + res = client.put( + f"/v1/studies/{study_id}/bindingconstraints/binding_constraint_2", + json={"key": "less_term_matrix", "value": [[]]}, headers=user_headers, ) + assert res.status_code == 422 + assert res.json()["exception"] == "InvalidFieldForVersionError" + assert res.json()["description"] == "You cannot fill a 'matrix_term' as these values refer to v8.7+ studies" + + @pytest.mark.parametrize("study_type", ["raw", "variant"]) + def test_for_version_870(self, client: TestClient, admin_access_token: str, study_type: str) -> None: + admin_headers = {"Authorization": f"Bearer {admin_access_token}"} + + # ============================= + # STUDY PREPARATION + # ============================= + + res = client.post( + "/v1/studies", + headers=admin_headers, + params={"name": "foo"}, + ) + assert res.status_code == 201, res.json() + study_id = res.json() + + if study_type == "variant": + # Create Variant + res = client.post( + f"/v1/studies/{study_id}/variants", + headers=admin_headers, + params={"name": "Variant 1"}, + ) + assert res.status_code in {200, 201} + study_id = res.json() + + # ============================= + # CREATION + # ============================= + + # Creation of a bc without group + bc_id_wo_group = "binding_constraint_1" + args = {"enabled": True, "time_step": "hourly", "operator": "less", "coeffs": {}, "comments": "New API"} + res = client.post( + f"/v1/studies/{study_id}/bindingconstraints", + json={"name": bc_id_wo_group, **args}, + headers=admin_headers, + ) + assert res.status_code in {200, 201}, res.json() + + res = client.get(f"/v1/studies/{study_id}/bindingconstraints/{bc_id_wo_group}", headers=admin_headers) + assert res.json()["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}, + headers=admin_headers, + ) + assert res.status_code in {200, 201}, res.json() + + res = client.get(f"/v1/studies/{study_id}/bindingconstraints/{bc_id_w_group}", headers=admin_headers) + assert res.json()["group"] == "specific_grp" + + # Creation of bc with a matrix + bc_id_w_matrix = "binding_constraint_3" + matrix_lt = np.ones((8784, 3)) + matrix_lt_to_list = matrix_lt.tolist() + res = client.post( + f"/v1/studies/{study_id}/bindingconstraints", + json={"name": bc_id_w_matrix, "less_term_matrix": matrix_lt_to_list, **args}, + headers=admin_headers, + ) + assert res.status_code in {200, 201}, res.json() + + if study_type == "variant": + res = client.get(f"/v1/studies/{study_id}/commands", headers=admin_headers) + last_cmd_args = res.json()[-1]["args"] + less_term_matrix = last_cmd_args["less_term_matrix"] + equal_term_matrix = last_cmd_args["equal_term_matrix"] + greater_term_matrix = last_cmd_args["greater_term_matrix"] + assert greater_term_matrix == equal_term_matrix != less_term_matrix + + # Check that raw matrices are created + for term in ["lt", "gt", "eq"]: + res = client.get( + f"/v1/studies/{study_id}/raw", + params={"path": f"input/bindingconstraints/{bc_id_w_matrix}_{term}", "depth": 1, "formatted": True}, + headers=admin_headers, + ) + assert res.status_code == 200 + data = res.json()["data"] + if term == "lt": + assert data == matrix_lt_to_list + else: + assert data == np.zeros((matrix_lt.shape[0], 1)).tolist() + + # ============================= + # UPDATE + # ============================= + + # Add a group + grp_name = "random_grp" + res = client.put( + f"/v1/studies/{study_id}/bindingconstraints/{bc_id_w_matrix}", + json={"key": "group", "value": grp_name}, + headers=admin_headers, + ) assert res.status_code == 200, res.json() - # Check the last command is a change time_step - res = client.get(f"/v1/studies/{variant_id}/commands", headers=user_headers) - commands = res.json() - args = commands[-1]["args"] - assert args["time_step"] == "daily" - assert args["values"] is not None, "We should have a matrix ID (sha256)" + # Asserts the groupe is created + res = client.get(f"/v1/studies/{study_id}/bindingconstraints/{bc_id_w_matrix}", headers=admin_headers) + assert res.json()["group"] == grp_name + + # Update matrix_term + res = client.put( + f"/v1/studies/{study_id}/bindingconstraints/{bc_id_w_matrix}", + json={"key": "greater_term_matrix", "value": matrix_lt_to_list}, + headers=admin_headers, + ) + assert res.status_code == 200, res.json() - # Check that the matrix is a daily/weekly matrix res = client.get( - f"/v1/studies/{variant_id}/raw", - params={"path": f"input/bindingconstraints/{bc_id}", "depth": 1, "formatted": True}, - headers=user_headers, + f"/v1/studies/{study_id}/raw", + params={"path": f"input/bindingconstraints/{bc_id_w_matrix}_gt", "depth": 1, "formatted": True}, + headers=admin_headers, + ) + assert res.status_code == 200 + assert res.json()["data"] == matrix_lt_to_list + + # The user changed the time_step to daily instead of hourly. + # We must check that the matrices have been updated. + res = client.put( + f"/v1/studies/{study_id}/bindingconstraints/{bc_id_w_matrix}", + json={"key": "time_step", "value": "daily"}, + headers=admin_headers, ) assert res.status_code == 200, res.json() - dataframe = res.json() - assert len(dataframe["index"]) == 366 - assert len(dataframe["columns"]) == 3 # less, equal, greater + + if study_type == "variant": + # Check the last command is a change time_step + res = client.get(f"/v1/studies/{study_id}/commands", headers=admin_headers) + commands = res.json() + command_args = commands[-1]["args"] + assert command_args["time_step"] == "daily" + assert ( + command_args["less_term_matrix"] + == command_args["greater_term_matrix"] + == command_args["equal_term_matrix"] + is not None + ) + + # Check that the matrices are daily/weekly matrices + expected_matrix = np.zeros((366, 1)).tolist() + for term_alias in ["lt", "gt", "eq"]: + res = client.get( + f"/v1/studies/{study_id}/raw", + params={ + "path": f"input/bindingconstraints/{bc_id_w_matrix}_{term_alias}", + "depth": 1, + "formatted": True, + }, + headers=admin_headers, + ) + assert res.status_code == 200 + assert res.json()["data"] == expected_matrix + + # ============================= + # DELETE + # ============================= + + # Delete a binding constraint + res = client.delete(f"/v1/studies/{study_id}/bindingconstraints/{bc_id_w_group}", headers=admin_headers) + assert res.status_code == 200, res.json() + + # Asserts that the deletion worked + res = client.get(f"/v1/studies/{study_id}/bindingconstraints", headers=admin_headers) + assert len(res.json()) == 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, + "time_step": "hourly", + "operator": "less", + "coeffs": {}, + "comments": "New API", + "values": [[]], + }, + headers=admin_headers, + ) + 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 + res = client.put( + f"/v1/studies/{study_id}/bindingconstraints/{bc_id_w_matrix}", + json={"key": "values", "value": [[]]}, + headers=admin_headers, + ) + assert res.status_code == 422 + assert res.json()["exception"] == "InvalidFieldForVersionError" + assert res.json()["description"] == "You cannot fill 'values' as it refers to the matrix before v8.7" + + # Creation with 2 matrices with different columns size + bc_id_with_wrong_matrix = "binding_constraint_with_wrong_matrix" + matrix_lt = np.ones((8784, 3)) + matrix_gt = np.ones((8784, 2)) + matrix_gt_to_list = matrix_gt.tolist() + matrix_lt_to_list = matrix_lt.tolist() + res = client.post( + f"/v1/studies/{study_id}/bindingconstraints", + json={ + "name": bc_id_with_wrong_matrix, + "less_term_matrix": matrix_lt_to_list, + "greater_term_matrix": matrix_gt_to_list, + **args, + }, + headers=admin_headers, + ) + assert res.status_code == 422 + assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" + assert ( + res.json()["description"] + == "The matrices of binding_constraint_with_wrong_matrix must have the same number of columns, currently {2, 3}" + ) + + # Creation of 2 bc inside the same group with different columns size + bc_id = "binding_constraint_validation" + matrix_lt = np.ones((8784, 3)) + matrix_lt_to_list = matrix_lt.tolist() + res = client.post( + f"/v1/studies/{study_id}/bindingconstraints", + json={"name": bc_id, "less_term_matrix": matrix_lt_to_list, "group": "group1", **args}, + headers=admin_headers, + ) + assert res.status_code in {200, 201}, res.json() + + matrix_gt = np.ones((8784, 4)) + matrix_gt_to_list = matrix_gt.tolist() + res = client.post( + f"/v1/studies/{study_id}/bindingconstraints", + json={"name": "other_bc", "greater_term_matrix": matrix_gt_to_list, "group": "group1", **args}, + headers=admin_headers, + ) + assert res.status_code == 422 + assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" + assert res.json()["description"] == "The matrices of the group group1 do not have the same number of columns" + + # Updating thr group of a bc creates different columns size inside the same group + bc_id = "binding_constraint_validation_2" + matrix_lt = np.ones((8784, 4)) + matrix_lt_to_list = matrix_lt.tolist() + res = client.post( + f"/v1/studies/{study_id}/bindingconstraints", + json={"name": bc_id, "less_term_matrix": matrix_lt_to_list, "group": "group2", **args}, + headers=admin_headers, + ) + assert res.status_code in {200, 201}, res.json() + + res = client.put( + f"v1/studies/{study_id}/bindingconstraints/{bc_id}", + json={"key": "group", "value": "group1"}, + headers=admin_headers, + ) + assert res.status_code == 422 + assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" + assert res.json()["description"] == "The matrices of the group group1 do not have the same number of columns" + + # Update causes different matrices size inside the same bc + matrix_lt_3 = np.ones((8784, 3)) + matrix_lt_3_to_list = matrix_lt_3.tolist() + res = client.put( + f"v1/studies/{study_id}/bindingconstraints/{bc_id}", + json={"key": "greater_term_matrix", "value": matrix_lt_3_to_list}, + headers=admin_headers, + ) + assert res.status_code == 422 + assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" + assert ( + res.json()["description"] + == "The matrices of binding_constraint_validation_2 must have the same number of columns, currently {3, 4}" + ) + + # Update causes different matrices size inside the same group + res = client.put( + f"v1/studies/{study_id}/bindingconstraints/{bc_id}", + json={"key": "less_term_matrix", "value": matrix_lt_3_to_list}, + headers=admin_headers, + ) + assert res.status_code in {200, 201}, res.json() + res = client.put( + f"v1/studies/{study_id}/bindingconstraints/{bc_id}", + json={"key": "group", "value": "group1"}, + headers=admin_headers, + ) + assert res.status_code in {200, 201}, res.json() + res = client.put( + f"v1/studies/{study_id}/bindingconstraints/{bc_id}", + json={"key": "less_term_matrix", "value": matrix_lt_to_list}, + headers=admin_headers, + ) + assert res.status_code == 422 + assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" + assert res.json()["description"] == "The matrices of the group group1 do not have the same number of columns" diff --git a/tests/integration/study_data_blueprint/test_thermal.py b/tests/integration/study_data_blueprint/test_thermal.py index 9fc7388642..a44d7058ac 100644 --- a/tests/integration/study_data_blueprint/test_thermal.py +++ b/tests/integration/study_data_blueprint/test_thermal.py @@ -37,14 +37,12 @@ from antarest.core.utils.string import to_camel_case from antarest.study.storage.rawstudy.model.filesystem.config.model import transform_name_to_id -from antarest.study.storage.rawstudy.model.filesystem.config.thermal import Thermal860Properties, ThermalProperties +from antarest.study.storage.rawstudy.model.filesystem.config.thermal import ThermalProperties +from tests.integration.utils import wait_task_completion DEFAULT_PROPERTIES = json.loads(ThermalProperties(name="Dummy").json()) DEFAULT_PROPERTIES = {to_camel_case(k): v for k, v in DEFAULT_PROPERTIES.items() if k != "name"} -DEFAULT_860_PROPERTIES = json.loads(Thermal860Properties(name="Dummy").json()) -DEFAULT_860_PROPERTIES = {to_camel_case(k): v for k, v in DEFAULT_860_PROPERTIES.items() if k != "name"} - # noinspection SpellCheckingInspection EXISTING_CLUSTERS = [ { @@ -63,19 +61,7 @@ "minUpTime": 1, "mustRun": False, "name": "01_solar", - "nh3": None, - "nmvoc": None, "nominalCapacity": 1000000.0, - "nox": None, - "op1": None, - "op2": None, - "op3": None, - "op4": None, - "op5": None, - "pm10": None, - "pm25": None, - "pm5": None, - "so2": None, "spinning": 0.0, "spreadCost": 0.0, "startupCost": 0.0, @@ -99,19 +85,7 @@ "minUpTime": 1, "mustRun": False, "name": "02_wind_on", - "nh3": None, - "nmvoc": None, "nominalCapacity": 1000000.0, - "nox": None, - "op1": None, - "op2": None, - "op3": None, - "op4": None, - "op5": None, - "pm10": None, - "pm25": None, - "pm5": None, - "so2": None, "spinning": 0.0, "spreadCost": 0.0, "startupCost": 0.0, @@ -135,19 +109,7 @@ "minUpTime": 1, "mustRun": False, "name": "03_wind_off", - "nh3": None, - "nmvoc": None, "nominalCapacity": 1000000.0, - "nox": None, - "op1": None, - "op2": None, - "op3": None, - "op4": None, - "op5": None, - "pm10": None, - "pm25": None, - "pm5": None, - "so2": None, "spinning": 0.0, "spreadCost": 0.0, "startupCost": 0.0, @@ -171,19 +133,7 @@ "minUpTime": 1, "mustRun": False, "name": "04_res", - "nh3": None, - "nmvoc": None, "nominalCapacity": 1000000.0, - "nox": None, - "op1": None, - "op2": None, - "op3": None, - "op4": None, - "op5": None, - "pm10": None, - "pm25": None, - "pm5": None, - "so2": None, "spinning": 0.0, "spreadCost": 0.0, "startupCost": 0.0, @@ -207,19 +157,7 @@ "minUpTime": 1, "mustRun": False, "name": "05_nuclear", - "nh3": None, - "nmvoc": None, "nominalCapacity": 1000000.0, - "nox": None, - "op1": None, - "op2": None, - "op3": None, - "op4": None, - "op5": None, - "pm10": None, - "pm25": None, - "pm5": None, - "so2": None, "spinning": 0.0, "spreadCost": 0.0, "startupCost": 0.0, @@ -243,19 +181,7 @@ "minUpTime": 1, "mustRun": False, "name": "06_coal", - "nh3": None, - "nmvoc": None, "nominalCapacity": 1000000.0, - "nox": None, - "op1": None, - "op2": None, - "op3": None, - "op4": None, - "op5": None, - "pm10": None, - "pm25": None, - "pm5": None, - "so2": None, "spinning": 0.0, "spreadCost": 0.0, "startupCost": 0.0, @@ -279,19 +205,7 @@ "minUpTime": 1, "mustRun": False, "name": "07_gas", - "nh3": None, - "nmvoc": None, "nominalCapacity": 1000000.0, - "nox": None, - "op1": None, - "op2": None, - "op3": None, - "op4": None, - "op5": None, - "pm10": None, - "pm25": None, - "pm5": None, - "so2": None, "spinning": 0.0, "spreadCost": 0.0, "startupCost": 0.0, @@ -315,19 +229,7 @@ "minUpTime": 1, "mustRun": False, "name": "08_non-res", - "nh3": None, - "nmvoc": None, "nominalCapacity": 1000000.0, - "nox": None, - "op1": None, - "op2": None, - "op3": None, - "op4": None, - "op5": None, - "pm10": None, - "pm25": None, - "pm5": None, - "so2": None, "spinning": 0.0, "spreadCost": 0.0, "startupCost": 0.0, @@ -351,19 +253,7 @@ "minUpTime": 1, "mustRun": False, "name": "09_hydro_pump", - "nh3": None, - "nmvoc": None, "nominalCapacity": 1000000.0, - "nox": None, - "op1": None, - "op2": None, - "op3": None, - "op4": None, - "op5": None, - "pm10": None, - "pm25": None, - "pm5": None, - "so2": None, "spinning": 0.0, "spreadCost": 0.0, "startupCost": 0.0, @@ -376,16 +266,50 @@ @pytest.mark.unit_test class TestThermal: + @pytest.mark.parametrize( + "version", [pytest.param(0, id="No Upgrade"), pytest.param(860, id="v8.6"), pytest.param(870, id="v8.7")] + ) def test_lifecycle( - self, - client: TestClient, - user_access_token: str, - study_id: str, + self, client: TestClient, user_access_token: str, study_id: str, admin_access_token: str, version: int ) -> None: # ============================= - # THERMAL CLUSTER CREATION + # STUDY UPGRADE # ============================= + if version != 0: + res = client.put( + f"/v1/studies/{study_id}/upgrade", + headers={"Authorization": f"Bearer {admin_access_token}"}, + params={"target_version": version}, + ) + res.raise_for_status() + task_id = res.json() + task = wait_task_completion(client, admin_access_token, task_id) + from antarest.core.tasks.model import TaskStatus + + assert task.status == TaskStatus.COMPLETED, task + + # ================================= + # UPDATE EXPECTED POLLUTANTS LIST + # ================================= + + # noinspection SpellCheckingInspection + pollutants_names = ["nh3", "nmvoc", "nox", "op1", "op2", "op3", "op4", "op5", "pm10", "pm25", "pm5", "so2"] + pollutants_values = 0.0 if version >= 860 else None + for existing_cluster in EXISTING_CLUSTERS: + existing_cluster.update({p: pollutants_values for p in pollutants_names}) + existing_cluster.update( + { + "costGeneration": "SetManually" if version == 870 else None, + "efficiency": 100.0 if version == 870 else None, + "variableOMCost": 0.0 if version == 870 else None, + } + ) + + # ========================== + # THERMAL CLUSTER CREATION + # ========================== + area_id = transform_name_to_id("FR") fr_gas_conventional = "FR_Gas conventional" @@ -430,18 +354,15 @@ def test_lifecycle( fr_gas_conventional_cfg = { **fr_gas_conventional_props, "id": fr_gas_conventional_id, - "nh3": None, - "nmvoc": None, - "nox": None, - "op1": None, - "op2": None, - "op3": None, - "op4": None, - "op5": None, - "pm10": None, - "pm25": None, - "pm5": None, - "so2": None, + **{p: pollutants_values for p in pollutants_names}, + } + fr_gas_conventional_cfg = { + **fr_gas_conventional_cfg, + **{ + "costGeneration": "SetManually" if version == 870 else None, + "efficiency": 100.0 if version == 870 else None, + "variableOMCost": 0.0 if version == 870 else None, + }, } assert res.json() == fr_gas_conventional_cfg @@ -453,9 +374,9 @@ def test_lifecycle( assert res.status_code == 200, res.json() assert res.json() == fr_gas_conventional_cfg - # ============================= + # ========================== # THERMAL CLUSTER MATRICES - # ============================= + # ========================== matrix = np.random.randint(0, 2, size=(8760, 1)).tolist() matrix_path = f"input/thermal/prepro/{area_id}/{fr_gas_conventional_id.lower()}/data" @@ -554,6 +475,24 @@ def test_lifecycle( assert res.status_code == 200, res.json() assert res.json() == fr_gas_conventional_cfg + # Update with a pollutant. Should succeed even with versions prior to v8.6 + res = client.patch( + f"/v1/studies/{study_id}/areas/{area_id}/clusters/thermal/{fr_gas_conventional_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={"nox": 10.0}, + ) + assert res.status_code == 200 + assert res.json()["nox"] == 10.0 + + # Update with the field `efficiency`. Should succeed even with versions prior to v8.7 + res = client.patch( + f"/v1/studies/{study_id}/areas/{area_id}/clusters/thermal/{fr_gas_conventional_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={"efficiency": 97.0}, + ) + assert res.status_code == 200 + assert res.json()["efficiency"] == 97.0 + # ============================= # THERMAL CLUSTER DUPLICATION # ============================= @@ -570,6 +509,11 @@ def test_lifecycle( duplicated_config["name"] = new_name duplicated_id = transform_name_to_id(new_name, lower=False) duplicated_config["id"] = duplicated_id + # takes the update into account + if version >= 860: + duplicated_config["nox"] = 10 + if version >= 870: + duplicated_config["efficiency"] = 97.0 assert res.json() == duplicated_config # asserts the matrix has also been duplicated diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index 8000a80ab3..33b059ccae 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -1780,6 +1780,9 @@ def test_area_management(client: TestClient, admin_access_token: str, study_id: "op3": 3, "op4": 2.4, "op5": 0, + "costGeneration": "SetManually", + "efficiency": 100.0, + "variableOMCost": 0.0, } res = client.put( # This URL is deprecated, but we must check it for backward compatibility. diff --git a/tests/storage/business/assets/little_study_700.zip b/tests/storage/business/assets/little_study_700.zip index 721a9b9f44b79ba09949b3e09ece0e732f901f31..e9a34f5f495ed7ee150689396c379df771b6ffc0 100644 GIT binary patch literal 131440 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

    }AA8vTJGU7N#6|B=8Gmj%Vqw+j4 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 THwg3+_^ dict: return dico -def assert_inputs_are_updated(tmp_path: Path, dico: dict) -> None: +def get_old_binding_constraint_values(tmp_path: Path) -> dict: + dico = {} + bd_list = glob.glob(str(tmp_path / "input" / "bindingconstraints" / "*.txt")) + for txt_file in bd_list: + path_txt = Path(txt_file) + df = pandas.read_csv(path_txt, sep="\t", header=None) + dico[str(path_txt.stem)] = df + return dico + + +def assert_inputs_are_updated(tmp_path: Path, old_area_values: dict, old_binding_constraint_values: dict) -> None: input_path = tmp_path / "input" # tests 8.1 upgrade @@ -152,7 +163,7 @@ def assert_inputs_are_updated(tmp_path: Path, dico: dict) -> None: path_txt = Path(txt) old_txt = str(Path(path_txt.parent.name).joinpath(path_txt.stem)).replace("_parameters", "") df = pandas.read_csv(txt, sep="\t", header=None) - assert df.values.all() == dico[old_txt].iloc[:, 2:8].values.all() + assert df.values.all() == old_area_values[old_txt].iloc[:, 2:8].values.all() capacities = glob.glob(str(folder_path / "capacities" / "*")) for direction_txt in capacities: df_capacities = pandas.read_csv(direction_txt, sep="\t", header=None) @@ -160,10 +171,10 @@ def assert_inputs_are_updated(tmp_path: Path, dico: dict) -> None: old_txt = str(Path(direction_path.parent.parent.name).joinpath(direction_path.name)) if "indirect" in old_txt: new_txt = old_txt.replace("_indirect.txt", "") - assert df_capacities[0].values.all() == dico[new_txt].iloc[:, 0].values.all() + assert df_capacities[0].values.all() == old_area_values[new_txt].iloc[:, 0].values.all() else: new_txt = old_txt.replace("_direct.txt", "") - assert df_capacities[0].values.all() == dico[new_txt].iloc[:, 1].values.all() + assert df_capacities[0].values.all() == old_area_values[new_txt].iloc[:, 1].values.all() # tests 8.3 upgrade areas = glob.glob(str(tmp_path / "input" / "areas" / "*")) @@ -184,6 +195,28 @@ def assert_inputs_are_updated(tmp_path: Path, dico: dict) -> None: assert (st_storage_path / "list.ini").exists() assert input_path.joinpath("hydro", "series", area_id, "mingen.txt").exists() + # tests 8.7 upgrade + # binding constraint part + reader = IniReader(DUPLICATE_KEYS) + data = reader.read(input_path / "bindingconstraints" / "bindingconstraints.ini") + binding_constraints_list = list(data.keys()) + for bd in binding_constraints_list: + bd_id = data[bd]["id"] + assert data[bd]["group"] == "default" + for k, term in enumerate(["lt", "gt", "eq"]): + term_path = input_path / "bindingconstraints" / f"{bd_id}_{term}.txt" + df = pandas.read_csv(term_path, sep="\t", header=None) + assert df.values.all() == old_binding_constraint_values[bd_id].iloc[:, k].values.all() + + # thermal cluster part + for area in list_areas: + reader = IniReader(DUPLICATE_KEYS) + thermal_cluster_list = reader.read(tmp_path / "input" / "thermal" / "clusters" / area / "list.ini") + for cluster in thermal_cluster_list: + assert thermal_cluster_list[cluster]["costgeneration"] == "SetManually" + assert thermal_cluster_list[cluster]["efficiency"] == 100 + assert thermal_cluster_list[cluster]["variableomcost"] == 0 + def assert_folder_is_created(path: Path) -> None: assert path.is_dir() diff --git a/tests/storage/repository/filesystem/config/test_config_files.py b/tests/storage/repository/filesystem/config/test_config_files.py index 4f88115291..7cbab645bc 100644 --- a/tests/storage/repository/filesystem/config/test_config_files.py +++ b/tests/storage/repository/filesystem/config/test_config_files.py @@ -27,7 +27,12 @@ ) from antarest.study.storage.rawstudy.model.filesystem.config.renewable import RenewableConfig from antarest.study.storage.rawstudy.model.filesystem.config.st_storage import STStorageConfig, STStorageGroup -from antarest.study.storage.rawstudy.model.filesystem.config.thermal import Thermal860Config, ThermalConfig +from antarest.study.storage.rawstudy.model.filesystem.config.thermal import ( + Thermal860Config, + Thermal870Config, + ThermalConfig, + ThermalCostGeneration, +) from tests.storage.business.assets import ASSETS_DIR @@ -333,12 +338,26 @@ def test_parse_thermal_860(tmp_path: Path, version, caplog) -> None: ini_path.write_text(THERMAL_860_LIST_INI) with caplog.at_level(logging.WARNING): actual = _parse_thermal(study_path, "fr") - if version >= 860: + if version == 860: expected = [ Thermal860Config(id="t1", name="t1"), Thermal860Config(id="t2", name="t2", co2=156, nh3=456), ] assert not caplog.text + elif version == 870: + expected = [ + Thermal870Config(id="t1", name="t1"), + Thermal870Config( + id="t2", + name="t2", + co2=156, + nh3=456, + cost_generation=ThermalCostGeneration.SET_MANUALLY, + efficiency=100.0, + variable_o_m_cost=0, + ), + ] + assert not caplog.text else: expected = [ThermalConfig(id="t1", name="t1")] assert "extra fields not permitted" in caplog.text diff --git a/tests/storage/study_upgrader/test_upgrade_870.py b/tests/storage/study_upgrader/test_upgrade_870.py new file mode 100644 index 0000000000..f22a7a30ef --- /dev/null +++ b/tests/storage/study_upgrader/test_upgrade_870.py @@ -0,0 +1,31 @@ +from antarest.study.storage.study_upgrader import upgrade_870 +from tests.storage.business.test_study_version_upgrader import are_same_dir +from tests.storage.study_upgrader.conftest import StudyAssets + + +def test_nominal_case(study_assets: StudyAssets): + """ + Check that binding constraints and thermal folders are correctly modified + """ + + # upgrade the study + upgrade_870(study_assets.study_dir) + + # compare input folders (bindings + thermals) + actual_input_path = study_assets.study_dir.joinpath("input") + expected_input_path = study_assets.expected_dir.joinpath("input") + assert are_same_dir(actual_input_path, expected_input_path) + + +def test_empty_binding_constraints(study_assets: StudyAssets): + """ + Check that binding constraints and thermal folders are correctly modified + """ + + # upgrade the study + upgrade_870(study_assets.study_dir) + + # compare input folders (bindings + thermals) + actual_input_path = study_assets.study_dir.joinpath("input") + expected_input_path = study_assets.expected_dir.joinpath("input") + assert are_same_dir(actual_input_path, expected_input_path) diff --git a/tests/storage/study_upgrader/upgrade_870/empty_binding_constraints/little_study_860.expected.zip b/tests/storage/study_upgrader/upgrade_870/empty_binding_constraints/little_study_860.expected.zip new file mode 100644 index 0000000000000000000000000000000000000000..508430ffc5b01313f9157d424d75df6dae64ad2d GIT binary patch literal 124012 zcmeEv2Rzl^|NrG$ajmSIEhC$2&s;luR!H{Dc8z3Rd(TA4P9-TTB(nEbvJz58NR$!6 z|7xlD^wHVJw|NXfJV*8>=xoVuX>0kB6e%PKLxKBYG-|1OU+P z-CH|*Crca43x-aXcDDQJcSM9ZW(_Bv^opP~u0bZrJn@`7$zwBXw8vLC<)Y7D(6}Dv z8+?1~d1WJ&7P-H&(Tz(f(Q@Z`Sgk3pW4JAIqTW-Z%zAdc?3_4E%fp$u@oa(Y#`Bw5 z7#+mQ^bOD{R<3FF*Q`wZSz;@~7mo(a6LHo?NXXFZ$-KM8cha^mMBTWY5PPXdFpZ5e zc!lk>Wz#8mDYU;mG|eUZ%?Euxjvel|2S3?0)`J1TJtyL!- zVs|?4^k=F|fmL29od2afikb{w3;0)Mo%o{9Lwh_$^tj>jgEz)!Hyc0f(&km2OOo5z(6vO#>W#+ zTSX{b7((HS{IbI3{-2g|?te+jFQz|s&_T$I;~r8DGj)P}RrFts^B#_bXlp(;oD(F~ zJ*;7ZB$H-9vf=Vjv4#`q3Ms{9~fux>MU7{gV!3eDoG?gjZ48AlMH= zJPdzI`=f&T;#+;O&wF^2N@3!d$Pf_1N2yPOP9y1-IgYDKRnTZ4+i=~x#hEgU$v)&c z?7r1tx){CObbf@1pX#ZjPREo<7T09zv3J>lHT_kj>-QSta6= zKtU+(l}D)n6sYtna5N$R==`LnN68$H)|nqDWmVPYW@m?av$zH?o@4D;hBsemtF^rE zfu(Alw9x6N>M)(2@+=>|%n*9XUgoeq0i`_CIx^c*asWfAHm9n-hmomdLPa;r`Vro| zUZV5l0sfb~(C1|cJWkHpJf&v^Q2nt8|CzLXHYNK8kWv_{+;X4N#=U<4!yuTo+Wl#h&99cVr72rqE-tz6ny%x2>U)KD${751B&$W1xt3O&xnj zQ+r3dzo9@clw7Hg+b? z)}JDveYiibTdEHMI5AMFUDXx4Hm?%YrAA{;=wb1%X1F>^D-L-=K((9)KL-dLAn>1q zK-&o67w!2;RU}ZaS#EoXn~K}{fiG#QFJ9MY?fF^NeXEd644n-36C=HD%|8U7#zU#@ z=aAW4vs1X=&t^CA~{c4F^#AA*?L-4`R0RjgI{0AYhdiCqF^!`-=u>f zOR9kTKo$I#iNOIo2MBzl3U=oNcA^a%OPDd6xvA|huKPva%Y5RF0RU8`9seK32;8<_F+2MpKjywL);eLMUjJ&294DyuxxK)_#{mQf zQgAR44s^kRIy|rl2iEX6Si^U&>|a)6J|$0n#k1L866L_NIk4FWHv7P4AK2^zn|)xj ze}m0FP$m0OC4bAa+22a>foF5z*&KK_2kK})>gWKa1C$Q@zXSj8H~4?wcs9G2wy?gg zMp@e#n(TX7Yd4|8-xK=VyRvtR@;-L#`+bUkyOa5x@OcH19_3C*sG& z@h^xU7so#-eq0>?3F61a@$ZNq7su}szfW>f&i2Zl3?cPL5YOIHZ*1r2X!?0teK{Ov z`$&*4&#nX9RJJ!+w1tefw5!OS%sY9at(8To?fDq;UlaWO7o`7^;B)^Qg3tYr3O@IL zgy3`kOM=h++XVkl-H6Y-7Jkj%gX&Xy@)48hJvY*i6X`R(ZBwHE3pM@?5NrSd1DHnz zG1+{=CXOoW&U=*`|3CB?56_4GAcJBB+MwYfF2xk~! z(P0RCYH92Ac~{-`DP-Hb()KuwY)9)?XGPx*Ej?f;2X0H z$z|$PUy5Ta8Qk!-E2umJWwY zVD#gc&u>ioU{oKXZ~giBNd$|Y2==z0A6VSR@>3I_zt)8LP7|&O^4rfJHi7ZinlN?v z6l5T{utSjFe)eeM(}~GH9vQb2c)B74?Y9J;9|`@&!1G&rpTnzPGep}m@nwhMj_1Dz z+28N^@6pI_^!)dr{{O-A-=i1*i08jYJHN~G-|YbHDBxYq{+_^d53>J)`2E(va}O5& z6U6Ty3Ox7V_IHWj&jQbHoBekK&u?4)R1&{$^YF94a}Torg5dwwz;h24{u2cM9|}D8 z;P!V3{%*aylfVAh>~9P_zoobR{eM01+#8So?m+94UfMel-y4tr?m&F^{M)(x1ZHP# z=(ulmu;f@Y487bP_VNOv=vJ7o~nCgugX7_?Zy?lgm<`@4tBLCf<@$N|B&k@;Q`T76dMXDdy4t8P%YfD?J&+AfO ziXgT>KdnOS$#!d*Q9>5KcB4z;uNDSrG`0svB@_Y~YrEdC3@8toU=*0(*I!c>d8>?v=QUh;DcRSM@R?i46WOIhtXci* zYAdsoEL?}CyCsg-t5ZRASs?I>K2lA>&#Eyfq$Qf29YqC0v!E!~A}#B}C2n3G(pFH^ zeH-I5gxPMA(XM(&sPlLnrSr3#<~LiJ&kjq*zG4caLUYRJ*}x>oP7zS(iPgQwsA)fQ zv+C6yE+~Cl@Z@3i)YLdE0e&R?%ZF&D_}CWQ3WV6s*yuet>D0xQD9b(kY7muUjjHp0 z{wwiT3~> ztCK97=ZoY*ga0#MO%9~rYsSAILfrcQOCq%2_<-vmJ~)VJ4iGp%-~fUDcm%$Q4|W$( z@0nHlyhyk0w0?R1Fimv>5pW=Uj<2shonpko)(=kc7rNV@e?a@wWz^rA>^+_0?`VHc zUj5+n{iLz~%TDn&^xwj>r&GKaJi9!a|5AXqgW*^Fj?&vsF(uM>Th(>~HZG1|3x<^< z#UpDtx9>5R#)T47^cIez%;J%j-cn%IJr;U4eaP!DW^lyotqQg+EJFXv7fDd!W^4^e0v9kM~VXx@T9=Yh9S$3Ar-+ZUb=Y-{;FjW*P ztvT(XWHWIA3^f6IpyJ1_6YRmeTPyl+ZT{y?99Z#A}Eyf0gXzS;D>V@bJx(UR_*rLem)a%bJt z!p+3dZYPtvuUqB<3X2mW5Zw(#82z1#_dZej_`aX4cXGQpP7O6)z;jQPYShH8-aE%E z!;iFT9z&LlmY-jYEe>Q4Df3>aD37s*jxbXv4_=i$&m6h@2-;zFtRv%T0f%7DK)CjW z{_BQC78rH1gQ=k#OLvD7{5AsJj-$pDzS#w!aMUg`=~D#Y?y5@EZBk@ib-^ zdRE-eE;rNuj$%8-0^dyht%TVk_YUhf>Py88LTiY2MIw6kL_(jmz&1r)v5TI>u z?B5yRT|_^vAnXUgut?>$=4KUT*5zw*F7K-9pdS(*3s0^_SaLM_k*u zyKQura;p8d&Hb0#KJ`P}y9aV-l77P6*v`i07Y1=R!JXR}Z+ir^n zDLMck@HPFuH)Uf-Qxi+4oqL2~zfd6b=>U{kwFXAxI7l?;ZuRrPL--QmqvhBRN~i_q=+FjPr6>q-lXV*?M_GyqU_M+Kx|u(4#Vq{C&^2K6F8;5Iuha z0swX{_1?SZmbPZrcCM<1PKc5b+h0MsLHrQnzyg%KO2fJtedWA>yod2A8lTQ9aJ!89 zeTNWz`D%i8ZV%nNL#~F1o}4nYb~gPiefuiL+ObK0ckuSr+{Y^!|Cz!24fAzh^7n7d z*ZpDno963p0B&FX-8XQ5U4iVcBJ-b9Ab)GV?yK`Z&ey+T6!sZ%r?gx+Jd%C8!rjF)r`>EjkadeKF zwK}mN<}VZC`FR_kTHKdo%+4s!#@G@y-qOY*$JWx`$e|Jp!)lbhIAz>i?(t7G`Hf#m)d268(M@(&K=ZXkcuRlm5^WA~!q zk5lY3!B3I@r?7E5WAvv-D}6~r?amr~lDO~bBErSLqNu(d`XAF4-#6fB=lH7)d|BQ6 z!oiQS^?d_2h(oAMcTO|?$*S6pw*58qzge06t>|-c{KM#Tas0#RBRKfS(f_QKe!J{* z|I_Gm|I_FrIQUEGf6)-zlX&}u|J9puL|9x`LnG^Nt7!kk6ztZFJBsWZa>mxqutk1F~CEU(~bv;Nw@+?O))7i{oG7<5v{dU*LoLxA3tu zslJh`Zzt8hr1eLQf{vJ~)`o81&G#?+@BXmxXq!l^j~FixLM2IkR!g6WeERq*dtXTH zQ4JB+A(x}b#vD1$rJ8rrzODIrsInq^GpoW9(xFC28V8ZHaAwf`Q0}*_KGRhaP@mY0 zN1ESw%U_0JTPtqA5IJ7Jmvc6Dlmh&7iPld!$t<=Z!$krBrV-EfYS>pY+bayFh|@Cn z;}x04h}-&c;z_S0)g;=IQLEo(AYv$!KU*RQ2m0lkh%_l;E-vy$XJ!_W^m=c;u+Gfa6-M*V0oyqv!opd$1ToHqbD3VqJ0zlD9U|hiT0ri2DyHVo={cU$B$EV(l9!l}yH1eN&yuL$YUo$1X|3;G zu(KslEye(EQ_=s=gEq!<&H|NSfl~BB?dSjVN7@wP$UnIgxG*23xD}uE% zc2!HHIoXmvfhNdK;qF$vtl0N?=`f=g?L>${B0xObt1e#=h8dgM8ai6q897^8o0vMX zBldfLwGUrJ=ZCQibCFY0U~Oko!(eWgV^d}e`FS|J?fidj4zbK(=j>?wtQa1n)zlijG|91$&oSaSE*e#9i{;dDAp6FVdr{8TNE4-ml%M8wdi}D!}Mi50% z;Gs(6tEr)&s;P$hhQgil(izj#Ut?qS6S?>GN02In=l7l8H1Bv;W?fq5?y7z9&HC2y zlu49<*=L2Y$%ou9vtjO_TD9j{u<&q!_azkOhZAZS#_R=LTzW>x7G}`rt>)%>M%vvS z1qj*&lU5gcMjnQ13Mf1je2Kn!JhtY!E=c`3Pop!Xm*Y_s#a%<4I+ZO8c%F zeDZre34V^q0v1mQiSD^PAZ1RuTMS6?67rwGZl`LI zK2*!A8d%9UNNygU?Vl@MbbGeB$P&4) zwLbQ1cSTEjV|1^QoH$F?9rnPbn$uZRP=(4S+0MV_g1uUUfT$C7JLa9lbK%A87I5W- zyAL&IrOpbUL^Vav3OQQ_OJ|Y?r$-bZgSr|7%`C`tuj;$LE>btYN2*`O9v7A&+B5HZ zO8OF{r|9YnMb$QHwzpRvmszH@EyyqyFjWj%ghb(l zQYd;(ez0Vis&hQ!MA})_^b}xo`B~cB;Om|um1XC;KGM;3qi|usKhTXT;{g& zg$%}bVNkSN`T9QV>3+rA-tFn}ns&HIWySSq-<*zh;PQ2?3?hwi)j26z$Jajh8s3lG z&|1-XIS6-&^J2HN2*`acbvGdYs;iAsAg@rYu^+Qhj%rpJnmn0R_+4v@NZZjj9_}y} zt~bwhkEL4~&MMp~Tdu={aI^g$%WwJ1v zF1hfjtx7VM6`IhYUpHEhfy5Vxwfa>>+ILM`W@iDO z#yT5L*Ad%XzAkNzIf^rjIf`!GT8t`FS23$&B5&O`Oc(4oF`0XOn<7s?>nfRt@`S)b=|xY?ZvSOwBbe{m zAh+lI8-w@xnENYUX~1rVv^`X`pJSNP^|{tHqz!^Su85)>6}u>NE-mBA0}Jko$uln^ z81)Awlbar%%cfb*9b~i(F0L!BCicw*mLdB1UU74XW(>x$+zTD=eoz+`TbeJXOPndY zN%h{*u~UPolwT?_y`FZdD`x0HsY*y2cEu_Cxl0#kCiGt3Vs=VBt=xV1s=K={M2Gts zyxiPO1$%So-q4weGwDwrD}`VmgXQ9l6{Do8j|Mb$xFQ(_M~YD(gw@DYUuAPlnpr^wRm|5b&R;`8fAlr zMtZ5;$zsB*?lxHrTZ7lVbi!e$+qh{QXqU>>1dfcHBJw_F<3uKQO_xtC%3tTyBWf3#$}dYOrb*RKbtB5SXrjFIOibw;8!NvJ^Jhb3v_)#R zY4Keft*P0KD~8nkZ4_@W3WYQp*jDN2MdQ1@NuOCay#iL>DWGaWMYanh0N$t80$o! zmWI9UyrJ~-I8mglrwDDK6G86_?QcNbM6V9L3+x%{kEhaCeg+H1t{vj7pJcx%!e3_) zhx_<>J@%RfRxf|sp=H83!3~dYW79l{w{7q>tJ8xQwMg^Ow!GIR$eLkxBq?NL9S<%` zr&4jWlbsL++IP~}YE9j$P4%9G+6=nBn>-%2fzc#-JxpkGSZC^7NYl`nnTbH~hHJ2> z8-zn6r`RhV-|miq)Y$cU+lPleo*I3;kO>X40zy(;A(7L4Hqeb^$ZCO$tGF9F+P z5Ga9`voPuTE@Afb2^<1zLc~i!^_^7c$8P|9iKbLA=l*56c^T>xvPlc;# zWoMp7FClr)Jsg9SSd`xjBH^FavzANY)}oRqM_Cai?8Mz9=;c&s9DUOrxki~681lAD zy!GbgYi$6HO;5}>*CV{{SS$5{E8o0s0>60--6@i2-dH(F z1i@aIF=I3?KNL!t)~c^?C{p8gGJOZOWayGjqN*g6G|kkYI4-%oQ!Y59?ytu4rMIFLUW1*hZ8v-C%z&+Jjt*&{6ueBl!W~7 zq*?Hj(N*77{(9bwiH2fbi#dw7?Mk^C0Tc1~9LY=;4J)ZVLwAT$$j~CS`3`lk5fa|I7H(tX)-y_UC*xg_6yeXQe>LPs?w2FEFtl0cT06uwM$ffE zd~ObX>FQH`d+hK@m&S@R9gDf8G1y}Vp!Xbe>Ey6fE4COfd87_`CdTG-s>=iufg%CI zOZ>M*0;W21hRKD?y>OEsC%|wVNnq%zg8ui#HF0PS!Iu=YW~J}kz9_4<3es|gDg(#7 zwMHb#ab~Q8gJ>E7R!B__RTltn$*mm1F-ma?HUlm*=@Qrkch%%$72&WaUz79@tZN*g$ZHpcMmqEsQ|18ZG0%rHVIR;4$>Nd&!-B?;6D_wjOMQ{prsi= z=Wb1rXgwbIaMsGLK(ZtKgzB<(feF9CG2!A$EtkWSaJISy8PZ}o87>n$KhsKuR!TPJ zbyc&0gn}4PW_sZqEa^AdRX`Tj zno`H=&Py*Wgt5t3_*{Y99P61M2If0gY11g8I8TAX*;mF{a^rVq5K5ldu61{g6{|B zd+LtMV9khYoLH$+!1EC60aBlC)mb)uWGdF!>P#PL5HIQ%FA6Q5Rn^Ft5jvduhDsCR zFgnMgljzinQvjYmD5pNV>0WqjY5`Ko-iF~VYvT)i?|tRPlC+6kla5PMSt(5PuV9(+xH6Dyk_Mg7!wjpoQ$Vd6~f%LHR(xE1TFwr;fwe|U$ zqk9gGx$&NW3_ymjn2527yEIK6i-v&GqnJYF@ytf)~6))0S%< z8;4hEBkD|ULMqKeh(f8dCDHVkbdB*D<*zC`x|;pA zv_Znft5E`$ULJ6H7CB#}=Xts^Bf~oBK!K#F!m#ey929qc@2m?C9i0~^>7sfsF_+B? z1&Y79P2W7Hd2C%q-lb`)<>Ku#kdRi!^|q9AOC6^dATMt>GM2q3y?eYsm|!41o=K&G z`9bCVx$0bvVr3r>B9-OWA@A`=%aRQX9SI8wJEc>0vg6-r+G$qPeAwYH_*F zk65)#;;^{;FLlu+QCk*_j6FF$FmYSTld`Ue&W`5h>{Eia`NT8DL!(cxq^#5m7F%fO z0?TDM8&*_Kk1bVIn#FG1fX}MFGp1dCz^RnqiJnqXzM6ef1KWR07kp9ZR{FdisYEWq zqhMh#J2C9*HLQ|j4^?O+4e_jufaS=sQ3yz=wr*WIJ&EE)%qHA(dEn(VO6i$J_|k0z z6#XRB=RNV_$==T_uJIo0%2QsUVL1HmWKHp1rhMn1!q=-w_~5EOfs)=3@#Rwz1(FXF zlg#%e1qqz$Ow3zO*9VV!rYEp_JyW-MUBOWl8I*5u|8AYfrL5U1X<$cF%n(Bs zbx-X4{6Gs%5{|3jSa;7 zL<%$(SnII~YCHny2A&=sNbkih6|yXl)IBEW1%{zpoQR`HaTOnEolKN#AAk?PT6v>j zQ+GKRxwMmnk0@lEDBAN_JzETG8P0Q@c*8BUVr7iVlthW4*&M)G$Ut=m)}WB(g_|(G z`oifkgHdV7`0ctr$kBp?!UT%E1)iSBgQkb3v4dz1=J9V z$D;k@c5(|GrY(9UUe^WFISs_@3+mb@)>9R&G|toP|AjTBwAl0d(QXt9ob25$^gw*m)xvxI8Jn|jZ( zy2wE)(hXllg}Z*G))5t@_{f&eXC!CA1Q^7M=|wY&)$)tktNX zQzjWn{5DQ%#dbQ=&L?NRng_>J7$H01YmTkd#XjzNLUnPexyTP6kKiuzgp9Lc-YT!B zw|?Y z^1~jN=)5jv6ezR~Fgiqg#%L|`FxXy^8qjuZt&pvd521}1-U=|~uPGQg$ycICIWXQl zz-TyBFKNNS82=DEe&aeS0UfwfD_eqQ$)CP z@!h4hCmS5oCC^2tOU$sB$S#1ZqA)kEzTQ|L>8yN5AiFU(b31VDj!V?q9l4n+y~2wj zm!pERLl=+ZW29g3yTQR6iJ<_UPaqLHNZVOTNw24yhHiJ z;CF88j}D(-FC>1u%)~bFy3c3hex6v&V)E!~HUEusc_d=VePPrWOOA=Tdpy%iDX)64 z@nCUSX>{XJlHCP6g6Ce3hg>PeaPF&T>IXnOLysnkNxo}9c~7`l;gYBz)tC4xk>#v= zrdyo9ihxS$*pu!dAtWz~^kTQ`>sP6__=P7+9V37a^!>+ z-nbnfI2Ofe0L3TIy6qrfV`QGIobQD-R*6sci2_?!3<%4K@!8uKg9A6v8&~?04a~C6&8v*2UE;kd z+ZC0x0oh=BD7$ofMiBlQ@Wjfm1ox_e0cR0MLX^+*SV(l+(^TcBK0K8|8=tsPDKFl(!1&V7*0wlJ+Cb8GMz0vTo2741ktIj+Nif) zv$}GgT(Lyz7>q3KP6%-c5Pez+(slCW7Nr1Z-;2xd?@i`h#u92Y=F1(CiNipfkgEms z``}|586!FWs3sC=#t&_T-4Sb>=K-mmyd#EVPd-ZVEa4sTBF;u2ogsV~^)RGr zroDz3Xu=_8mrMKfJYczD-WaKL7A^wrGK)gGQUQNdsR&|?2Ldgf-SKhFc%U~=^GInw zZZ8O5q`M|gj59c3FC)7&?t%KOuo`}u1I!@G5EtkJa)WRNprTDv;Br3o!SoVIaSXj*-o`0O1IP-a$4Q&Utug|cEepdno+60!7!TqrXz-#mq zcDSbfNPJ+tJRsSt6mwQj9}h@6tFS@C6xQ-v&ODCmk-$9fTh~oM^Y(xuzjv#AjKV!4 zX95h}m=2*wL*;(_2C^F~ofH+Yd2%ii09G0c6C zyhO5Sqsj2@0WW|`k&r-bxi4O~)KtyYw=KZ&(9|tKCrW@$uY>QBrJQ*NZlCAFH1q@7 zI7jxzr3&1TzNB7*o3B4l}jEfJNzkt8>0*sMUTO}U=N$YsCqOjsclud+D4|J}8 zGNaM;VuCrT=^2KKRGpO47}2!)eTIbQ=)yHvY`QC)uCCt*Jhzr1kk>QWKPoW#jGPq6 zI)-#Zr}}8M4pM|q_uEEb>npuf1SaY%cu|L*1kA#vKqqpDIO~9qIH`+H8L%1v6~Vm$ z$lw!vB>E)GzCPrJAo6iTz$QT6|s=RLEV}Ub7CT3k(=;FXeT~5=noqW}`$zM2fDX8#x_k zn?#%DdK@iImH#d-+SKrQBVNgd%c|vOCydihfPgwR6;M*N%qO5%5Ax$KtAc@wt2E*u z&X+-SMo@VX>cd!^hVVrXZNnh;oT9DSUiGI}8pH{>m07IOhh?}8;eEGLu+d^=dYA4o zfagyJQoKI~sVcB#4G^X##r;4A#*09|%n8t)VUfnM7{qwNNs8{7B$!@BWl#rD5Khws zDaN-`MEfI!e2DP|0z=d}so7L=njS!vK>(_S8Xfd|LfO@3r0B+F_VP4ns)-y~$I#wt z8>X>S8gvj~!GfuY8I!LCINynI#s+ru)EcMY^cJyucdaD^AcYJe9r=K7YA}lDZ6_|- z;7mgXQk~2ZO7lEYT=_^Kc($=rNaYat#JEH7qqrtoQk9e~0`kycyz-v*W7(2o8SOc{n|q6wDuU0O>f|Y#k(8zodi`jSAh@ zC5@8&CO{>267t2F zc}lQ7wk{TMlX8T(!*JZF>}CL_`Lyvf$x7$0lGyjO$Q4jNw97F6P(%2jxBLq@J`mFp zy@DSNGoPX#59v6R!ZczsL(ua{Qz6cS8_pe>GixAm>#7l&v!#snBt{C7ImQ}X)9o~e z=TS4j0$Hq}oWqT9YMi)ssu*-&j-Zs(mH}4J!k`p3YS}G3Pd_iHN=r}KJ@L+0<4rVP zXcyj*1dthRB*v)e~YLMWtT%Bok$&aZ;chEMvexSZxFMapwyMO zMo?c4EqY4ar1bR(%vpVZ@fT1FJ0E0`0!1Xy%Pw%%u^4}13_eKjeNs3E z2o{+@c&BWE~|i+X$>hk{#IRgLhSu z>ZH7D=yZx6H{1()Gsg2iW3wy)TWY4Cr_f2s!NcazUjL^A^A9+?F<$Fnc!ozb=@`h9 z@E+d;;3B;~siu7o|Hy4g&QB_~FKqDeREX=m(&otlmz8}~t`V#waV6AreU=|Oi z6V57)=RMwG_=l^5r;n!4`szZ}?jINH1EgZDMND0f+<3@Dsi}yO4*-o`ywnDfd9``L z044L}d>28M|`s zy{9hEk}Oe0?n$YwXe?juQtHzfMZRnf7c5}b6sec7y-ik*8pjc2g_WaLpnLEw`V1!+ zF*MNlYT0{dqxQ0xuC!(-&AU5yp;(Ir4yyItSf9C8tKE}=TnedTU?8l5=bVS)0euBV z-*DVI7IZ>Q9{_Gjh9>hf2}cy`ry$ zX^jvkbH0h1GN%DjozBV^bD>-w!Bt?>aGgQ6f2us{tHgb^B=d@#+;V{#uNg`(ETngRzzGT7+{okYy-} zDBrbiYhsEIK?>CIkpdBN83QiBy9KgxDG){z7!=%r-#%UMWrLyRqs3{L!7zfxh*%DN z5?;c_S$$mgQnyUER|ZcD%FJNFun1QkR*}|LE5ng%ONDLol{Q50P?zcei{+UPY2kV|(+wj? zK;Dpvj$k|T0 z+Xs)Xip4$#>l;ftao9c6;zCO@F{rw-D00)GY6W#9L=t7wAU}bW1ylvYSg4w|M5y+1 zk|)F=E>Nzh*(!C|Y}vilQmN|ufVUE36;R@!V2Xr!Ive22s;W}t=O6HrDs?8Y`V9eV zwFsf>Cs8zwJcPQMX@K6VsBvrvqV0zKek+e1FZ7V~?Q8jWOdA8?1WouMYsd|hJUQ!!TY`|92#}*NI!-MTR*Rd49~n5qdann+{MI`pZW6OUk0W zl1yr;c8p4RCyJ{ZLC14PRv@B}Dhp1PmAJ)Bs znCwKV{3xI}xfun`O|WffuOh!V zi(f={fn1d~H%)2!Qq07AZno|Y>E4&+9mcKJ`H`>lj#K1$qNl37(qr(>gvx;#i5dY( zGzeEfZ8Ny;xD+?!PCT8IGxwN! z*~Zs%t^IYWdGB5lFr*O9Z>Zv>M7!Hswe81+AmyQ6FZ9`JzjQ(Wc?AI1Mlut}YniAT zPQCyq6b2+ojDqU1d*jbxRWznSr0tQ0gk~=iUobo}SzBtkY_UqH!Q-07p7=}!osGgc z9FcQ1Oy(oWB9Y4n8bQ)oXwgPR=ts@2x(_%81QEo08GSdgYQ9XO7;I#qAKXiP{l$ZBgIVj3ELT0IBNoL%V>R zlX64+yiBagjTgwHai`ie@PLOAivEo>s@u^llDmoLn)FG!L|lu(faCHOuGoYTo}L*u zDdm^Aq1s3?jIKvP#&KjO;RV2md2Z|AR6EbZ1pW`Kq9-Yi`!yW8l3>Uty^i1Ahon%! z&MYu-uTjXB<}prQH6ZDh>!j`4a!Q8!>nE_7o zL=stt`}nHTlH+E-Td8ZcYJGkrf-Fa}*B=pG7iB{t7i=6n%!6JC3zLrwym;S1+4z3> zgLAdE2sNchtZ|s9CatDOt8bDs#bzOKh_EGDO<*ejP>NqJZ^N3?MC!$s%T$OQ5-K;^ zXp$f)dV#x|+?0ZVM!A@!b{ugi1)U&Y+6#vMGsx?h{ZElrA<)!Ly^FR!eDznxCQpY| zrpCN-Z!W-c^7an}WBCFtMG6RxdOUi3-t)X!1W>55_)z2mdABJ!R!Z&G-R8Xd8D#~J zPNx`etAQcVxzsqmZo#S4L%=@mL8K};BaYSzeca&H*lBUX!LtP-X+gKdvfsiaL*F*h zEin<_0~&0ta1=oW2^O10%?8UbF9h@%c8<+@CB9r;jK$;dRji`*8;X{qyh2WXvsHiLUkt$U6|2D5j`?t{ig&d#qu`G>_825Z%N8(2K8f&ZiBI>x_f%HQ03QvO5 z_3U|~T8MHl8?h3;sN%g1 zSe>ZOv{~5NVGii<(-r)sNDK7$D115*MJ#epFc5x20cTaDB+85FhKcv@5~IO@Ucx** z^5@Wvlp$@3*GfgT7o1Itfkt2p`T-m~&^`J|+s6XYJz?_0FgBKnbJsxL*jL;SPiZ-v z%){x;r!lsZU!7?%qodRntcT~q)G9GO51mrtJ?b9SOex9a8JwnvvFQ^DYpPgr$)1Ah z(1xmHo6J>7Bh6J6#UcVeUHr#**VnJ6^*|cVM|1=9Co`QuLnJlgXT2Y-B^jfVXS`G+ z%@GWK{2)7K6OsC&f7>!JFAD%xqZwJ*J>Zvq-V7sWhIegD)VF<|W3!oDzIR zoI_|zG#!!fa+I{H1<6H969PG9$1o^rn1EE&ox{GokCg&?aYj?Q8;-<&fMeZr zP|4UwZmm41<>^Fsby|ZX`80bQm5(Q?FbOYS3%{Q(zJQECQD_TRQw<#K3e_e*%H%T2 zhYNI`kuVgtPYv=x7Xf`JF2q5X7aZXWofeMfOd6o9dg!lPz{r8>L^PPJa6u!s98PY2 zbxsL#%v)uKIjJP$-Ig}enm>Qd3eeF$*uVxOKg}QTuJysy zdwE@YnXFKBGVv}^8x?cGRxk-JE5$XH>0m_i*H;5nI!0K2itvNS`&nOAZYRUD1PJ#e zuAWu{0L*ZK5~ERKjzS@zU|49nUS9v1i}>i8-Zbok51TOBU)^MX>H4bQ_KBwFBky4B zAdFr#M8w-XiMdAF_1M=?$V7=c5J}LzE2*QT_IRT@YZM;$iJ+H3*NvdB$d4|ZA3b&5 zz$@#LYNRR%_!Je=l@jHHsK3psMQVLFYm#hNMz2SOsHZT|<%k}91i*e2mcu#-Xv z!|{Pkk0sSgDvuf)*yU%?X9VN9RX9)Se3`cGY_ff5`j(>SI$re}-trNp={ln6t7!R6 z)ol%QREr})J}wgA^K@{abcc|+P%FqjnpbaV?y*p!m?pu@6AWBqMA|Avi^JqRu6->4 z*j1=?I0)mtBvZu$2`iy4+?SDjjaLjebhuaOHPc9lda%-6W|^kRYTELTQ(US#b}3cX zMFS0Ou3#RO2aWPIewB~!btQ$V6B;&_cE6 zW%~`Ftds_MKvvHA``JK5%F5aJIX8CEOQFYj)2IR5&fVbC3v0*`exfMoJ$f&a&$rDo zRdo2TurK%0xMPd2`0Cd9U5Q83H{b1gcSs0%j?uIC;k>rlVa}@l)VLrd9}!3$;8^_A zbb}J15$J16)#{PPz@F%V#f(^QJ0F24#rt}h05q3(oOw5K5aGMQ@jmtAAkA1IpW|L< zsZckmhogv7`cSHn?34h;D@NJqhZcp1Jcg&7^#dy@L(o^=cJQE_$%CZ}zEWVEavyzY zRjc^ERHArNM}As~X)tVB!jK>i(s9D#89;FBUcOAGSq=zv;$=*;pU-PWFka*#X~_Kg zaHO*Up74zBha@Y$hsV;C0T1OR%fjB+1ezcB3ck@lXm3}*#2m>fWvp`uHYB^SHa3AW zO+W@ndgOQI6eC9yv?-xV#88wccvw5xj^(+og4_rECR`+a6oCl?poK8{h>a0CyH_Q! zx5Q&Z)?@$)0@9S9!091DaEm8AQcO9iGpEQSVnC}dd}`5J+V2~Tk z@Yff6CMR`LBYTgbb|8-(I3@uDCq*&9!vX9mD4W|r2;Zbu_LByw8G!C(VL&`4bYYAK z*l>u%G0=_`27VX8z>|HQ_&8989iX{{(6@a3EPcZ171Spc%W<*ZDL%0%kBsJzK(A_1 zo}eI4Tofa)mftwXjBttS-=9=6hv-}Z#UCMgW&tdKeJGw~g>~6nP7Fx!1f`=Wzjql^gDu{WYC9*iC0eVD}cTrX11Lv&?BGqi4|h45Mhavt3@h%ErU3&sPIYX$Q0%B9x~g?+(89tQa9LJM+ zuWSsQ<#9?+uXe<1NOd8<2XO+}gqsyDSYSX_%}|ORkbwbFJMef<2bmZU#ev-C+cIE) z=8u5wUl(S;$PEV0*gjsJQ24z2y55w~e+c5jrG9#>I4yLKg6#=bq(5oBdeF$|OL(o+ z3Cmz1w1$N5I0a+l{JLgD?^oEyg!SN$Fz1H^?SKdd_A+BY5C`(~qLOvSG1QG|9wGP! z(L7wbzb=UhC1HT>M+RpV0Edk2UK9B2qHt6Gz5-?UA)@&s5Cg!!AwCqc2@V{ze2O{Y z4t*aCh{prUzWSnaW^+L!M$kTUq!)$Zr{Uu7m z0L>u+e))#k4TNk0sc*Da#j&U(*01w_4hAIn0wx?Na@M)5K12`;LaxwXpbh~39=POC zD-=A-jHyR0oN2T^(v)2v?iSQO!Qu!rtUHQ3Sig8cybqCJUqXll!S(~ESTvsi`%rFH zy42-|WbQ2x$G}}C42b558Dm2EF(8No>Ak4*W$ZbZe9kqTT{3^DmQe387#n9BQ$32M zoM{O8VaN+i{gErQPLmcRjA^M#_=C`&^bE}#=3M_#^d>2Uuc23hDUR(iO*+y zZXehkcqT)z?=Fq9r1qY!wwDV7qMTwT4A3=0;1eg0HX?fq1lO^UZ$4Wly!ymx|_MV?!(+mZ5t7@M+CTputwY*XG+esA#noVH<;A*rIc}|;pu$XOCZiWf&GSA z0Tf?dl*d5tTkvy%{c8qz9;gvFAvGg*7+}XGyuH~4mCtJ^Ha}pYD53$@Uu(tqBmWQv z1aTmDPdaIwVg?Qo)Q%x9MBgjIAsntvooOfu0|NXJ=tr02Bfh6(OS~1jSZI&7C0L<| z-jvM|^&g{nz5?tC^dZ`tLU9Hz6*VRKA>fi+Uf+z=kKs3^e<_?|k-9Og32tNizsTD_ z$Ujg$f>ls;MiZ>h*2?FP>|w%yeEJu|!FR9&f*c}<10W`Z`T~8fF4P6KCst1 z(H?pP1Mti%!YgO48!HC`LO77q4(v>n-G@l{OhXGQM+muM>>udo)gRl09f=l%S1OG^ z0{v%-!p(%9wGl#l%<6JPr&m+_A&3VcE`a?=O!k4^^CrrDf%8{m2kViwz^~<0PVfp# z9vRsGf-ox~{{RDe*(@1ljCI=knf6cEm<7W1#(ajRz#*K>i*y;uv5&(@^AG zODZp5Mb50Fdji23r&P^YGI!tvwg*s86xXS{QfEjqQjDd{}|`%EEc3^^jm2k`YTkRO5=0G{*3K1M|Mf}cz5pRX}m zJEH;iUk%W{rA^2)|B&E89t_mgjq9s_F&xO-hlrsE4cl`qVPC=SBulabIf;Q?$2buNq= zit^CK>k?B`hSvo(nGP6Z&58c+Y-x#g*b0x<{PA!X*#1^1dVro-e;vRA^tXUP;n)CU zs1??8d$~D8%EEvk4&=cAQy(Is-jpoPvxEFlWsEt=$HOxUT;Ky~)cK}N{1KWvBHE7t z`We(@*brYJs|O9;hp2W{6SQHr6$%pAn<6zg<;3fO>cHX0ClHIy?5Tz34>v#=<4w_; zP%Dg)Mrg+(JN%qGVEg5SV+99{A+E&!Kwx)<*kUXUGr-S0rTc#>9*~Fw!5+18V1O|m zU~`sPx;55oBeZ^&DOp>=^8sgCo>*=pz#kCi5JAs@gc0Iv$BGy$;se;7b4}F?Z*OWr z_#{ldXJDP_BLyEcf0p#n6zEq%_d}%nDgoC5@~Ee{d?=n4L{(KxS{87_UB8TXE_yQ&ze0L1+ z`wocjEr8rW;Y>5K-hz7$>jLOYDas*&9Kp_o*8F=-ug~GEWO|<=+w<%w4jGjfSU1xW zMfKN3GkR#DMc#(^`$8YgW@uYNW0W(|jKl`u2VhRRUy&!TPcDrQwmN4T!wGM}3ge&? zGmq>$V?YQ8B4?Y*>O&-rKf-jDnWX+B^tqO`*apBk22d}A`@3M7!bsPq;oC5 zCkBN1q&CUX{QlgmnR6fnFOx zj^N%ls%CJh*qOwH2Qr$V?F;Qt*<5Vf!mY8Lx5Cf31L^27aXwcNmmyWDt6YnBn;5;0AS%@W+S}jqV|hGp7{J)SPM|=LdX|Z%(D{X zy21PDeTLF8AmJ=CdG?p^_Le|?7;*)$R)qY}qup)s^GxN4U_61b?+0w}JD~EI#v}%U zXWxsLH-UWBg!by_*z)GYCn(AtP$mx8^rVx;Ba;^chb5e420d#};aDIq$e6?o@cct> zVyGL#@8j4vOztAQ=M-%7!Av{ez9L1>HkGGGtpI;S8i$CNA9{+P^<&GLqtr2GXm&qc z9CPTS6*wliQqt;Q$ABmfl!E~o@c^51Etl2R3U)4Ph(g^pNni4PsZ`w>!Ur%HvIETB>4ildF3qj7D(a{333U+?!(&fan%oG9SHHj)BPR)HDADl1BEdl z&y{H&5 zz)&xuVnKQ+zgAE`97wMli()_)he(-cn*x6b?7*RHTT(|{o9@+P=7u6RB@?c<}#2ldlJ!s@O+Z6Hvu-6po1aU)jrS5A0pHUWV zO`TsOgF7HEue?5eh$Qnz{yhwc;y@`FsAmrvHfNhcjUaJ^zHqNRQ5@hkdy20SsTV0dmd9mNg-qF*fI1 zv+GGGCy(sE1OtLN!216ti9^JOS1wQQ2~j&B)`NyckJ_snoygf%K29nm769GD02?t1 zp=+hh(XPdf(fnZs zWbg5`pEOaTrvcix)Q<3n|1}K!gZvRCjc@#YEb->G`%^@H!S}_0Bpw+X4A8maYo#s7 zo}vp-Hz=ZNhrgCPi^T(?J!ozyf&n&s(*HgTz<5F9V8M8!We3yQ^G9U%DV7iqu*Sd| zDHsr|89MRmg;YGqc+Rz)=UU2(0ht`4e_z*(<3&wpbfKi}IT{B(J#~E!!5%Y-7?5HI zM0(OG_bfAcdQ&Q?50RWbX#On@kut{Xqg_zxlIE*v95l00Z@4ST;#qat_r`z(JHQ45 z66!@l{mA}d3_ScV#sG}>C*06fZ|l)C4pdY)oQ$a^n~r5Skdr@Bk6u(_@c?`Nh@}3c z_3A&OU{AV#i$hf3@kezh^r))iOGg_mD;fvzXjlLfS0XBVz>U*Z4^mDG6d1TCWW7$1w z|I^2Z6F;KOY0mkAI%mRx8a5TF!DcJYVk{_wKO*g%>+f@}<#*x`{fBY~Wm)j z>TtV-y^gUg9KesMftv2N#n!c_S1Q<}mH`8D^&wI+he(ng_`ie!$Q@#T@ZPhxSr<`# zOTq#Cw06|AtHrAV_m)m47K!P(zP@k zz)u@TO{eP2Mz_wbru&o1&moe80nzjBzXt|n@yh?3IYjbfz-qK4J_kG0i9i4*Pm1&JHX~_Qx<0$%Ec@H|A<4xtp85v-ElYc=fTdevm%?snHi~TvC*wO zIG}$lO}P4@UM3L-b8K(l*yxBZ7d4^w9?HQVsV@e;KacFcCmw*j&`Z1?ggh~}1Fv^v zxh(J6#-O=8I#mP*f_U)vF!ITtV_k7>_1n8Jh9t=o`2mZ$)lB#>``{D0b zXE(Gxlosv~-o{DSP7z(!2M2;bF7}#@#|<#@$(qq1uO`R7_F`evr&#~r*z!lD$Ak>$ z+x{QM!1Dv0U*F%=;nI#wmy*RHb|HP;j9Z%+aOlzAt#joq`D4T5(pY?SG!SZQT36U%WgJJ-u~K_tL!5XV0Gf8U3=MUZQvBW!ECUJpc2h z_q%!3rJqZyR@}4pI8t)(_U3n0zwT@pxwm0u^`4#6&V>X|3hz7b($a{O#4?o>RXdKm zmT{|x$f&8Ut!~f+pHK7qd7X8-c&H^u_0_BK>sOSo>h!GiXij0UxleEJr%qXwE+?}e zFAf~MqxGt8*Y<=yyMD2x$;%FD)sg=9Z|}MjobNW{@|rc{PJQ}o@1Iq=o2tJ>wYZVC zzfbVB7q`w|-~aN@&r5GM4(~MWZqVTJ+aVn^ycdkJ;Ka4vTyxF3U^zGU;FhY0KT;}f z+iUmR^-}+uNA2K*QE`FsadBJP=#4(}^lXmq@liobt2=x+>(ctn{&$Ch&lGm=+IZHl z^XC28VKC>A_BGb)m<(*M`2fU!JwTI_uW^IiqY>`NwO{wuwu;{iE(%M?2dUtBQ|=-m`Xy zf4zHzpLf4|D+VOQxwTq6TJ!n#mAWrq#y-Bd3q85(7xTX1-S7M6(xJ`Q z8q1g&Z`-3Au`fgJd@>rzxjH#$)q!fiKOU}W6m&A9Lzm>Y8!UtNtZ)qN5LQvN+QhzK zhuh-7!g=2=e;Y9P+pFp>DD^HXJbSc#Md_}pW?FIA4HnvdE^+G9{msnk$76QAQF$C$ zv(KPipQaPGwj6HgXt!$9L)YR7>c`FUPbHMyT;|?q)|`kl*>igPx|DqAHC59qfRmpb zWSIZuSoPmy&wYD^UL7A-=HnLnaf_+@Z|lFzN{#(w`}Nf44e!7G@O=B6PGh4+nHOGc zSTts*chJ+oRet8)#^dJAbsTctbfkTJS?1ywaaXM;HIDx9=@R;~edNbG{gn$gRxj6&ztSCZjej z4BOlvdA5C9@U9SA% z*(u}lz2>7eT}onGOxsl-<&OiF(dgf8j_U5<~SsAbGp4s`&K@!G%aiIc|@E#x}&G+ zwLatZ4jyv7<@tR2%}=ery$ss)_tPPF4FzRYg(Yz?6e)RvvrUWbEfJ z(ceB)f1Nn(?}*=S-zjxb-Rxa4z5MT-yI->6=dJnF`f#F8^S^ITb;*9X{I9+NaY?Rr z);X)W%dT?X*bLY@r1vlRmFvb#8SL)p`b%=jgVOS{Re6ES9b8Ou2YENIs;!#b-+j2| zD(;eJ?ykqpO-BCt@Z357d!K(=bMx<;cV}LsikH@mZsu(>+&`z!JwCq6RyV(2mNuPK)VY`U z?soe(mO2(ZD|ouYG3}|7_Qu^sy6@g?&iuS=yvgPe7>UblSVF)oeRRs zpY5LD_pZHml;6+{ZupUvO;wr>+T2ssyDgSvj^5Op+g>ij&q;TiTUFbh>Y(?WdFMLp3*t{dwB<>x;8rs;xf8j9Z>p znz43K_3NWaV?R3G)f=76O*yHd)jX&C)>Z$ws=N^=dd4mi1Wo`1L+twbMRzeI2Dw#eMEgnO&y-d(?Zy;%L# zCE>o^4=%L zUfgBPidWTd&c&qdPW5#CD@VQgq1hu&Y*^%}HtEKz_SxeCo}T#O?Qa(x&Ai4(@Aj?PF{7wyK{va9%kRE%`o5Ucj#C`t zF0}r(KlVw;op)Bxi(I~@I2Lfvr(g@)y05DBjaL2aJbMS!%&B_6fLnDnr$v=ljb~BW z#_i9pFLZli*sAxnoXgja%r!jwvGm1gwD<1kyY8=Vca78!h_gmKdor7xJ22^(b zQ+E7PThHb ztgh5m-~|;7azE^tRDE*V{pN+=gy6?LlWN(pX}pPFwEu1 z<;H_bd#&%3eyOT<*B_C|H`FRuxt;$l^Uaib4K!#E$kyej_(#8#?@u@r`@uW* z`0|m3^Mc%Os#)!Osk^2@)E~h}cl4-@*Q+9Lm>Ru)6&5nsJ*H->dU4e4@#V1?`pxfv6Xw{CwiW5sl zeHb{vU4<^=urKKjt? zhOG-6wEv17VcV+psm#73!YrPy_v}~WA9p3c*vi|}+gC!2>|26%k)>{sACZbYQT`nFqp zUG1fs)5vAs#ydV%*(+O|y*Sucy{dFqYva6=$8)Zjx5mcpXkFC+5sW)~^ z4}W<$@>q3K?vU!I-&&vlwWfK`19z%l&)&ZA!Rr*~j2x>zTZ=1KsYeyoKC7J&)FRaN z&e7aB>(G*_ts_R;w>DkqF;b^8q`$lW(GgyiYU_PhUNK)%xiw@I{vJu??Ho@%sLg%l z@vLpzIHR7&Y(F15cdUDuYJu5~m4Bki7JuDabCQ#n#!cv&wK}9tP2*L)FYLJPvwid4 znx>P=Omd3`=j~2$^uOA{rR>?SF0GDsGaGgyw@l@l$1`X37fn-M%zL8y{`^MWUR?i8 zH>=Lx+5YCv`8N(*N*(R2JiGUgd(`Vf{I|}@8Qc+mdn2(E>M^=v+lkIq4)GVU`Hq<2 zZJoDq+Sq>XJ6o=QFsgr!>6@P}U!A^Z+{de5Lev~oLwDVs)w;ycpvjGMwkI;HTs9xCdS%qL*VtyYs5yFi zchyzT)Cj9^{3)ed9%jac?qBE9-Pb;T%&_Z|10F8b?EcF}b&p1QBcJcQtv>e8Z$T-} zCARw753T$IP12pa>`(T>c-IJMdCffW!j`v={^vcDw&u-nZFaln-pC8n=j;wYtkSgg z(@}|=i@sz##lMayID|ht#?j*A#)=!^+uH95Nf}kvF{G%lZ09z+Ha`w{+ZO{xZNdY; z)8T(pNju{|dF-0VBT-Il3pa*3p8y`M7t5&9C#m44N|iPDGOu{ekUr{W#q0 zrYft?b#VUFXKqSK$%y=I*~!`upRefmd`{nFwIv~eVcIJigqzzAU(H>T-}H0uSJC%d z`S`APd$Ru8x>}2L_th=Ue;ei+n81KIx3*xo6e%j6T9OhF|$tk&F zHXZu2=bozE^egx5JjSF2H!VpTY2lW4&bCQe?DLYzaVaY5XB{2}{`|?c33xxlR0CbNBs@&z|`1OBf#e*7n|+)2cgj4i?)6c^_3T_VtwAaBc2_s*_GiT(BhABeie~EAH#zmXzgmKE~qrIkNbVxpZDzlYQ0bESMP(x5w13^j`~D7#yeL{>|f(Ly7+)s zuEBHD4sN=7j&>)V-<%zGKiJu4cv#6|-=W1eKW5}qY;ZAY{>XKpy=!imMbGBBgVt!c z<=;^qwlnqd$xB){r~UO;uKPm#;D1`(-+k1kwdrx|56pvD(S(MKC5pXltB zxhmn89(Z9>(Yb$Bc7V5|%5SsHl6p)`ao#hc*~VJOf+thLb5k--`b?jq^OKXu{noB_ zBd5$C;_!4+Ielg zz$tDRJb$)^N%N$#uG(71Q|zaHxc>S0E*;f`7`w0OjZXyDd|p(>H6DoPE-cjeTk zCg*duEw0e@UGuQlfWgIU_YHm5M|J+tk{sV8ZtZ=K>6gZu_@}5O#C3c&uWje?qYHNW z-SPRHc*@Mm?5yS0_>Fm9J5^hyR{D5)_bV8*6N%~uC)sovCg$=L5O~jveZ-Ox}}*`e~BIc z>aU^YpT7RS`F`- uZKCaZw8MZg|)!q+l55(B?ynF76^OZD^7O)q}AH9IbWTc-q5>{Y&!J3N0*05_Vd)URUSld^6glA{9V(N zeLBTGj(G4^ujpFRrAtQU9)b2xYR?(xm-oN+u#4W)=Ov}(1p{9%I=RWzRP{j>egIDI zd-v18{cm;deOWg9?Io+*(VDKiG^WQa-SlYF6#oGOtZr>oa4yc|-hSJsc*Ll4j&=`BJo?$IJh;xWA36KOJZv2u+%^mf`0M@X zEze#Zzc+A@!yns?w;JiboELYm)@(>wPMP^#&AGNe@47QTV(e$t#nTpiiYdx%>>Y9Z zVwdE(r)nMJ$^+h|ackBr3JI-9cOSF$7xSFHswc+z+h{*L)vTw638(r%!q9iSoa~;j z*zUSzorQXsZ+I);n#;+p*Uq_T_vA!#uh@4+J{xJG?LIcFS)s}EpSlIz^236UXj9X9u#M%~mjRz2dAE7< zEZD#Own?|75+nDt~3Ol;tW4D-!OB=!- z?LD`4-rLSYX14vd=iKGvD0)v%Qu@V273y`*i&C(Ze4t z!lv3yST^UA=bdM*U28^lNb+10t-9dHNDP!74?L^3s2S@Xyl7&P*69Atm`BCw@doidzLPe}!ea2Mgvh5JRV&k4eQIpIcUFzf8cuTc*%AJ6ZoU(P zO0Jt4Ego6n7w~-bg&lUyhGcTv&FSGi{)S!lg9o~;@87y~Zr#jps+JbxTg~jTIQiWE zxX%mqN1}i=2cLa;siLd8*)IF!AEy0OFSv3v$2wovIM^@KR`0Ni{iaT(!3o?EYAID; zb#3RymAve_z<-tnr%mW@sUzS0Hn?WWW#gTreSF*Q89M**{lo6})ONP`Jh;vFpib^r z1`UjKOh0M)jyt2#faJ0QgSS=Bq5?kYdzkx;8|@#T_FL@7pk+~yI@HYiSUI3eNyN9V zOVM7Wt9yLuxaZdn=#0NG7(2O)lGjTE^$N7-EO#DJXfo~UK-K1x<9^LmcMFQQu(ay* ze)jMAPyZhcXC2q{7xnRN3>YDd(MSvgL6}G=9it=_RFH9|LpJkJ@=f?d4KM`XOwzvdlrj|#z7YgzP;THSP+SsBeaMaBbh;ENOqJM z5AK5>VCs?z2U89kSHEk=n4Zg8%k_O^0m=pHf95yE8+ogT4EsN zhmKg^i3MJBq^+a@p}_gWJ$H`2_aiEEGU43z3!{e9`OOx9&clf~0Y#7>w=_*YGpgk* zD}8Um05lP>D zda;1mytQKjQ&F|7Z|Zj(o=qDK`d=`bR}c6~kd`nU8B3W0)|~!SiTbjAU+fGT?Wsj% zaB{|Oq&Z39{@9GhQ=n#NR|KI2Xfvv5gw#ux`hGP<45d_}cR}f2MsfdjUrG_fPrahK z*kAZpLKQ|u?n#zB&?Z~=AN*R==2{gc9CJS7XoP?P&}^bhlb7C^B?K58R;48Cx=21Vfy5Q6AB?hH?K~g zPF07Ke*PCEAPtHku`{~xMQ4Jdui7HQhy3y2Dn(zn8lA9|1Q{6^oBdv1TU^@#E1vW; zDM+jEhtm!j=fmVLl7S(h^`pZln?8rYh4nA=No$AzxTVX8kX;4*nxTz`PR48Xsr8l} z-ZN&E0;j>&go%_qi}zlJbkY|l>a| z!B5+!ZYq3NiMQeAS#7j8AZY_X@kxa|TX@^3U{6~$oZkM|MH%-dHM@5y@0TWK6w})IMw`XyveZIx|=JD1ZuM5LH-$Ik)a_@@!@y9_ElAFN#hu#AabnYHJ~nhH6A&LHm-ZE1|ZQD*YgWHuKk# zPl1wbC~Y_i=hVLp2NRn8{-hBh7kb6bp)7}O9-+!g$hX0XUcBXZZbuIl{h3L;frJhW z^j&LBje#|%{0ZPaLZrb!Z>46|wd;R}TR&^jd6_S3&5MIN8JfcKbDP@hztuddnxmTa z!0n><1pMeu%Od3D0we}b{i(#jf+r*M^l*{;H=;87mA~Gmx(>HM5QSH&Wkw zftSL7AyV@q{itQpdhRke(kLe=$nYqUUq?%F^A;PCCK|SpKW?5T`ekT>%;ntm&d)?w z`>nM?e5TV8tnKn9>^l+`e61Q<$@biQDzBqrCmkHU9l9aRr=&Yt(GJ9>w9_4Eb^wek z`y};iQH=b{ILg;ec3m4sNzA?}hS}Mq30ZMEbs=$f@~r;C_fA-MXr33yL&9WeB^0PL zXzh~v|Jqw{8TFVP19QXps;D{NJW#a*8V558?6vHCa$J^LQVK-1Pis*Ikp@XXe*C(ZdA^z0(Sl$R|~oR`4Ic}nD-SaM>@ z2H>QFK5{e9X-(ciZ5 zl%=}*w>D_?WT6D237?}_`}crm4pDq&;(&<7mudS&#A3g%}36^M7;=s5gPGc)%^ck0Gq}1 zXxH1Gt!13>|QO4b!=P|gGR^`5H7N3S{ib%yWP>Cus_zAoHd}R@ ziL$=WQolI@VdUppCZBY(5pGg5jO=BNE#g>2vPDXz_%DYnP#`yux3)ITK$YzoUt~@VX zTESoY=YxEfw~7vAOnjT(F|cc+H$SB_ZT|s(hR?hhr*IT4Dgd!ChYpQE_kKvEGti>d zY+4GY4JLJ<-8j`&8WA{t9|^i2?jHQl$KGG*DWzL`qtCv(5uP7H{XX9Ayh0UVrH>;* zUe^#^8jyXj3;ObAd(*TO4+h}zm5`w5vA$R7(hRn0|E}LMp)Ve2YsVd-@r6;YG3ZEH zoX}9Sw?=z@k5igWX5@40x0GdIX9HP~Eh{ThS3UV*dP(6seA(|a8o|@Trp$(IM+Xjj zgSb5u9iSILZ^*K`hSllBxObu9;5M|da5efXfrl~p-wf5?5WCvKr|-Tcqj~voJX|5? zNUVwCql=5I>{hV!)=_8ib49&b!K++-*_&H8hucgQb-*BtlGrKW^HQW4jX|+#lrqX$DI}ND(o&#kf_5bkS|zr^2rI;4TMntqAFT-TD+$m* zd81Quzx(=JRalBNMJ?n0`%>xrTptjyqAk}$yghsf3 z?EMI-!TPMZrSseG%3r+WZ>tn$r`K|`l2F%)E1aHG_L)V~%Sz$x7_$>`wyUCFPw}rn z&9>_`YVvVLB$i~>s|SOgeKa>$S8P(jN_2mCOV)Z6h=qx%+#jL|JUn?+m^Vp8IT zsNJUX@dn(k2s$_BXbP{8bo7Sk*R{9PY_OfYwx>@Qd$ z2w*v4Kc8*<>Rc2`TqL*^rXTXU_saNk&K%^j*RmDiu@o5z->T~5droH?;;bPJLgzFwxjC8azfuA@ll135S6;E~>HA1S z1aXn~>0t;{+sA`;W6TikhMspIZg_{}g$UtacaFbr9=B#rE&jBiwc~H1#cuhY@+yhX zC<6DU;Edq$B`co;YlTK9ZX8_s=LH=#o6* zgK_rewVZU61EDXKQ#%GM7Nt(MxEJP~QrC4;D1(w~5A;S})3v_m_&k9fE_frPYDbX( z#;B6EPDii!9WQ;=^Urtw(2fe*`QZmdkc;J)=Gx?`M~v@0ar4Jdx+%8U347~0&a#l8 znuYRHQQ+#VIbKE(R~-uw{>-znGA{rKoon0UC~XmU5!OQ#nj84zFfPN&)QCh+zQbu$$ev6Qm)Jx* z5{`Z)7U}!VW-aVZ?9b5zkDoP}sxWu}e9N&mj>-%GurO!y|6ryXaJbztwBDb5xA0g) z;8aRX$1e4szlV1Vmf`4Y)(ll;CVJw_h`oB!-KN7UHuvvj4M@64gmTk%gWHd)%C+p)$sWsT(;v88B$V^x%~+?!ditGfbo4=(o`t0fXIyVh6~1HgSjSFUILYgl0Wmww#4L@lcNP0k{p zl}gC0B#O9S8ia^+=^$n@wdajN|M30&RX!9*C6836V$tA?dkzQ66>}8C0IJIlH@-n~ zb1QMkf-;^wG2fd_sJhSAW2LBb{ml)OggOiLT1r}HfBb z3)zbEQAgCP#SJDu?C$u&|2lKvxa?uNHh>~1uT(#3RQMv(X@l@~o4PF8SBMD1C+`-k zfX-R1n_F2WRWI+JmH48E#4?H*FzM6p*Ib6?UA%nyqB~PYai5Q7uCzO+^j5XXN~6H+ z+A|OwU0qz_4r1~MV+KGXUIX#SUUI2(;C=JMXNIrcNP)hl1JK^q2uWJXeNxu4(|;cB zt4Cfho%aJ?@pNGML)rTR3YAb$VC#|;kKQq+i)yC5TtLy+ZOKmlIc-*j)rFZJ?lWC08 zVSb&&C#om3XgRI@&^|ek}%#3R}xs z-3jTjG!avDM1Efh6^~?$2)N!r9Az%7EF&9~TQSH*R%#Hew=#P>S(?%0Y{m1B-{#Ad zJ}er`fAoz#t+p6nzc(#2iomuakc1=8`A=Oc-O`_i{Fj=EGFR`l?f&q6y&-OCwv{-* zP{r)|u7Ps+$=!tmdo>}am5hwT0E!Wv+9P5F$j+!1YF@}hn`B)rlPEP`!WsxD>j(}z zBgJ0^EPJ^ao<`1HaSTeZ71@U0le-JZ*R%lM)Dx&3@R^x}>}Oyc>5$KEY1X6%cGu!I zYG3(Q;I;LvKkCyn{ps3)N6acTlmMqqc4~F4Nb%32zQ&LY4{li^ck@*2=x_yGD0jxh zN(D2zB7j6xF;Bqo9k`NLL&TQL2al7*d~b$vJ7;$&p_4_8Oio&!Q2t^(dUkWDRkIP_ zpgtlcNx!vC8274Lt^Ot)m`1eK0thw!x&<#;8TLaV|ZMDDy65q z8SwqeP&DAh+{vOh?0({<%8RY*U$azA>!w9+rzG^JQd2d|x~ukmjJ{*Fv2&^{z@7?n zIppWtL~eQdI67h}?cPtnsAJzbJv?PpeB}3ifYoXjGR zotD*KeCPCrstm;WGe}YZFEC7TBR0~S>u%ocDn9+7wb?diS0_6&Yl+@YmvFxkE6V^_ zK&QWDU&?CmYtK=Za>BZxa}YRy=Y<5Q&NICdZdQzBC(x)C%2~J-+gK1(*}F|AHalbH zyPHV5JQM4$E;fWAGYjb{dFnvYU24*xU_7kQ=Q7S5Q^1bw1-aAFM=3XHKeJo%v4TZ? z8$R(jzB{`Xvdj0v$r$6$_E8vf2m3I7xw@BX7 zrqHYDMZ`s2u}Q12Pr7*3=R$@@`Z!|M1V$J0Vb@)cZnGYbDe#X1!fYk%D0uNy|2WeM zUKuwAS!IbjVPov~?Co{cVd^9OYo^qYkl?N8&Olmi_K^gcx8IdDdZTzaN%7=po;ec_ z+LCJOR+}tr8t(#3ls~3x5zI1ayX2?X{Py_TU%t71=I6M-<_xG zU%-|Wq74H>4TwIoU&oxXplh~IF3d>F#|u{k<_yB=2PzT(kA0e%I8sk;g7H-zVS+WD z;33UO|KI!zYeV|gkM;RFS8roT!si|MgKX*(Y?xQvqBqi7?S2?@s78X}0%1EE1(m8# zfLbox7YjGZ^AG+jdMe#gUj0rQH!0n5rM%R3XG$P@?d%BsyxLHA5Y3#zPlr{W{E}N+ zH#ztbt*%T;nvCp!?t+X8ArI>LOWFJPW{lXUr@qte<}ZdT-+a5A(9Vrx`x?gK7GSQb zsc9%^o+jvz+1jP&5B37h2Ur29!y0Wa=S44D)~8T+x_~t2fHvY9np?xL_<0Hcbhd0C7IXI5c|Q;-(*Ft@!#=m zS=K)0KmPrXDmnyg-#x^{2`Yo0*fjLYXMg`@-H|LzCtxvcc>4pFyUa*fgNn&KY33IZ zQsg0>oBdJAPbF0t(wR}!o)F?jhlkunlozen)!xplkBB+d)$U@%>G-IdtLiQ&Z&$x0 zEKSk+cWtH*IJJ`*suuKFQN<+gF@&8W24L^>22!HTj54EF@ddveY^w42q(_k-69{#U*~wp#>iBN(kokAoatWKn~trZm1h#lI3tuJIktk<`dP}(;9TpgsGDS zEDIFes~KruM5~L9pw1dw?sK51zQ`%`*MXt;0I1-UFbzH}N>0>Hf;u1zH_(1pD(f?EE4`9GG;T3ZN2-a!#~4GQrOY1nx~1N?14{T`%;SO6ijj z{a{T%e1_9)78}!Ia|sm{R=HKt{R$=5(M%RRGH$>r_p^DQi0rhqmQc6xAHVCEKE)!e zs~`4)1$Fq8B>ipKoBGmR{KL(H(Me3P`ZvFa+djX{Y`iJhWzVY!uO+*Q-a+c?t9b@+ zH(D!AR`*Meh<9|tJLWV!r(O#=jBGIu&}^)E@tyWwhAWO9~4$#^v_b^C<*jX zXIOt_C!bMS_v==W{vRGCk-b20rhZ}=I9X2}2Q5TI5kF1!k!HuM1oIi$#glMvhQShweUov zbuJ0*#B+nWFcw1CgQ#^PJ_XPQof?5|9R?pqh*y>bM#=y-+&W9|oWHd}B?`$0y?dOZ zEzo{3*%cs2aiw`*1_4Y?jXMtDL^sfQL;v+l6b0@ZFNQ+#J19nB&DV3f5{(DhV*wn` z3YT~$d`JIfi#Nu!*c0OZq(T#_F#0yMjZkG|e3XaD4L>%azMN@>SW>moJYN4E6I!!4 z`N6fms(y_3pv-fLiuMz)5O2u8AO zvv01h>cFC{GY%qHD(oxcieGGxYU<|iqRv|wtCYzHzH$NL4bQTo4ojcnN-pscCmBaJ zLMcA_LozD?iK zaHo1YRK9+^>+p>SSA#?SAz#+QXu(SC%a;QElTU!UO<+!$G6d&`%-OKeR2iTREm1PQ zRBcEK7yt$~#Ie5Ha(f3Dp`q^eep?4kDzac21V}zd2PS)MP&>X&+d0Us0W?kdV0z4x zC%tunMJ1qk?=!H1pyCvuKN?_1cOO#Wut|_(DHZLGRY>m*H8}44dB0q+f=CF$szc{G zYRIVPZ1ur+>qMBNz3FwZxTZCwZF?*Gg(>=h@rs~#7mLk~)Ma6dv{}_O7>J?@^M6Th z`-~hHP{x*gV6>5+J9@p;elP5Ede}PuJ=C4wjVOc0;z1s1W*lNkf*1Vj3Al9ymZR^J zCB1uKa2l!oZm~87SChAnla(`1@B2O;SK^a78<-rnniJ_CCRnuUv!_6<4trBGd668j z0YK}JMGlaNeb9!@Wr9m(Wq7+Q+~JfxZ><=Ij%AJadF6V&ojWS_o+)(x&9R8UWB$x? zFJZ~y{>kbh;Q0B)k4QK zW$?}?LLRjbhJ$h+3!Kcg2iEvRg~A+T<%cBca0w24&_jPJS+pJu!uRV*e;knSvZp4Q zXg=6d%fpZKr09C4O`Rr}*x!KcQz+xm$>Y3$gab_^$Pxn-&vOt+$!C7~+wT>9NL8?~ zGHFauHLmj27Jg)ztH#JbdnQk!K~p z+{d-{wnn$yJMFEY?j7I>u^4(;_~JN%Q!)bh*zwIzOu4NJ939zX>csM8luy7^Z```p zgYbpl)zM5dKBt5>wzZ&6!A@f|;DcjACg|=7kmZGZ5;Kroz3%cn?JBvfICqAQ14ouh0*UgRw|Wtbr%Ko|=#f9U&t$HZu}Iuzx~1Dq`Q!u=9dY z0tc6?#^uF>O4z7IWt~Dzyd6T~N#a$$P)WL63z;;cCBfXHEt?UDEt_6~*dZC;qmta` z3!7$TpTVKQ9iMZ*Fd96YZ}*Dk+nPy`#1ncyCi#`(4YXzUjBz)*qyF--iPu{YM*J4zp(!hV8H9kU}F% zp(X06tP}=))fGjVh0zD{>PMOhwh80pP<0SZy%0^}J9Y(iyKRFc<`#gDJB}Lc2U$Sy z3&EW|6n{(l&Xms+UDI# z6(v}i$um?9uZ4qF%~TwO#ITp4F+<)A^~`#1F^nHuj9)h zMNHs8#_#kL)EVw63LvF-%u@-RQ-^6udSOSrelQul380t3Oa3S)Ny(<%@g3k zIp&Dl`lj3->_!JjsKiYuvpGlWesk=;u8Fl0_Nz%ELV9qm`drZF7Si} zM;v-V_Bt@^^NTTT(U;fRGuMhBK&?MtQj1oxLj6IsIiU@Lul2F%8 z*~RiNGSeX(Q)uxE%j9^k*;%f(I#?F4gZSD81^7Y1gQiFH_I2^2YF#pxxQe*MKkiz; ztbe?^65BfDdQI;^=xD1#RiC3VQ3jF3(V)ON-}{KKA4f}BY}7p>WGA_WEj^pa0wgss z-pOszB-0uDpaz4JNmPb(5))G*ZVll6{!~qN{K=b->t-;8DGFS!Rn(!rL5;DDNv(0# zjI6+Xj0rpJxNZ9fsr`qT=Ym`|5ER7pAiFd}tbisW z*BWL#W8)LMXW|N1ffwR(HHUp~0#KJ-qJ2?~(Z~W0ES|a@I_)TqsK4P({|&ZjU>Fs~ z_C@jR+vnP~Lw8MBblprRU~nk*0~O=yoR8YPhvGhdH!~7KMKJ2Q?*TE}?320mV!(3Q zm)Z~7y`pqyUk)Aepx@#D3-Fk)TR9`Fi3mJs#y;pHr;p#`z+00Z<*it^1p**};^I*o zvJ5Hyc@h8*dssxV_Ep6qtaeP6a+jTf6ALf+Ofv+muwvM_@?P#I2yaBM8`>`e3g>R( z*I{|sycvE%$U{yZb<;@7dMy5wJ?AZMwP-!J?a82g|L{m)Wf`=ihTHMRs&n`!+42h0 zhMqV1ky&j&reB%mvwsUCAe^WvcU)oqF2n8o;6?avhRDJefFP`{scK+W2`?NeS7MGX zw2;xs3+$s$(Wqar# zgywD!Z8yY@-v9-Of}Ho2D3n-SINMleEtQ!wy48Bi$yAh77d3DjyTQkH+fL-2p>psL zU=MSK4zt?px0LnFEjn56f!7ifCM5#kL#%tBDhf17sO=}b!D;;${QrnSBQYd|qKAOR zEd7)c1%t$H@&jfO$4$K^HO&y$mfH(G+06mX`%fpe=s;XDp)g~I&=PVw#V=t{d>?C~ z;&HQq)`^xc^`?(L?@m9fWbNr>(NrPV?KFr<1poX-ZTzEW$7yiJ%eN%kIHifGVC(CI zCLS!KAi5}|iH8Sg#svnj>Gf*<`alZ098L0Av8mP~Q@rs(A1buMCN-25xLqIa%3cRu zojBMYSqka^pzdO+)fOYQS5GrOj1Xjy4PY$WA2Sk8K0%UgcFhmC`N6(mW5uAqH=!nh z-^vl3|J9qAKgxROHuEDCg}QNu{(AaXeL`(WmVSf|uV<@rD09t7mpuj@&3_gAb{fH% z$e|qq=BWi+Imn~Uek%L36no&I0|VYkgJ`m1Pak0|m=smvV3a{47Yl@*ArfLpLv9`} z+n?N}M|IJ;wEQ8n8fGz{AcmpRqkhcUaHLa!0dqk<+dQV}9_fpIBmd+wv~?32-qyJ7 zxlZ-0}?Bj0>QR1PqxslrK*IcD>bidXemtAX8jmo*e zb{14k2YL&iDIQ_#Q0_mG<+|7@^z;Er7Q`X(vJ7LtKFtbEfBVd@^XY8v+ixCf5&)OM z&$Wf>vIk0J0*XQ4KH-lxk8>b1C3rBXlUnKEXf;_$QHknO{GQB+J~OpXByb}JdF8fg zxs|PI$k>+60{#5sY)H71oP%HF#I`LMK3+_k$cD!b&^P=4DP@!V}-mk&|;l z&u)>45$UBd`?z>p$L_jnIzqZKP71LAQbT}=c4Ad5hTrUsD^X!-*eCt{}1srB9VHn(P3mUBL z?`W3&o(0>Uu8$%k`@SpL1=dvB1Lq z8v;+YZ4%4C#7^SHHz^Ib+C297-@!k~TBxFb8YEa(;&UlQt18Ab)p}AF-`zafCNx~<6?lg9tub{`(z;XopZbE z6Ces2kp5+=_ru1LK2-}n!{!Ypjs?Y^6=^`@+gfs<-xWAI2=VW-plnEcfMRZ8tome8 zIESEGQK9!UTWWOl$+myx>&XO5JMHsL%YGrI{LBivZrQ*@c;p^OJ3gX&z%ix zxc@y*w1B&xl(v+=*Ujd|G2>p1?P~y+G*p8MO17gEQp}7W1!V8wlj1QK8MkCp*U8l$ zJ0jubtOKx7Yx2&mj~4$D@l?N zT3^8x706>nJl)D#6f@#EXV3JxlzMOOt9vEhfmS%Lp}t&(+(+yg6F`~5vDbuk1Br73 zw@D-r0~}A+)}=`C4%1sk(mdYH{U$Yc2|bJS01b;q1s?b?bW~(LYbz>wowef&*2*0M z6f`9N?=(sE*>2XqX3s`GZ{V81M;V>jF4hvULzuVooPFa?2$)8K5D0w}XY1_u(b&nL7wOpdt6TQ1Ri-uVyh76fM$<&0GggA2IC0i9nWBM!m?(ob0;4z6$upTU9YwiHZ z#cVi)yQM;S6H%ZjLy3Rc_cP7dFl$nfXeEh=0Bs1gxJm2`QN1Hbxxxj zkMTLX$aJ3SzN(Fu1rgf+kRA@R*}cl}$D+F1Gef;mif0BMc1m_f8SRLBlctU5O3Z&X zRhBk=$*wjnK$O+jRx$G1HUE<3dB3F9c$fLy!RfQuN;w}@*od&=M~dIIv2%q(v8^mK z_T=3#IwU3=beY(vO-g@&OBCvI?v)%~__@avRE2Q$0pnk&1Kx{0%umxJtCn(|$0PbE zKPhbW9Xl3Fg2t$S4*<&|aV;DJ;##)M1z`Zdbf7q|e}40&;(*V?^97HJeZmrvtg%>t z;}EE7_&fi!Je42OqgKsoh5L_wE2pL~6{r2IN@H6V7qXH`0Y!u&VCH|H_kGTIMLFRl zk;k&2QfouPIhm*tX$j;c#e-a_CNhjHWQ7#{v}~$0J`VSNr*(g#V?t__H}A>&;A);s zW0qc0;f_(ciiH0r;fq#V)2X_*qI@DRzm-z>&L;OGJqdhxDWyiP;fZ+YUe~Z z;PvI6gJU#+S-vS0f!hL+*S|JGXkj5GcKZ15-Q}BzE$-ZB=4zNu8@Aj`b={UpfBfnn zPU<9A9wi}Bf8X&*kKI9(HTtRSSLgJa`hZNb` z;2fgpyxs(+2@qTBo-i)=-SuM)A%{exuvOW3G!Y@nc9%lwuM2UI^FM&6xZ5M0F>-lE zGdo7*Y5ZFITfqtqJW485Ouf(Xv{5#gKin|{-(ODrb;X#3eNz!)REOhU+Vb&o0te)U zF8eR9q-WeXDxtm}|A2HfN_spyL`ek}z6klBKRv$=CJF-4a4?-^MvW6k1IGtQZf<{) zzlraa*lc%XYrQSW`6bm}Id`P~8yHW{)7a77Bh~iS*ByZ2K+#@RU>hPStCM7m zLf@K_N(tAnGKlvs@ys-8Vm?mkKJ|W8ksF|U0SvA${vNv`EW0udS`%h1}Qd*gbLy)_Ty79_&7s-xmxw%ZRk4g<~A3Z z8VO?v&ZiR+KZ(n&ntruqD{hztGVaBY%se@|^${x>W6pru1-U+oRP#5oxlTqkbh_RD3rLY~373`LqVea_ z&jP^#4fz9cY;|ys7uM_t8WVP9hyOg1eKz4P_XYVM+b8-lfOQMjub3z0Y%H-)SdT{g zDOP(3i{+8Y$@zpE$KZ5A9|lggUHv!5lOtSN-*B-K`nuC~IE788ZDwDy3EBA|(2AUzaU4`Wz z4YD$w%W>XhuX8~Jy+E+%hFavfXOb07 zc1=9CF+}ZB$bFo8&M7n90I0)L_nfO8cy2$Pyt1Px{u$04=9<}V|K4PYElGt)jOet@Sp7o3{#JjE zt&TUx&O&fW@#msbe^>}94^uh5g9GKKYM^>egdbm?#V0|4vsNSJb{cVU^1DyjOL<%L zh3R-x^E2}H00hu&U@B5lm2g68-L`uIEBg|Q>2;M$t0v^MTQX@0H*NX)!(}d zSWKag2KmcR?{V|TKgy3@11Z27?gw7?+9?ODYx4vx0}d}*=&4(mhUg%TL@Sa#yP==0 z@BpI|z>K1I&qQfQdSLjU=WeqQii#SSTi^|!OY76}ar)fvctB4E310d(cIQQc5`}i) z4;^w%ZTcw5yzrNc{IDKusj;V^9qk z7p&{_pKFAYLMaJ9$-!CXPNcVNn`ghj(VQrEr1sAz4iz z2)ymGlMVPKK(R8m<|Q7aabTqd4p01@_FE2R>~$eA=|n9L$7s+aV^^x8By!*QHgPBX zp~!t&%Kmn>K(M2zZx45v7Gpdd7@VuBnO3rNa+4U?DtwXY-2TY?e#r_!CpPfpSgq^$ zPN61Z^KgAcSNB{e{pkKPMLS254Z3;;FGA+K7Ca3 z%S3jrk+FL95{^S*ptN+1h9V&lY)B@G75E~T3w+{vxliF2D09hM`QLQG(0czNr@k*g ze|K(dI~Shkxk=uKcuxkYdguS{<7%{H=!_T3dkGz?m|ND@T|m(m+9r2@K6Yo=bHpFa zxHltS(3m&#F}Dg>B`7bpT0Prg*ns_1vtkDXRCyU zKUPKrj(J4sSw;z#WU;v=eS7dJ0jP{idg}_6jyq+|sPUQ6oR0*-xBOz@iH=S{^NjS& z=OcRMTe_aY?L$L+(8#hu_`w_jg|r0|k7(*zH=7>fg1ypLg7f=qlBIS?6iX&HGr$0n z!Qwk1b;=Ad5&0TJ9v@ILSHKGF)${`xJCgl;hil& zkX`~!<4W@NVD+u?V4aS#7C`OrwQnxUJMvow)Z1Y-oKTM3Es6UTZ)_fP)TlINI%hA;_C~L2Rz2J1lytoRb%IFd>`)Ro zw5(!et+7_FR^5q3?F*B?>*EtWj$ozhjj3U@_K_EQ3<=@|b-P;yJ3AK(wk~v(Tpx5x zo3F2L3U)3Ev+4_@GF{FQ=D+&+q#yBr+I{d>Yvc7y zopOba!K1-EzfW{l=z{zQ53zV@D2e<>L9Tv^`w!8kVWPw;XN(H!@PIQygJAP%&Z9rv zy=E)NiYNZASDhyo-tt4q!h(>cU-ot;muWsh(Iq8Jf2#?8 z&T|e{b}pO7q6M1snc>+9mekl|gv+kD#r6N%W<)QTW}6;upOG8;i`k2Y!Y|fpnzgAb z!o50EOwIrO4LQ0wkZG`|bF|W(;rQ|r``RmDl{ES0@JrcUqj-_VSH{)+5J-V+!NPx_@uG z_T@2ix1R^+^W@+EP%{*zDPZqfZbe;c=u=S*EV7r$1|G`M zYR}YkOwnItML7@%m^ z^Dm1hzCs<^?iHNfAUQ4qPy_Ah{VtltE@90?6YRR za36eHSM&6GX9Hy#K3lI#5HFj&HYHu2p~}pE_R_2->$ee6@o&MGHP%uBqd+`R%_p<3 zOdQU7Plp!N7=0x_9_SXzwM-;R&(;KekO%1m#(>G1*LX^Gu%dy*{&ee$x}rJxT*g)u zWo!u~P;k97qsN(=_V^)j1kM-%W%Pm*rC*xxpOq*Bi9sSu=OXp^QvK}L>nNMV(?yMr z>eaHzQiPNMcSwj!GXa0o@#J+WMZd4V-AYYJdak!VK#}R&JZ%ueTq27k4@9 zId{No^0sq%9r)f(GS>k-OrtLioXXTT%hh$P5LE3mc{ZN@Idqw7x~i%j!Ahv#N-~op;lZ~;_9*8gx?eVt;fBAo5ou-KsGo&6UECE2w`F*w zCb2q^PR3-n6(_?10|MW0z!E6JpG1VF=Oy6gmW*%6-O#!*6P5LM@252O2`*igqn~Lt zW!kZ!miu>j22nE=063Zn`v`}XZ#$O;ffxCKc@e}40e&DAWePA~_4VSvnH0>`*|(d> zE;+6@#vh%$s96|1M*}A868Dh6pV_)F;*NzNcTgJJCwh zdtZ+nmbSb!r#D_~sW5^f`NEATe;wCJ--0D8G0neQ_Ds@kSvi|wHov8>F07pihe1Zq z>$A^Wha5#{T$C)CmgEr2vHaZ0V3>_TC7tslRtVd4<`_c%#*!y5rb&jksv~hB2J^Lu z@{y4Pnh(d#Az~DZq|z;o+8i!`Do{{IVr?5=FOp^^CBC1Wr_S)74||HA*=Jopfpxlm z+T{xA=Uh`Pjzmw!R=4f>gdd5D@ZO0@_l|P!)i}@oxmw3lGG65G%d?Z)6UA)^CY*LA zbLn3C6<=@ce-2v7h36sI>MnB?p0cs5z(3Cneo!{WSaHP0EX6cqdWfq^e{20l&X*R* zlUXf1UKlN(Dq`-34ewOLJA=F}kjnX~W!V&fd`MTOFxW?V-80FPMjbG{k$bfix2~x( z%knGhndq2Do<>7E!qDdhP=BmihzzOx=pb@WQSDJ9x?Nn3)@x@jZZ~>?k0(ctTwlGN zaU==DICJ+zPv+&^&wtO7zc!S;6{smNI~(-OXcEcD>p(b84`&u-N@OVdTFW*boEHD> zozE=sz(=PjVaRP4DX*%!|2ozH*X>d&RJkA~fHs?#3ENYLLK^-n)Jr2@#>Tv;`w_Kx zv0>4~24`*4wm5J)|Aa2Gg6v)VMk+vPqvK8o&Ku`35;t!1lIX<6$%*Smyl~AzN2$|8 zO?qelurgzqC$eTfQ(C%|Yo!!qmle$IS3BhNBRWtg@8>g%rV#Z^BIn-mfP9L4Hpe%H z^qIOy|L0UX_irY@+k)M>lgCaDK(g`Q^#)Jv5r45*(m@F_B6(tBL<&O8un#us0UK7!A)ejajV5~Wu>;bZ1m`_tU)z1X!6fSJwP_z#3N)b1v`Z3<{u#4%GqwAfN?$r zHOb8$kdw&y#^A~MRQ{WGc~~c{JgkrgiGV#W$U+*T;Ub6S5-kVqM=5?`&bm;7AtklN z6Km03ECHzNylo{$a?wLl;3cK2u~?0TQPz4&|BE91$N2)0yxRSxW`G?6^^Margr zP853?^8^54ocCb}QgC{ZJB0LJXBw`mz{+tjx0-q4C4xmTQayWD0)oIOsxl%YF%^Fq zhTl93i&`JKoW=-0L8W4&%O0Tp?J_PtXX967fhVUWyy+t13u_M(Z;l}v@qy81|Ke`B z#RI$te}8R=fC_;QXIM}Ix0+!j&(2Qjiy0|YleCPMws$3%J5;hwtMn=fgf~`)9&Lxu1BWS zOfRX6{UJcH5t-Y(u|2M1h%wa0ma*yE;bJ1Xkd*%zt>h)*bF+SB*dIFRIp$nbxM2Wd zO2wOaT1YtF4$Bq*pdS_SDAIiUh4Y~>NajW#i zlph#}?-17(_PS;#+m@n)lvK~~Sqe7ban~~n$RCH{B-fsCkP`Qirl_B0Uf5<2^TDSe z`V)cDC(uDrqq_$JVP*{qB>*`QUE~dJEVu-8W9M5$`o$y{bh6LFckJ3o1lUDyd^=0Z zW@`JM9_W4`p%DQpEc)&Ij$dKmc}LbRSjYjyqVV_JKN3$r%y)Qa#)< zEAx;E+|Knm29E5Ykt;_EU;QnSymxn!3Htm*+jOrt2#A0Ck5&idJk@}CHw_&-|IBi| zqmiU}|4Lx%Eb&d}lRBWW=Hv))DtP&)vij<(P9uzppiFk#2&NdA?wPj*1FG3g>EV9* zB0I5Nviwk-UG0$w6?WCyuh;n+*1lmo>IHG#sE8W=G(uLZ)BmZdk|Fjd0wY&aO_8Fe3;KIemzB{&cmp4?sha5~?r=)7O)( zhE@%#Q<^xpnR5Hm%0AT#XkpzsROgSOD;u;(5tMB8R+?!yo(88+LG4we*%E8cVUZ0o zH{UF86=DSgmn(=r5Y3Ut-yzAtaNJaRdR%6?pu7t)wFv?FyRU(I=5(nlcnjPm?Q8s@ zWd6-N#@SbU8+8GH9+9Gg)Ip=Y6|mPEExBb?+Vs+OQqNbR z&T0}m>eLDjb0ma=mr1uzWEVNG%T9B`k^g20aNLtTWGZa3iq)wzambjQ3K$O9-H*P( zn^s7cy_G=|*>9b&5?&mitZ%g)gVv^Ber!86KvXJI zN&{5Rhq+|Vw4=~p?TSSra8p`4?YF-7_PT608Qy<--??1NdB(sGpf~;vVeij zB(%I*%+vbz_0(|xvaebXK7otGSUO>i{sNPYS)LZM>qx4>nB@0LZ!Yf`S9x`6rwpO2 z^Sq(2Dd=xo%ADltVj1%{HvY5(cC;L2X)=8d-Gvel>2?!e(`UA;hg@i+S!g6!_}1xR zBl(E7tTb0g6+=^@yxGbxSQv_dY}-)e{E(e&QIe z_b8BvXxd&*hQo3aWYh_vSF5ueePjzZbw~YP*!d=i42NbKlFz{YUoS;wm>x1;j}g{f zPu(zd7z{F@QEye7X{K1|h9RuQTf_BGhu>(#(uwkS&!kqb4h9CNrJ}p#Ti$sZm9V_= zC{}~GV2W28^;%JCIqIOj+|KLhXytaTyY9R#IT6nT)ktpIz4%#mhZ+oQuWce{jRg8unM_=Ph7KhC&@AiM9CqKUiN6(+a z?ErE82z`q~`WD^1Fb_wv{m}C-umnNU*PZ5Jd0=1b^kXQ_|4|w`b|%|%kIAUE<^knr z@qlY$xliO*-7*9$|7cr*+(f&deB7(rX<-C`0~`hClbk69Yqph)co6A*~*wupO!g5T}02o5G`lK(fV^ zk435A{GR72$oJU3KG=1Z=})k^F4p!gv7(VzYhToZR0nk&zrziRf=fj%PXDr+Isohk)7GbZk0)LJmHw`VbvQ8`zkZ zZrY2l=D?h!Au+0PWntj^t_bVmVAfWeu6p$cY;s+*Xj1R!hC6(bhUGToz%hBQ@dlRy z?Czr)_OJzdgMm-?cDiq;*naKPMe-Q2CiDy-SWZ#^;!Z0%7B55%jrMoIa?ct)3Bu;t zpX=ROgK&kTji{4?nUYh}P6Ra}=MJ7l&gjFBcGgR_wRMpvJZE5z8Pcv7@>RID{F$E) z=-Xxn?k=T?9w@aBiJK_5XdtO)@;0{d|5Ws9?0|%aoXP#xQ&*MYz?GK%3Ei-cVc^wa z>0F_7}YIkH%|R1$y6%(FDp62=s_1nzaa6@@A29C0c-SGM|-hwvQcLocpYHfBWT35rS0zYx?)z>3ZOqUS${MA ziSAxfWrHqEZ*aDeGA&8v^LB|xF-G+4{h7_(4mlvs;6Nn8f&!!p&z^o ztzT~x)hO4nspuh<(0U+!ty#>PA8zW2CvORV4c8n=kdGd-_~K5oz3$DRvaVBXXlv8? zD;S|Gi)``?^rGnm4q(ayHdIHZhmMQQDv8fO&i94Gk=51Brtj3WQC;58{-+9`Z=m4& zG@Eg1a?md6NbADE>BidNF9mW0RVU^YK>iqb>+t!1GYf4!6D?sLe#|Z;!r>5u!}%gv zSD-45<#}&YHh31pJRFgVjU(t|0#cCBr7+%*S>E{iRcf>6>#tOkvB~{tj?cZl07x~6 z_r+o!5Dp=VK1{Z4rk3HLB6-+X4Da0tyOvT4`qzL8AN9@$xV-+6$gTSj^|o2=^D=n+ z>u(-^PQr2!Gokb%wNLaMW(tOaCl&lSCR)y!4~#xMdCE!Waj{4#dD#kdG8^y$^|p=x z*P7HIuezRbK+@0kzfXjz!@l0_5vk&e=r(b2<#(9e%l1|OlUxx;tz6gtKoE#AD(XeF zjuxYLSBUB1PRPHD5u1?8wA*nup>ndS27=AaajqrdQ8UyXau{ag zmPoNlBW?T0<8_NN%u&#vk9Tb*)vXNgtDUO*Zr9ho-T&>0DoX3kD1k4!7zkR&RY+KS zSkR1VqE5pR2dBWOJwJyj)S6j=x0n-OIYX>2@L^(?{-W87T&@G#0MCi=?1||3I!!q! zw$!xy`hW7-!^tR-*@pR@iIqM%^$Kq(=X0Ly@Ba}OgT4Qt>ARy!^>UtK+64B7-XMxzX6-bh|~a~Ca{9*P`${4@Zm1*4s*&g8W~r$X?haUFNPrzkvD>F zY)ng!+rGNN*=bCu52*rvmkNIa@wgaF$_@F=-4$q)xj-iN;8XY-98g2God-PC>l?t2 zy-7wy%SfD#S#g^`m`Z}v$bs_dLSx>dYMziTjoP&RV`ZiMv$~I-jGe>j zx2(kv(!AkYbd-+fOC&nDiG%1R4}s*v79Mr)t|+Y0e9#y*mO4R>yKa*bRnI!*XZdJ=9ibo8~YMhpc5;M3Qm&Sc`qR}(Ofy$!7bhFpz?!_njO+M;+ zGSU1rVu0gxo6~Ehl#It(6)v1&h%dw9`bE{qE)}+zpEyxnl-GWT%tI)m7^f`gAO^7LFcF6 zmF-WAEG;1^^d6frcl8xe;ya7;Y}k#=JreyvCaX;Hb+HvAX)9mJV+3KQ(jNf;PZgB= z97QY+WS?{7N3NK8s>Dp0+mQBZm|7LI&OYtAaw`A6XR?v9h|QI@GTRY#m4pI9u*x)w zD{a-n9Mxjd4;|fHBdi75g|y|t4{;^7?c^AX8A;}qbS@-1muuW;WqRlRZzl5)Nt=e^ zrZ5N65t+Fzv!*f4mp^`d!8hpKkPFAqnv_KMX9Yiga-#c1R&=^A?|-{N%^eaKM;rct zM?0?~B^~<>#^jyCnSaK6QUDV+qqHli`9Zy}fhkf}i_mUt!N-{ zOQgGkAk*lF_l|=Mgk{g9u!%g_*flJVcYo}k<4^1RFmzVD!vSN(gOp=cGm}SFKtyib zntLL}VY$7mWHu`(#?VKVv?POJDdtN0&^+mEXQd$~JD{kVvwlRV#zLDX$kcKT$fwYW z{!BR#>wXB8m&_dz8PALY-jCwQQP|^&XE?REJ=uyr(k&HUx+I%r#cLX{bVk#K1NcSq zb==Yu-~J9dABI}xv8f zq>kU#P#>i;09qza*T1r&?wx2uCF?|oUK+ow<4w`-W$=>T(_qk^J%aCtLdV09r_&E4t1Ygc}`CZ2Uaoh1s5-_SSo*Ce&P1ktGqmh#u3o9tnz4TSs=)42g zF;d*wWk!rwUvAK+RtDM4=4O2d0!i9;2M+g!Q+~RPKp5S$ zpcwjbw2t5jW3_ucHVdNE+{CvS(Ex9@kOe?Ng-MFD174Kz!=ptr&+mHWt#nYkodjPe zLDG3XnL4`sn61C2d2Ybv2Su<>XY3@l#JI}iq)?-;j*&uh+;&c}(xmeXP4=!|KBp)2 zJPNtv_a4Eao=}mWpb`AB=8D;;q{VpbuE4@ide?JJ z&iF>+?sNMA?3!EmEH9REdpH`ZdsNfMvTM%09xt>>rTK8ire)b#l+6l3PEzD5Pr}kQ zTBo)zASEiAVUJqF)5~lc45rt0dp^JG?i2sIl+F4|pRHwbG9nCx#5;!gk!;SDM?pAW zUFh&nnfmmbTO!-OVUKx-K{sWgoA|a4)&0XD^XMy2GYxs(D$R~|OrNKwN>3!(@u^&D z=?PhmjY|hz`f`&zLon1OkzF5)74l9(eajTY+pf627Y*rC#)^D{nodXpy_B;2v(s#@ zu^DF5Qy?MRBc^;{U9xcEXs=@P$x{933D3SXKk38G3m^ zNi7V%BUj6C-+AzU534o*F!rpx9K(ensOz*XUYMP#2Y46@h%K<>o5}V-I%Dz&x0ivn zSuLMY@2VNy!wi?F7GLKOpJUgT_toIhpN;6fjp_F`LVylfRd-hl0f0Cnl`i9vgrw?? z;yT7)Pw0_(FA}Xx>NQ%boeeJNjV4CXeYBB^u9>X)x3O7jVbn<1Pdq-B`RS~rTDjbn z)8%>7msOs=MO-eq#e=nWw<3}W&(1Zm}2%ffACeGy~r|xi-NSx&P?~yI%-kdI& z`9RCZ5(F5*RIYGn<>jh3uQ8I~4Lj$K`0UC}%D@oXg?{xy&%*YSRGGXJ))CV~t5Nd- zl=N$_zpB{ko<2dXqw9Z0mdiu-`-^)Qo**=`gPif+V0^*BnQcNCZq#E@WL;DIN|oQ9 z+(sc!xL{D7XZzmpWfRNvklG!k84hKqiJ1P|0T*5s*D40vTCTBQ zVRQ~T+gjq%?ML5Bk?`q713{;pgXQY2fr^?-?wqd*E#8&$o!6zZb7)gV9tABe`Tgj4 z%@mdqKK#SqPrerXRFS;s=yMkWj?2U}4w6TFtUZ0|)+}`=v4qJQ638p?Lj27|ug*5r z9$|H2n^DAD0=-}>$;Z!^ZlMJx3K%Xv6(qO+)&@>Ml3hkZ?kO6{;vvgjl;x~^ojFPG z{MEfv>HCxBgi$PUlXrn15sIwO6U&uitVDjZRZLv9t$v??SWWz;)!B>m9Xqa<^XGbG z)iNx~5}VX(*`?1hh*=onOegg)c<3_T{)6I;KA9iJyXG$#1l>Y8 z3CoeF*1f8q&e;zr1oMic(xorSSC`}p`;_9E(2ge-K?oyIy$T#&+OjY^Dlr6QQ=Vmz`XQVZwKTZn_UdM zEhaU3*AT-_qMY)%V)eoqgS!g>%jnrCIctvS(!$46->cEva_n@$Z#t?7`GJarWZ2X1 zN`Z#rJt@H)=|UsE3OPxcT(i%}-(cd4jRNu)U7z`Vouuz z@=SSJJ7tA^N+8BGmhQwXQt zalX&cS(f=yz?{|ISM18$u5FYXV<>L(7IcWOY1q;g@&y0;z6A}0+}0{|y8Dw}^qm;7 zu0jrHiDE)U^smoQPlNEZMKpqPbX2`_s`9hzkIqD2Be_%NMx>T1bK$jmh-dR-i`Ue{ zudj3(67!wq9yR3kzuIxtO<~&F(XEG`13l$^z2$jU`n+IjW~HiU!RN1L87`?&zIGiB z)Ap%R$roqSegC#^E)5$B8>eM>Duc&TS#2cZ#1n}qPjqp?8t)3yo5mvcqE_~AE_&yg+XsiMXZ6WHj`^XEN>M)&bSXHZ$uouEfnM5GR&)MHR*Y}w)3e5| zDk4e!4hHlea#3lYi}B~W#|-I=hrS!q;i1MDeR7@gwIrPu6CaYP2>A`AlnT3hB+X_& za*65Kwcpp#yp77t2>F949#hB zNzF)14VE-VbdHDMv}F0L^w2MsGJ#R86$uVPrY0sJ92M{RfnH_uHlK+~*9AH5KBW`9 zBMJtgr8Q(3V@8G%zKgBGfNzck6pa_|^{!YYl>G`;qc%`YHbt1W6rVc*i`w@@3$0e?sjp~L*etsy=k}dwlI}NpT zO?u?9n~dW3DW6^ukiRrfndXH@Q_JHzBu-x7PQ%q1VXG@$;TK9)duhT`p9{xQoaJo# zI!{7XsfUd_Iu?GjSUp&jI`V;ApHlLZvG@Gv_=Mb0bBTk)KRyYeT_nc$C+fKR+^+tF zKbwRF@zXC?3b~ic(qB6?d$Zg>GW*riVEDW0ux#0aiM1 zr5pGoZe?=t&=M`J#yrnOs&sFrO0VsuD+bA@Pw1r{5iqnTL%xUaCdY8;-gf zSj;6j-x?*IUd%`^>3Xupt@Z3`eRVY)Jey|@fBgW{4w4>^J!7vV2|vMAi>5<#=Hp`OcS6FsIM)i?1dVU0CBV{^C&Vjl zQZH0#D!iGT&l9Z-$evntTlLM3&6P}>RxTAw6+sZjlE2+W63IGHIO|s9n$qGC!j@S_ z@QEBBOGY9fSZGR9>g?)xek`63eewfoEgxmbNwJ z%ITR>(;A{UTLL(TX$honXLFLtUh47e=p>a6rc845RX;EB&5hmbNZvig7V<}VX*hO6 zp`XKB&zqn3Zg!@ernAd^>19vh&j{X?|9fXpKDktfLwUn1MMd zgPLQ@Bp0Zgk%+VJcF@fNL|lTOuATB1U*T%kA9ItYm^$;UqUgj$K5juf%;!&K7SG(* zYw=$l<3tSk+)rDPH~INdf2I4$=*IRBW zFQqA54!5f~k^DY?k%gV_iw2d+Tc#fuE5s$0^e9uBryt;6o2*lERm-?DQmjVa+R7Wt zUZ3?O@NMnAao*=Edfh_K^Fn&<@;}z}m%%`hl{t2v+^&>%2hUONE4Cr3!<@8c)aRlJ zaYfKvzb+?y0V~Hp;uey|OP#Pcp79E1cp2Q8bG%clwsMrBs9N9pMPaPWRlFw7C^YYx z?CEpeRL0<$bo3VaZq%n(K4Rxx`;L~B#&BvQjeEv{1H{WRDU=_kp_Do+29qg_f8f77 zx)_*k)_bfb0!QKGz(X2?t4j^y?Fa?o23#NYOr7$4Cu*0R&yVxHXd9aUlEnV;+vC2v zhWN>h%6L`5a?&%xmB2oqH@BMnNYY+P-&={qdn}U?uOOI0?#U#9L_PXK2z=d|LnARf z)}~+d(}G!)ao=oYv`+~Cr@C|*ROx=^5MIvgL?7=XhHo))z&?PR+*N61x@S!l90+@s zY_#6-8lBHgSETOilqYw8MN?~?LOK^k8M^qYCJ8Yn@(puX+|t{p9vg~Rm?+2Dz`@m=KxuPJg&se92_x97 zxFAS))-~(uG-pq!<*=(hTJEFB@%+gLaj_a~4Q8*{NWotuKM)GgT|iA1Car!0j;o2M z9blY&a0>lFabWt89xRe9p>=n&fLRQJc%;vPR=&Xe!Y)SFm z{4=C%ixbfj`+JfWGy94dQmKU?U-K{@Fjd;8-Zb2HL+3)(N2ZLr$flaL;bBs1awA#3 z$8{`^OlP_rbKgE97+ZP&qeK3gx~n969*%;_EQ;o5GQKeH=(Kmo*F3LH)l(R1Qe2dN zFvR~+N9KYPsfuJsj=p$kI?379@}U-mA!{n{++(%R6lWWP@4Fi~U(8>y76+a0?zhOv zWmfICOb|S+d)_9m3xf{B8?XCB|JaOu;g>JmxSzUvz}b~cd_?)OCj&0nn4&9N9pkL- z*4KWit4%V%!T zH{5>ml&nADT2!K~AXPCSaw@ULId8=P4XFL&(NG7?S=OntNwvE?h|zPMk^#Wlh7XnZ zS2!PjXnP?Xo0BYpK{eoVWhyMsj{)fIa%LEqi;c!6;#e-tvUDMBobuC@SreL3s>a-` zhi~(ZuB9TA6}pJ)JSlQnxTnFscv=e16v5KBASH$K?PLbdA4N9v0qil%%}Mrv8w0i{ z`mX5p@sd3Zs|{z+ZJ5WG3R27?eXx+7f2>|k?y}U8>@?&MsYJP}(LO7FXRD=BUozaa zTWwymb|SjJlK7&0N+i2B7j5FAI{&4JBKH!0dUjL*a#_*SFNv`fy4OogbG3`&e5VtU z9^nTF_6)jucn%iMc;21xMrIqC66Fm&c8RC}jmP{IZwy{~Ixn^kh6M7*nPCHQ^VBdV z*>)?5{D$ml1eGEDE@5efa+F}21W*~ZQv!}_c~?i+2b`=cEHRV1IZVb2YkkYhlcTF% z3pUQ(5jo8l&9G`LN=M7JSHy+GY(A9NT`oI4XKE&usZiH6;fh5h6#XDvSMgcHfNs-g z6+9BRD`7VAq1ZJ^)$Nwj)dnCze!FCc;=O)q zH=x|*Dq9r<3UL+;UK}%dQh#CWumD%&lwv1A*^~n+HSgVKUqn9`5(73=}m+UpGa-&Ll#aBQVYAY$B} zV!VKuw*B~gESqlZ9P#4${vd$`01{6nF80;MXUG&bkBS$oSSc*B2x@_1Eml`&1$)n4 zF|izE@+zNXW~6*y`km@k&9@ch&Lq`IF}hq3{=&e5pj6q4)0DosXqTRH7lw?DZADIW zYt|k4AH1UvbG|259Y-%bgH>#=VpqFLd{U~73**wt?OTJ-7+gB3WV|opN0J2DT0IO7 zM!@WJd}`suXGEc-X2281Ye!sfSlh{_idC83CVH~U;O^-mBDvY~mBdpfgAxPx2_=)R zPKBCE0{F3yEb!C%)F4I$9B0EikL#v-iYrb$Ppog2u%Lqcy7D!=mv<9v>6puADoe>{ z)G9ih1POW)HW-==gyr#|s9_M&{J`qK1#&YHaI`;TFfTD-=*sIIPs{Zr{5-J=;? zWveS%f{}6@$fGr*f=wTPu}$|Ou+FMoMLP0w3N4E!hP>M?4v;MI<*kP)1Cfs;pEwWX z9_R@|oqA^OJl~V=T&0Eejxb$2>7eAEWd7`;!Fi$y>&?~eh1aZ1k zWx71nJx=;!gZ3md8}FbTc7wL9-*}8tNA$$XtyNWdCc`g1L@hb?M<#V8uD|rI`4QC@ z=PnA&xmqOg9T(l;rj&XUI*oV8xA@iRvCP%9E~IxVuilzBggmU)FY@ymBM|;xwX{Um zP1$k(PMQgTWL8XQ#IZ3=gro^H`*z~>Fi{+Z^d%S4=hV(lX$hHK_FbBgE$}KW`nONA9m0VX z1tGl30)SfqW}gybY{3ucwfQA!oC=3onvLR2$$7M2OsGhoq(iil<($hIwEJq8qMCER z`xXVF_QN`Sh4advl)t9ofQkun0!p002&b9Uy6M?>=Y~X85#PQSqGxhi5 zo+%ywjB}|T2QccOn-(>t(R&jVSvh}YBDF+(hU}KW98Uvx5kQgll8%M*T%AvOKYrLe z7RbNSFGF%Y%{ku?ewwG>?mZEmq(erQky?ldy>>I* z?ldN;nFNx|k1kKlmv%g2oUyamWcjauEdC&L=CR^i936SKRPOciq-=X7U;^@{MN6B? zVrjuCA>OGEckafb;hXiKA((!`D3tAI`8@kgBY+!y=&n0K5a2W+!sVCKGD3)G%)Xyu zRbQe{f9tH8?g(qgz`RgVr4i{4AnulX?jWs3aYsb@jEk2RFoOBP5sf&MLQvQRK5{Ai z(c(4l^7=bIc!&XtdY(R@RwFl$atl%n%WD7`aU^6y004jvNDczu7z<`t7)AmB z3@`wI;{Zy46$lJAFx9hS(F2)+EP=*4KroQmz|=tDIt~C$KTH+0^;p|s0T7V+^xhc! z{Nb!v?9hE4``ZGc1Hxi^yhJ5gPNvb+~~uYIBf zul*2M&UBKsC@K(-nr5;~ylRod2I@qMy7^uMrV*oG>sI7Cp&_=2$~sbj|HdLcA~y)dh^WZmC|eJ2C@o` z0(Cx=8;(T;5ANgBHXg~p>3kn^igo79r3A<~k6n~Yq#Fz+H#5t8klVzc1AZyO#WYcx zJAp}0P;`P@5ZmOspk8i$(`@m~n0LG(-q|Ae;Xun_Q1gI74$#j>WYjA#)?>^|hUAXS znS#n@^i-KFz6^@z0hj&mlD(qgWl<3R*xAA%dy|hSH`Z8fRX`Qu;iQ2r?p6ks(vSReMtqB$3hfAl&*xw7B~WdE`6}-%VMW9y1w- z!AH}ADvzD-eI67ZI%?o`%!MX|aoqa`^76=_8?SYi+k_;W=NV~2ym(7WR_%DG2=ID|;nsQ2;)g7cH@uihM z$M0pnO(Yw#-i)s{Ia10H4W>hBd+Bg@kRDc=sr_|&EX_qE6?74rxDJ%jn)l6E#**#1 z4Re&844Eo@?A3RmG*{@;@q|utVu)Bo^^wei9=Tf+jEV zR=0{~$os9N<#nbf@3NG&#%rSpD@`H$M{cO?vUrf`I-9Sv_4#p!v=P#p;;APvl>;&+;#wm)$(vWqk8VV9=kurYAX z(fs){ys&pUOs8wgiO*aN0h$wEvNs$%a}2|!J6S;d?U>c-E4!-JHq2WeZny9r8=fLw zMCxG+rGIdYiwS^LSLmoRm|QQdESPo#_<$X4#K)?+Tm?6{_m&3k$A^qvCnXfgNLpsub!W);E5YO$2hveUa$8e!a-NgOFy-LWoqX2#0^vBAu4NLmj-K*o5!dL zz>FG=*lHKgE$80Gs+X^OBCDm?u3(>)vkI9l|H(5H-0_x!sjcQ{6)N9X%dFqKE8?#s?ipECE9vEJ*Ya z0$CY>&CGuV3_VD|*m`yZ4B9$2OjlOyJH6`Parp(T36RkmKcAs)P9}Yl!a+Yg=J%qQ z;uW{*MarwkxOI~PLJO~iowxbQgkGCl$cEkj`$CMg(*QAy!={4`H9CfP{)z9_cJNlZY zc$S%7GUa-fTs1>OO_QvQ7^9dZBdTRuX}X;t>p7m+9GOYzq6(r~yw_yG#!T16i}0Ug z`eD!`r!dxBW2%*`zbE~co{3qM>Fm`fbu3KuXPDeDS`oKJ;&okyI0}Bj>3q2I9 zn7V!Vn4nK`*NZVV%6%U@(Y|3X4#c<j`dq%LGs@-`~;ldi$Dezq=lAP}k7` z>s?O~@;;d+Vk)3Ub7$PV{dMNAOiUH376$OAoApuGWyJyyq&_mtz`jTJGc2T|jCTp)xpkJueZV*@KNGuRIND~4@FS{N|_w&wy+_YmqF+}1#vk*ce>r!~m# z4LEFTSlt`*@2p`NZBNDhy)^{Iw^*n^RB5@(8t$M&BOwzEEBL|@;AfbHH2mv>yUpHK z1lS-`myOr})(2Ud0F8g=i1iSDK#tg%eU3r3?YygPZDVUIFvt?VM6?q^4h3o6AF@3s ztqXFXKpV5VWZx2U6NtLJAkXP-tQ^mCD118r@O)#ykX0?b^voTj>93J#e;cT zH;8Q6yW~^Ff-N6A%i+s+85=fpGcv&;ANd8WB7;50FX}+nVaP|c&1O~v3~+YJXKo2H zw>0}JKRXG9DB2GJw*%~s7H6PH#JKwivq{=?2TeVo103>gKXy+sHX?=OD!pbSBz#l%KZ6&a#ty zcq7dL)eo)X@b%;HN1K@hRuA9_hzE`K-2+I*270CjV9-Ap?MXPv|7x_U$YJYxrv~63 z?TyIN#|WC^gai`@$g{ml%5fXp)q{0||LZajIt$rS>=e@qWN84}R)`%~9PPFx=onSI zAL7un5Dn!%!W=>tT95aT8T!u;J5*foru_fZ4duc+Z`)Z2LhE-KCjT&VfFczvNq0x$ zUvtv~ZD_+6)|I=ZW9Up@0w?)j=$VTemh^`ieFF|4?TkH#r{_gD$%mt7ylwupB4B=M zOV5xcT9Bm;T)}9=kZxb>49wXq8KGCCig!1@QAWV$lLuj{>2}Gm5rYmF)55Sf<{W&O z-4PsmL_V=>VN49Hw3#5Am~fYO_j8ik93hJqC9xO)s%^ua0oXWp_;9;NYJG!f>$4(IAhv}wHUsLwF-p4|J_^+^ zqBvB;hcH#$?w2b9a(lMFnYaHYzCkatr@+E&?b5@ykhWm~>qD@Wy0))F<+$NOri>f* z{c}jR&t2G9jNlvo4OZ{s69Xy;PZjdeZ5yQ=H)B{VZ8K9F$P(N7#syq+(A@%VpZn*y zO~QhLErF(1e?ISSp*bfEnRWs0LUaoqHdG{Gs1^QVe7F5W+h^1MICv_o-MWOf&off{ zO2y9dZ|u_#7V?noa}*ZqU)iUHGz^{oGyCj>MTOr!Hw>qUX~g^l#2p}W=o^tb3nU8?Vh-ed<{p|ImjKTWcQM46Z3D}!4nqpklkSb z7VBTRgP|M@o&Ga-Xof|F-yL>`Yv^)CR31vmjcE$phKXGf)A}6ZYV+q1cDP&kp6#zE zc+H>I31?Zi;RpNUQm*eM&Trfw6|MW@3KU@D;4H~^A47l+CN+@l;*APP%fM6za!wLr z`c@E2G%y8ku0yS#N?Bh+SpU68%p(7Jjs_iYJi4$P~dRqgTjR)bi;=}yQ$l={X?JKP(!w;HjM43exRos|0Z2}!9pH#x*UYX z`d8`F2r|psSEv6hUADlY9!9!^j=*A&<6HX(d6=`CcvzT!W1pz%FeE!<`_zHO`d9Y( z6&CcL*{7Ta47I{?Q&h=HIx(8Z6`?yMy{o7+U;S z?$8el`p?`!90)^H_}$^4a-|F!cj&X5qgqhnY_`8%Q~ZMz zlHD%p;G9hD9xUji_E$h#I4d(_pygjxtRWVgJl4Okj!l@4Nyg z7JjeTT^vD|Fhe_dhfvBwU*kJ(3a!UYz|Uy9QOy2zbqNg=Gdlm1~Ur6te=w0;fgPYy^F;xIa_K-T=dcO6w2x80Kp zx$9vLvIIj8+roF?WcT=NKX9@lAZEWOP8(4_22ulHtq~=w_a%1d3hjfGc4_}-9LmwyUvF@dunol=9^ z+?K-Yu0xKn^R&v6=brdKjfe+@eZ0GS;x zggyc%e1A{CpK5V;UV+a0$-i*=_BwFgQ``{bPIg`xL?r-Eh&gP(gp%X-eMieb+yC37 zxcxHCioiSnEnRJN9%yWArv39e;O}hsKW`~8>z%qw29aa^AQtq1&<5-a`Dtrx@X55H zwruCRg1vF^2jJNM1czo*M6oxH>;Y`)&cTPi$fb&p=iclc5<~Bw7Mbfqe=@@{2-(<7;iU54B>1GHfS0MJ)Axr1TD4NP^7&1_|WU`X+A`WND_3%hj+9Qu-2^#MJw1wzn@ zKx1pr=HUO)EOCGJ^M=6I0JQr52)@7lPago^$NvAw2==%CJE5>uu*3cjSqDsEu<85g z;E#H-zZtwbpcl|)@JCA{`}=_1L)a?VCvW0DokD`^TWbaM;rC5rh6{ z@b}mMsR-CT*roqN)=PXAgF#eCV3ubR@I}4t>Yec5lE&^#^}Nu`Qj@KxNZL zn4I;OjOy9{$s!iDZXK8bLZyd1(8P05d#Y|zvJFN4Z2<_qgHZy5_;>qy(2^aG-d{3y zmVcp3Cz!-P(d9+~bgP9tfD$rtkUOpb=-nrbm_23uXS+}L_U3KOxDU|}dSP<@#n=hQ z?y1R5$^K;Q4hmY$JBV=ak?dYpgLbr2anK@d0=5UO^|cBZ2k==a6T|Fw#C(746JAZ}X~ z{p)dCfXO-ZaZ^dyUyloGy#vW449mC#b^{~z4pOWAZV z7QXNcTT0d^2P*a4AH6qo3%fOz(C!_+7k0aQ|4+Zz84J;%g2)7fVhps0dS&a@84ws- zqU((Yx?SSrfb?Am@@$V8Ic^*&zZ%_W8{P@!PR(FI^*JBX{MLcA-=XNhcd2ibwMls< zC-4vefUl4TdiQE|d$a3H`yj}@Rd{R5b;bB-^*0bflmdCvB4GPgMULCev~4}N(gv9V zEe*`HtPPBHK$gsqQ(3=~!+}GvJ2^nFkTh;5K^DZ!tSz;-XSo9b8`?7XU_yn^1OS9i zBkT}`5^^#3*C6}`Y`mA>K-=v9i?a7utw415^ONJ|=#2221m_p8{C`~JXCO7}=3Aq_*P3@r_U3W#({GlWPD-6?`}h>D0(0wPFAHz-olT_PYM zAn+eoS9En<{B-xfzwd*KcO2clo^zi2z0XspROEq31c3KHFRwXV`0dNb4-^0uz}(u_ z(SiM(Iwk;LnL~3v@JRGq*PXr178hGlZ=zbShgV?_4L(cs9~m zf9<9%fi0;ODM)ctK0-_!Rx~&dwi*o+#>anNMY+`5-$@i$q_#{u|#htWSoNkNvCMN#2FH|rS| z+4jIG@u`LB={uQBeN2;au>_{KGwtmVKD~yo$HX^T$oYdTOn6Ncs14UD0{(oHg`Dg^ z%tG*JHqtYHf9X#|O!-JeuI~^r0fjKgHxSn97tHSy3JXMRyU>Xck520~{TT*_q#`H# z|1uRh+5c`Tes~RDvp_s-IBVV;Y=S?G2}^UR1DnGYhrcpi@+@+Sp?s5aNUG8}&7dFU zI@b?!o#cMD$@%Ybo$JTB&dm)cJ10!t-b2Yl)eSe&&npD2HVz}K+ZU&k-=>{{WF$UH*ac?x}Ib-Sln5QnUF-(Qm?F8GlU%!mr3`Yj138Z}Sramh8_& z{@}>^dek!fh2)QhLDy9O>Y@ApA)M&;mGmqD0sx~ONC4g+XvNCL$kFmc&T)u<<5hF@ zF#rb|QnmA0h0eW1{MyvWj-%fr1db5+_aM+ZLh!rC_+TpH zsJ6}D$7Fj7?=$k>3q!xhIUlX(M^pE?K{C>J&_7I8@~S2O7=Q`~sk)C{dT-lC2`6;w zO~2W-rlpt7iF?X^84SVy(eLFftl#zc+OU4#WFI~t_*yc{cM;-bw{|qNG}gD*{fN|0 zm~FuyZ0X2!ANiUi1pb>5_^#>Z{y}r`f7g5;K5h9?^Zh<)F|{^#F#g-k_tz~QneYGR zq5JQN|Bia{{|kYiFyCLBi+_*#{*XnCvlwskz_W;}@aO9a=lk^GP{m#N>?^YpE0a8{ zoNOEXBUhoFoh@ie?zf2z^}KT&om?>6&`5!L6|4&mc48(-lO$9YG2pTzTeV6f!wc+gFe^iYjg9T^X>b{ecjTpK@R&9%k7Qf z5}kfv$bT3z-lzMcAAT&L9?ax_6876*=RC~Fc-0Iqe{f&`s|QZho9)jb-o1&b^umWN zu|ziPIlI3;=;-$dfg=R|MF?yqewqkAyeR-5nSvuzc7(ux83O;2Dd0LX1^;C%OwSZYZ?e&o_jD|`i*uD zyGov`n+pI`k^t2JB#?Zn{TO+&E!2tW9)D+1^LOZX_!Z4xqxK#8akBpd^y6gzJL$*C z{%@ckC;LA`KTh^vMZXU`DQBItC55y81pN6n>kVz}?TtSUt3w^fqWpf7MQhMhbDOg4 zxx8~{T3eVETgS!`|1;trenI*V5ufXyAU@aMN_?(=1@XE5A>woWGUESb81Zq}!jJiT zjJlNWeE1-G%Z2dAf%H+{dsm|S18e;2!I%I58ZeI%Y_#`+RSa3il_!x4_h0%GrOf!{ z9NmxLakx3U{~bIIw;JpJDjr{tzk|5Pz}(sh9t^`hIu!0t&8-~{x2x_reBc29tML{Z zzkT`mK@TANcp~jL#D4rI-H+Tm1^~*)y*7UTd^oLE-XDHi?fZ`}Y3%@5VsnDaPl7+} zpJL)am;ae2hdGDtTxdQPA~lZW&Tg=U7j9JAs|{1-W@;2V)uMtYM;ydn2-FJ z<$lkW-?!u)gv#F#`|CsHZ#41?L*;Ks|Njswf1?+FOQ`&fc79c;{5^~HRkOb)Q~QS4 ze}I0!G*kPA3jYTB{Y{zLH?;j#^!r|>_Ib1aY^L^k%OBQ|AKE>9FH`%5*nfcdzcf?( zh6?`%;{Q#V+BdZQRmA_=?j96;e{1#^W@?|yd;k4^JyZKO9{=5$$p^di?acMtc>H%~ zu3wM8g9|#KHkSJKhqm}%OFFn3;e(`~&Im|8Wn*6m{OOe7;I!s%$^BKniSoO>|LLUQ zVB+LkO8jHqiSfHy{=&52*jF2N5Wr_8hnq0|KKdn_el6l zlY{S(@b8?*`IC14iS*!m{QsGmobU1f*G=VokN>|QL--#5|G~+e@A3ba<_Len!;i`0 z-)DsX7+U;vmT+*J)(7wTQQ&WX`H(04uf@fGjmUp@Wc+oe@YjeOZoT$@&#cZtu3%|y zZSmPe&YxV8_xnW4_i}{~lKvRe|AoL0FRKNYbIlVO0O;gH1squ7F9d$DOz`XB?DegT z-(Q|{nB`2r9Yz1yS!^e$()QHd)8C(pP;1^!Af`65-YHT=Gz!S&;!X@~~IC(1UE zhrBb=SRbDhm-lC=>3q%HuQX_cR$zo%cT-7tRS5^jW4Q8qvbR3LkW0`Dne}5VS$#XI zn~O8doX6(7#820qr37m;gJIXaBpZc>s?o@$#G4%LMFfJgAV@dE&1*x&@7x&FlvmJR zjq(~i-e#21rgC4f<8%y#<4{i1>;23jJc;Nz#t=#rhkWkcLA=kYm*PR;}wuHm@>WcF>!jz{5- zaM>uW-VD7pTXN&3+UTh_DOt%&>?4aP3qrlM_nifMpDi~299u(uTYW?G_t+j(1`p+& z&jvm7Er7qb_p7H4mQ{S=oIjKO+neOnH8Qt1HayfNmah(?D&H+W8Smra8<(#tNlE@o ze@+{JI)3^Ctv`nc{He6{7x27aO7p)0&~ukI!=fMK57!Uz=l#i7XaJ5%!^YA5i8F5j zUKZz=_pTJl1_%B-e;VybzO;=00fo49|CcCq*!+O=C_gyLX^s#$Lf{C2e?0=9q$HQlB*}S#!$r#;xrfZrnLKmSxwzpdmB05! zo7Vx|PJXs1SW11~P0?!lDhOf(^vG5B9lx`^C^BgM*z?OU_%EY8PWGcJ@W0-!|MEi8 zk-#Gaju7}iiolOYd7mvs`HLv;L;VfzL_cj2`fQiVAtKc-?XKS&P%8lClfYLK7; z0Q{fI@86beXm4y}?r?DNF7yW$2z@v)q(-%#!7v6KiSeL%EcCbU)ySWwVm@%ju_gMF zAiP7K^Z)?IUv%ih^>Q}m_CL^(S55ht04n(XawPsi3e|h}sFAgJ`7f~RGCheJ;OlWU z!4XHJ_Wk-J9h?03!TO<}Yo$Hc72*A4>RD2b8zJGw1!qZj%Hv{9ln?3Ax|QbmNb6UhEirhd)wYrGD*#u2P;~-jmoAwGSKoU2Y(M*&%peKDnTS z>o&jLA!mJfPtNOGIvRgu-=P*`9r&c5eR+p!?zcM(|DKok3&!iA`1enY*TZ4?i^l6O z0PaxzJv4CtxdA!cip;;yfc(^WJyhraI9`9kDIALJzsf26q-i}A!Cw7iru9(J{qa@( zBm{?Q^NhaxqbSclAP>+KalV1(8lmm^xp*Jhj7N@IYlHDTx%NmbFeev3m|{h z4w+zo4ujH1pJMhOdy${jCVuIQIb)ad@b;gL+qo#51Idu zrj2l>{eG~_AFB#q%7v$k|B0c3%RgiT_BQrE+rS@<#2++(pW0-6sN+8mtnH7X|1$>e zd+2kr|IO%gvj5HK!!`KZ(f=8%^gZ;s{%-WS{%-W)8vGLUaX&>f_Hd8utZ!iX`6}AK za0TBRN}q`{v~+~N-`8?zL-w^?(6_qz(`NTTd8h*`)WODH-_-agI(cBlKNI&0ls{nd zx8(k`llW|B<^c+yy8ThSr8(5$FoDwd?Cc@*_!)D12t7F2{~8@xmQ66r1Y#|AM-jB?WLO%R)8199%%jPJsGKEiZs^p!s zZEYG0R#ISVVv%1*IM!fKZ6|yQ#)R=Gm}|AgYraYx;uW3oO#M62GQ7qM`2C!X1EVk= zchUI0HXn{_6gGt)*ZBV9%c^N+u@xyc0st@%f8NiAeG>D2hQSzqyyRh`BI77AYab3A zsm-LP3D%@kXYbJy(wE6yDiMGIee#Wj8x@YPt?@)=W)>0mc<#O7p)d_d&mTv3H*J*d zt7k-WK)zmYeypLRqzwzpCB{XqI}|vgc?a_((jz7Dw!sQ|*^x~<(Y4#%jh7U3Bq)wW z6*V)zI(P9R&$IZtm~)YI>|@l)#DT$NB}Qb<)1-8Z#Aol;_o+T@sq0!XyYn(+v1>NL zbG`ri?0q{8xih)Pq!`{p6dA%_Q^oKM@XU(8Ad;NTu{ei2e2=NVvxE0i4gUE_yY_^$ z><)H3%@z~9ME7vk-@T1la^{U_Vwy^h<<83?|wL;XJ{IKksKG`7~aH@7ixG`BP|wr7Ly_x^M)n8%KM?&wb{fwDM*D%?GTg5xM|?*foSN z`+p!1>fmVP!e(x0^H=>}bVt=tzwl-cQT{c#YGz;tOoZ2fAdE1K90yqnS5*}WSyd(2 zI~eAWm(GxO_7x_2AE9e+Ul@seXnyaNJ=6A~GRx93S7*&@uXpxOr_3PrFAfz#XP$6D zO@_GwYE;LvprN7sZ%fEc@#1P$#%=kXoVrIyR~As0EtZzLN7`KN`SIHXlD1a5N1lYL z^UFUGm_*$>9sP7n8{_O4cY`Ns^~|zP+#3EK*PXV-(@ACEO54t-xMUBz<9zH9`ORJs z5I%HzOv03M*GtaC;%0E^RWtc=>VYUT?+q5P%ubxvkVs5_UfW_tQdz$3{Zo!sOGsW8 zHSxJ3dELj!n=c23Pw`Zc3I+NQRTp`57j0EDne|KZ5b&MBY@=+JI#$D@;$O)-KxP`6 z?VBrAbZ@b#$Q-e^eeRC8v=CHf6q|{`IuGSIbSC43$cruo;pXWC)n2xn4@63OqO=o< z&s-wy3VG~Q&EcpnpiF6%Y~%a%s;z20zlZ}>+wuDemqUx$%wS3@51yzmN?sB=hir_R z6?CZ#n$9Q(N)IbQ#OSOSFfk+5PSkaNRdm+$A&G7oTTDoXNcXbyd8zB*?xMsA1(jAR z*46muW#(zEE7A-Fxx8Dh`4Q9=J;*}?@C{aFr^WI8-em^AyLc}xEDGhVPGXw*rBbsw zko#)#H5t-T7xLwDMO9-<2^I-MH!Efbn~N9it;}PvVv|E|?`8R;61Oegk$oVNG=%oT zXdtaAxpem3-%c$ajBzTVdJBvZpdpdAhJASn83XFGn>l?RH$^C^Zt(31#FrQyxbdW-*oaUc@;+LAzP?4e(@&wf zH{f513ETBb)}?gHA?E&+n1v>1HL8x+zWbuasVMSMT!OU^0MuUkY#;`l=b$DA^m#=D#i-~t>C0&u@sG{8DrPQDgfZw2NF+BtxtvYCkvqU( z9avmjT217g3oL{8@vXw%AoVzueWM35)%CbGBDyqRRGTPMW{>i%y?uuoV=13xLV6v| zdS}$&<5K0IR?Ld?woBKqEllf7-eqz~y`a>Em+0#14c6isf|Z+^C}ZvoJ{-I_eKGyT zbHyOcQ_x)86w*b3=Yw0-+T6w07fY$bVXj!OnUC|;L;0wnKAtEec4RHAcGip7RPAXq z{#R~NNru&89;@lT@!BP+aZuI-$zr);IhCh#fCGP#5>w@IB?42bMUa&e4PNh=1k!g= zvx8Dx+nm4(l)>Chl4O_8c~T5RWTOKNt*ral#4EBWd* z!Jq~`>nbgsNL-iK>5KK^P+f-0(grp6-lpJ}G9%4chfpk9p5o@KO>FVb+96!L?N9Hd zLia{&@bRgpq(NxpMO*7Cmvir0Krh#(}MC$NT0 z2fQt`y$yB|NgRCR-#yqDOR1|g1P#Qj8RV&(VY?>GSF0C;{d}wrbK4BPhp+Y62EmfR zu3ML(aUR&yI`F2&g@J1tBzY+No;%{CO;8)+6w>kb$2X)>DcRdd&xinRJE*NSX7ARd zdM-h%2Ato_oDSJVYZSQ^BDgoKHG4Uzaq!~8v_ELqIZ(s}%&wMG>=BD=b6-z#{MLHZ zZ8Pncg04v%;@B^>NXJFzO4*47qIfXg?GsiLU0I41hwjt!mq5yy8FjqZkN0s4>IBb* zrRFbnU$qc^UBZkjDt(e${z+0 z^DXLF%BFB>P>Po$ZHf?dVDI7gaL6}|zU~U&rpWUTTJ02TxpU)YD?n|}{rKx!VIKD_ z6?;IHuU|ESUO$I&)v*W>Y2lWZu;q!j&?iQA2q%~}RL&5BF;^B$7!1pg1yiK8=*k}p zSG$)?*N!Q1KIiIs&y}5x%rF#doGq2h0(uM#xZYF zk*~HX=BoKk$KtXlGn&$m_rm1cVou7A$2E+3_772wi_ta z%F3lAuj+|jKp>hxG8hy}pd5uM>`}eNNsBU3sI75_xhi_J6Wy;Zh*co_+NY53JhP0&Avbgfl2??U@F%*u;lbNCq z`Jf$t<1A$yK^WuCU${7Dm{oqZ=ZZ=H1^JjuKG7oHevA3=ei+8=!m<`MySGsqk|SlN zMbQgKU0&0$xt6CbrbHWep!AX>i zf*b3OeEnsW1#y!WafPM9>Va^6U>hjdRRuXA@H_;xx4nXH2Q*=+L_HNc^o&A+R%>(zTxl9a7)+Thfug&i zZPKsY6iIHc5^Z#6LKEEY;(}f+m_2)ASdnnY)oQK)Jx7;jCky=&wr~~`MTwY-lUOUT zgRx0Q`OF5saDTc7HLWW`;i8JNs-#oGP966&=H%))iBo}uf=sXRY!tt^O!nf#VK3-8 z0)6S##PTb#Voowa1rkeY!)}D#d30NRRb?l)vR6y%#hpAF#G&csem52o^M$*|(v=wK zcS!mRBt+zgwJ+r$x$=2tU43HjxHdx@(Q}=tY+2A>?DajmrX}@LJJNDajr+~l?p*{2 zwJ_|ord(cczpw(Hyw|``_Lk(q=>j4A{`6QzF^rm+N5DTfB zNgr~b+c1gxt|p?*wW3AR8^!@zoG;7k&g$AND!;yc{)a6uZyz>z+Gd(L962?CQJTNt;AvUNAEL;zIxQ zJxON|@s@mrS@E*1}tzKl=VtPv zk!2<47guT7zkXo`$%BYhsQX6$T}s$Tw*o*=P}^Wp^(PvR6Gu+`{ldw>M+9cqnCn=jM_j=HDE zv3U%gHG5UTUKAdXulMLdt=sji#VRRadt=lfeHK-B^zw3lGgcCoT|ZCVYD`{(kE}z;AU&KOlOU<2l3V{U>opVt|>!3Q6J#~wH1~+O#G*Aezg5B@%qzyuuBEa z3na8p$$EgGsAgwk$Wxrf`delaWZU{-!*iRj<*jOOYxVkyHK z!;014M=4f9t4v7{A6(1p z_LENuwOuq!EM!d5+{EZ~HDI_Xv%>1cRzL+Ne=gETW+S`8ZrrR>;&DqLokLHQHaRz% zafjEW-%B2?AY_$cs_BB_VpemBqofmVN*L{y{7u%$_I`qnf^l8S7rHXVix=aKJ!z$! zK&!Sb&+5DDPLa;s2wb^G+=ZVWHK>L=)(~R3f#8Nk|xm}-ihb6U5Z{^)-q!@Yb-?zyV1!vHbsQXZB zYEkE{^>#NnQ--GQ8-k2Sm=kO6p_N!pcaXe$q!eTL@N&Yj{hip3LfRVvy9VIO*)z4s zIhQtX4al`#sHQ;Le!^H$Z(zX>?!VX9gymyZAs2bE*dm`!v!g0Y&YG$gjCfJT^FpIq zineNrzt0ntXnS70*ZQgZ{{1~!f;D1|J(pOVq^m`H?u$M3?2$f};?M77)_(2D6Y+u? zm6(g?!5vA9LqQa2bTO$9lx%AEQ}QZ(%y+MHJXbQ?Ps#)r-XZD|;i-L-iSX*h@)NsT zM#RAueQ$aeYFJ+qScq8)Ert-ZUQi^`Xpq+`lL#kTjgj26p3k)L%Gs&r#xfRyb0>7$ zzJ;pT%Qa81HYPO}@yYWM><#XqDc0k6%j@VYC+23zd%$@ScFG0h{WI5jszYbpmLPLm zc6|YvS&JMo)w$m8>UjYe+tyWrCN+zX-|`4``hrcNvS)|I#+=(F@tYeMsdc3+hpg*Tox9R4txq(j%$qjGrBZb2X6kHZ zlSlX12Df1Os5;Tvwn(O`1sY$E$~}8~k%J&Q8M#D(gAyCKahdtT#&(k;_FMsTJ1cW| z-uMV`*#@Ttz2$*MgXUzAsVB{Qci2_CcR8v2L{$QPi89PJl6U^A%Tb!lF-om8Dlk+j$ zI;XF9qGGiSqcp5KU$0}uZ+pQ=qk9YKiTo?CfcGNqy2b`HxVgQyB+I5kuH6nRqVtZhP{-Pv>)S7O+2>2fMCMCOFxN@1f~q2p?z!wV)dj0olQ8r*YBJue!5yiG({6qqZ}oQt~t3 zDTTI6Zq5j&E>v%!>>}%7pIj^qcyh(A{7K*&mz`&LS9S`CRyP<~r(gAY?LNvAjao|{ zeWmKVdpVC-G`Tl~>RQPuQCGJios{yb$GeZ$h80J5pC#E`wZR|rcs}S%A&T|rY^JUs zq$Bubf~drsdZf1mYZXoj@{+v?a|z6sTr*u_e3ki?Q^#L)4GJQ7kSE`p?wy_;H1&sN7mtNeJg1ZvBSbmFD-H%1W31rR#fNr~LZkm2z9H|v0vx2(>#+_Z?lLZ(n6c?wFJc0Y)y1c*AX2<|+0Zl8jm zqj%!Q+lMnbH_!ze40&@$q+`%fre$jYeO|c74fK*YInwBjP>l?z#7R&bd|yGh3(0Zn zs!XIYKrLPfP%s7#g-kGA6OcloMEp*y=FMo0H-?ukUUoc7kEKm&I|wheTDR70`=`)6 z_9*uY3^IssMTa1%aA1N6RV>oPZn z>bd)(Shi%NSxh6 zRyp$6Z-oQ%Jn!Bz!dSKi6#2Z_;$;x(7QX1G@4|QtH7rb^9lCA1H~!MA!cl~nZp-@$ z0tW5!b`K5)jD6W40^v3f!dlr|YY>{L7lMay7EL56&O_i;j8X&yjMm(VSItkWmg-tp zU^pme%`wiD03BWhJ|IoGI0R~2<~^?O1GI7s?}#nyo(%YK zFAllsDa_Uywk8u9h=3Uc8hu@cTMfC3gRGyWd%V3Mps7xN)Wya{UH-q&{liUcVmT@&d;Lf`OZ|5*R+TV|os#?ZF^yT#V(b zxcd_zw49nMIX`e(`|C~lO%K9sLbN)dV+Di>g|_E7h=Yobey~WzK{1U1MWfGaP;iMh zRE^oHtHL31=eGal?F|0B?wP(({?Q>a5+KVs!fmbUlhs-XVP0LU4ZxN;om4m`XPI## zj=k_(gh^tY$sy#Z1wP}TDmt&nq6bs}_4pxz&hQfJ5;J*wk?CWQP3Z$3(O0EboHdF- zL_HBkTqov9taN1%zzEXQR#9w+(!rX-9|%+&8vb4x(eB< z*J5>o0K;viJWg4{>0_!^iiCtDs9M_L^D)**G-=MKQDRj19$=%)4qq|gk*L3+Qf_j_ zFzpNmQ0r+0gajq?1;*Ut{FoamAfUn)wHOA+WB{!JL{69r51m6Fw&td(AHbGVw7=MM z_GNs%7(SO0vnA@VG?zZC_g)GnO0;y(`a^oq@;QIAa z0NM-8QdnjKXcHVHsP0Ju=~a|^wE%gcG<6Jx*f#P=Uxc7{QJz3x&{+;DR^^<=#}Fk9 z0OiV4E!2mC+0`Z_sD@>>a?~g)3G7*?P*yed)7U8V+VRn$fmB2c$v6ER?}s^J0^7T5 z3{$Xrir73mx8wW}f(8*zyu&rt8^!Uo5tFEQq$b5snaL4Mb3ajB`HVksv7uB@`55TT zlwIJnm_{1I$%y5v_rV6RB!rwGIa3K;S;Y+3DoB?CYbYh_yHYK)En3BgZUsbI2!_E# zT7)@a`>DK4;ebSdDawN3E{Wa{N#w}I@$2W<4T2E767-AC+=sAXu*8LWsg=85Fzw|p zPq%}Cm$d>#IRm{!pB9)rT62X4g68Y%xL)Kx>8)}~d0L&#Nb=S-YvPeKrr&;!^i?*L zhiYPm7w=N(5@V6P5WiBT!zFqln-92V;U+E&;WWx(EjUuQq=W*664l!&je_hX7{d*94{d~4Z#Oq0ZLQMjF-l5Y zh@(t0BS4URu2xJ(QJ}Hx#JWxD^wX=vWNQn{6d+qnZFJxs#RyTm{**!49lztI^M*qb zm5!Yy(Qj!GDwtz>f)7YUo|#T z;A*XJb1ej)8Ut+{RuAz;1mR5KbU8szkYzkQp-r}FXE!Le~!DK z2W;3sJ|5+x|l$vFqlr|59OJRo6i5X8O1b zo|72BGllf{zQkXC%+ZDRN(;?BG^|lePmY-9^d0~k;ng`+&4;)r?nz*r5p665a(7L( zt1C-Z0jvu3h8rsq%og0Za-O5CPe-8jU}$b3(qj;y8NlXc=mLSp`Y_u-dWJ^I>YHXL zg!u4@*=F5^rhA@`{Ch+uG4cFsG(!inxQ{zvZBe^F0H?v}i9N z6@5Ew_Ez}r6GjSk1+;ts#^|-{tzhZ7y{l&Mi647%J%dNUwOn;6i<;1UCDLUB1SxRU zNs7@OMAY+6u;E_C35X_$%CrHQ$=_={{_FZJsoY!OsdQN3KA1<6ZL_jx(UIr}oyc^~^@@VK&}u$xvK&b?ij46!jla z?Y+ITaI;3UI|Z>6Tt!b$PzB4m0>J@#^N+q}zk4d+jH)gG)R+uO=A+N0Ok1Z6%)K;; zyd_DVqkAdGB8!j#E|&~9Neyi;H3hYzx0!L904sC3k%}Ux9$cNy!W>GBp5Zx5DfkGS zjd7fFJfP_>+6LFk~Z%(IvM1D2tl z+`S}Lf!uW#$9Tvj%XktH9`LRGvvhWWRg{@wFT`jP0%y>wc&oNdcFrQLO4zT;gilqMp20jb zvetMyL2@$z_hriHkjyL{~j4L zd<=$bGAI^Ed8kJB8*y&J6ai)Dz#T{nXYH_XXLmGoXvjQg)3+oX^HLVZ0YM;*y^fb&sliu+w(!V<>|`LO(gU+l&U(@e1!1KJmaUD9u|Wv_T3(VE1e}I|tFSKqtXy)q z(*yX?SUH*ksU;pfJFvLtliJuyRzNmbu;~-Q|(N-Hfy_P%tdanTKAa zvEM>};^ul`>vE+P;Tz=jTEJR)rd=ADPN^kU*ESlP*6iKp0j#Q>(eh!Q=MU$-5Hy7g z1ozNtVIk#3vTjt{mqv>Mbx3HH^CIX_YUiaiz2D(3WAkc^gsF;wx*y!nZ@22#*DK0m zkMu^s)wPWe@M3mguL^VIr+Sk04r#uA1OX`3hsBcEC}pTsFo6m{8SU35&6aZW!a>R0 zNa>90@{#5YK-(axvEk>F>!gRJ!Un-f#Hj0tCKM;2!d0!**%yhc-gt7CfCYS}(DpDj z^lBj$w7OIR6pu92dtaZ&LrFtr2?L&vTXtPfs|F*~`csSMha58#q6$aoPcl%&unZ!e z6Qa6_s_2*?Ah-UU&P9KK5`Yz46DDgt>uMV~x+NO@9HeU~;lOS)q``@jWTaOWzb1Ug zu4)r`BuD~jPcJ`?gc+j>indZUZw@!@<-{+Df}9|n5sOu4p^Ie?w@M|e9|2ay$15O2 z0fFRk%d}R&$*QVS#IbicNtIeN=zaQt?HV}g`iK>b!;c|vW$K~!D5#wp1Z%n=zTMAb z!wEiSyhfb?&XZ-4$$}^gdIQm8_{Bk}%gIdB@pxn$D>CyT2vYo#rq_KD_;2z^pP1eD#C^Uv5j7CqR-n*H9-?+gahTn)Aw2fF#!JV^%*BpS* zh>sY7)_#7Cpjym0^hEzfru*1ko09~ogzL!f3d<*hP;0>e)6q`;QA0?RdmyiLK_M~@ zKI8%DvXr;L$-5xE=W&s3dw94?EBa*U^kLnEHPx|Kpl4~|8AOlc#8o{=j!rGiYh4Mk z6iyu#tzPz&dca!P9XkjVb+Q3ANl}znl1U|*y!xj{HSX}zDx+zPj zliN=xhRt4e9ol|xzm$1j(X+Wa>Ny-#E)lAV{&dZ$CXJUBX&yOCKRYeZwFzI@F}#l` zaQQ9fTlj(oxJrLQ&>&x7my`s70S^;J9+C=Xhy-p6r4 z2zki23cdE*u3yz1s{mkINn~PqY!Ft%$W~wkLVzUkQH(mwp4iLi6%A=%DO-d=!NqGt zSM^WK)RdZUm~9cLaXY86B@8K}vXUEy!fUSj$-Kl_#IpH718_Ps4a%r69SLgll47Y{ zEdBAhX6+LEhsaf@kSOjB?lw-#*TZs7OLhwkP7~bWeu22-N<;8MH$*tma#Lz`vv}nl zV79C9*t*TtDhrw`xh?|7U*ed1pgu@{cZ+GCOP^x&;nw4JoPfI~Rihr{&C?F$u!l-81e`$gOihG!dj3oKIpH#*i9?768MRxhw-yZQSwj zeIHvy&XAw>sXrDUr_U<2gWJ`MAYZ}8#6SJ8LC~7|IaXdZAnC5m1jGY_W0Tr@IOZ0V zTXg{B7+e|c90HwJj2=BnWyj5t(u(RK6*0xx*jGYK0&5`Kwg=g1A z+2HUMD?2ySfC(WXvXTCYx9t=SZ#UjKR%;4VQ3yvHhPbQKs0+7vCpnVu6%qvrnUg;C zPvsj-@yX?>-*%Wzz1Dn#5?(_>=0X`w5+Fe>a8;F^mFHJ07d6+6Aqpm^72r*qpzphg zxO2SkCBhaMlG>qj&Dx8%E`EIGLP%w5)SPQm0lI^yZ!iem8)z8KP z3?xR+ixCW5DiBT!xGS2y3Y7?6ZKPdiBzg$c+uvj_f(YQRHHnxElpVk7*Q?(#zUYxK zxwRIJ!|tt6MdLFVDM=AeMs~6hz3TBOlMb=A<_LzTKQKX5LHBa`@e$1N`UT@W3O#*P zBRxQSQl=eM33((8VOHYdY3&eORN8+2M{%mVFYL;5*`E{Vjp4`^09E(~qs;s>M7U2_ zvEBC!Y#@s0t^+?77{iZ~j>jEgCwX2Cy&DNC@JD;F(70B zH-u3f@Q~W}magzwY)^kW!C3hhf$2K7+!4)$xi<`02qvm{?pB~^q!zEfY1Xt4G~4T3 z^cavFQOIxd-MD-u{I*61TkC@NaYj(zNpCN!?yW*`ez=k@?M1#R4lUl?nlFf|=`Ann zRe4L$oO~jqKSav2<+yHpvr388hvLj6vre=s8D(pf9Un#;0-9vJ(4)`J#*%xT= zxS@cfDqI3-V!nR*?VE&15TJ)3Pls#_vYRrfN&ZT)sOGApaWT*UWJcGIg@f^sZpQjK ze`I%v+%S}tdHV8A3{T8>SG-vbyK{M1J^9pzHga1F^(M3w+5&a3T&Qa0ara~A6?sm& zMl?}KFuDh(>7ea-g+m)FHl4C(AzC!S%GpLsRZ<8`RYlS8gijmyInJ$}#I$a3{gtpT zfbLAD1I8fnQ?W~)&$g2ck;yV9RY`IL0-rz5j@pBlzUWq)`Ks*YiA zp67~wJy@fuT=gfS-@!6K@T8{niqY8~WDFOBIglBRuNSH~|L7KC5J=Ctoo5uVt<=~| zj#j@I*}eq=o_v9=mD0-{S%{birxCiMuy_w<*vW60B3iE6yCbtZ2~ZIBj<%p^P@Y^7u>*bgMeW+A_+JRb-z{(7rn zl#UaWpC@?d_IA-*h08(z5$44Pxj6i5`x=vo-#cQ~z>YmhW z15X;!+UD-CO*+r@S-((sf94s88GzP<0?&AxW{z)@bUydi7c^3&@<$MG?M!MfsX5(n zmL&p*>rB98z%2vF9NEd0E2HOc>3L*bR|!|a0KP;9ccw&m!Pnnr)gZLISu{$vDWlV& zgs-PC(q@kwcm}|H7Lvm<0L!UHdd>chREIg$LNbpE6WHma*J}XfzFW9V;dq0l^-{8J zaQeQ2`wmX^HqOQg#rayo`9zfb#_HC3TFSMN052zT&=p!3P^w+fRImlZHj+nYaOt^V zf~Y$F!V5HPLwMOLMT6bw3bt(x0N7cmffs=GR)VqOvABg`C-!7GZ$rHPt`^rOoq8HE zVK;iZ(<0+M>C@Ky)8yByPF+uxaZ*D;St?jY=0>4-gs+tId8; zNfj=Ck*wj9PN?>kRiwhq>?pyS%QAg>5Ecr(JRl3l@}q1ZykzBQIL3upG%5HTXCB#) z%drb|VPzXJ%tr(XwOeN*`AX{|V@12~Cfi02wJWCBrnmM}pZHk#`sN3nZ;lBfE-|?G zJXzK>!Q-gvON|LY@Dc{s0#3!gOxG(B9D%$tSE(Lp@b8Z7U(1O0wDIDPPajIJLbu^rM_vC) ziXha@)pl;Ai+Rv=fjN1GS=Z4g7BvcQOT~+4wB+U$83#h<#r5%H!0l(uh5!Ql5A&rn zO>!_W&P+x%`FOoj0O5omlL9aA42L`N;|MKize}>t3=ru=AQhPU&Wt1t<<*@t|E*3(>cQKQ-m#c z`NNE-^BE#Srz0S_msL~JsFbw{X!2r5_!~lPgXTw4)j0!ZwGrwlsa;GESYYl9DaL+| z{n|>%5wg}$+JaWs5<9*M`eOBi=e$UDWTGaxYNg3@bdSOvFb z>14MK*E!)5y0P~qu&D)QrbGz`V+pQ@*0=qZwUF&8hPVS+0Z-8DfzcMot1Hd7dNKYV z0Ej?$zlD9lLmmeB>p~F>Q2Osk^iK~O2Z~@o8ixqhh6_El#A-i~=P93SO!9?{=h-cx z_7)t>utZD8i07Fk4Nt<|)FL>b^{;FUoaJ#!Pp@{wYe;n=zXx#w*o2!EEm&YcR?Sd~ z9gu+mQ9JN>PY0P85XFJq=i4%1faZ^Y?OzvWz{m{-&e%R)oly9^`?}tg(0>Tx!lizC ztT-)nkAm$9R-`{^y?W5d=u3F5)CtRAA+&~s?>GfxiTXBi66;e+~vD_yQ&z zD00@htUg2#3qr2YU!V>E{vNpGP%9KX%Z#ZCJcz?i5X);`7t1f1L?h}^kwWh zmwe7OoLw@1sFqOgF&Gb|*DPKB6^KN7(n)2;#sI7f;p~#KXk)Tx zguYi~KL}nI!kR$VnTAJmtclNOdu|`t9e5@~u1+%yhCPe)nToJ?bl2jBSfAv4d)en&%EKSQ1NNSu zUegQ(b>xbzBNq%eKzBB`#tBwk24hh~AXV5%nLVc)kMc3G^Y_n?i90E)_K;`61wvTwdRd z)Q{merGF`$Vv)KrtO;&o`@hKBK*&E(J%UwGbw(4c&(_N4kL+Q>fPDHF!@+m31A-hP zhyx%dg!%$~t}fICwkKFn=NZcAO$j(i8!KLKhQ4&L{}}kBP$OI&VoH27-CKq!cJOso z!Lh3z=|KegKDiS3Mh)<@Yl*+N4Z*@?{M@s_0L>%2S>b~5)ErIeBFw3Qe@}7Kz@Ouc z_1lIaCKRv(j5$Kd82DXD7!bsPQsM!I9yEgI*#W0;L!^n2%}ySzNA)O8m(C%A-jvXn zP!I+d!1|yn#U6jYFkcSxOVJ*B1OxEQE5a*ht{W={141~E(+=!Rl--9&_)J3! zDn|&pV(cI2=hYwEgdK?%gjXt!KLY({io(r=p0yD|d(7%`M5k9%{2_=3ATEIYNKE#D z-t#8PeSz~=V+ZSzwZO0CR8H^;OCA~6|AH_pA^!jadf6-)WsG&(7CqV94(ktM{dUAg zFk_(ocZ~-m;XwW#G~yUwJkwC*TuUl1U`5WXqk96u8K+duSTc9u1hxlIPZZo22iL|A z)g}FiPBQRE&hh%sKs>Nylp)!}4$mf=z1V;m*k`B0{cuMXAC(a$OrKC zFOVOC7yzF0#Xd$v_ky2G?4PeOT05fw_FoOqzNJmbGyjm_K^_d$)s5?`e=!`$+lPpu z2MybEEn#25?j%dHCxDI>;CxHyQ7nl&aAH|we0@VSzFi&fk?_&nHTWDdN&JxuBo1tf z4rbY+>F?`&y_b=V4z*8K5s z7ufz*D0+aNSbrVB0`#|lLE+c{W2hC@b9=ctM9RW|AP(fg08<|#q281%&a;F3P-Toc z$;ZPp3S8g=Y1H|qO#Bg=J0jYT0Qwo!WY`d2Agc!r-G``lRTH#fwiOBz*qb6XH|50Z zf$G5F$0rbr&g`j$<_|YO8RJdSnoui@kw$38B0Kz?J7D|egkuE3AiD>RgqV1!3yJ`6ElzOJ7Yiy2O?*i%IZTTjX%P4mYJmfBlNkJwb%y0 zIR;QKg!{X3wmIodDp)s$I1u&~LGOBgKLTw;jX2z)Zh+5_ zh7+&{TjtxL7Y952D;N;OfuwUSzb6KS`J^`3AK2o#t*Ch3k>Zq`6Uhxgy=ciOk$wbC zs6D69pR_(_n_iW`C4_YWV1Zs6K#t(vHmYWDso0ssga`7M_6DoGrxg-qG@c>}qU}ht{ z=A!nCK%V&gT38EE>q5vER?M>!3xRMF(BbAGkNxx@b;EKei(8EuvUcp(4*aL z@$*dOh+sT{vF`_L?>nIKn8qXqf@j~0mp6fY)r9uy=-Be+#3v}q9Z)6?*z}~6#Uqm! z1BWG?Wd=QKPvKY~FUXk04DkFzZ(^t$!|&tRH%#s#yyp~b^TA9z-o7G5&o-5(N38&V zL>h;PmmhkHpY>zQnxoV)W@vUlT^w`hqZK$NxKh&UU&nwb4wQod8Swy{b1j$E)e3el zYKTJJHA!FceW_I47I88^bfxZV0H0A7ZB3nDBZE62FR#2leTXFUNB%twh~hvg7^r6t8a8K} zLX9ACguZaEJW(9rHG7J$5vd#3rx%qh46x&oF>s3igD@b71JN^%rD0(IKZOB?e&v)+ zXoltv&=KPUirNF)vpgxJ$g||;Sl|&_yQ&z>`$?HR`m~H zKxQ8z7H1gI`Jrp2&C#yKjnVvJ24wH?w4XFlqNf4cx73dChyOJU{Db@vC5>PS53t6-8YvhMs~I}+>V;H1$av1Roab7~ivgJ&qJLl4jN?U3 zXLO;Y?Kv6;K0S4P4#6HXi5QS#2Sj?(DfcWhd3sYSsSlByJ!t+d4v{j(>!V#z>5}HF zX&f}OQE#{_$>Ldc+V{qQ1UtY60}|>*LjB18VGKO{FUA0j_b1%YRB!9iG!9f$IGl{B zCYz3BH;|J*QjcC#V(|cb{)nXhr1k1QqF_(De~Uv@-|yEh`!a@Z;S> zcjW2i?9Z`MN-rulF`*O=k<$7QDXA~ve+vd+ygllMwr9DNU=zR*#DTeyn%>SNtNVu; z&b4I2A5q*6$ijdVGj~8v4w0gLh$P1Y{~nJ_8RO$gH{>_a!kr_61NfOV*kI$AB~r5x*Ce68jMSr;OJ}x}a0VZ4PQ{s;V&Iz|2rhZ%;~tTSs#k zderhTpuBZ~g7JWoI7E_TLRs;^e>Vo69l-J7aW~}M*Ss68cTpU`k8f}NLFe;r6yOjk zh5)5dDX82jm%l9qNo8?CNm4g}sikEF8d(sezjAw#C-9r&lW2qm}^!a`hon zGKWZ#9r(Y50mvO58`vSng+hbk61`M_IV$!uV9KcT-M@^^d%tp7)t)}~v%FiK^gaOg>?!N~HWbw-X zn>j@CW58;(Bt6T*0l`93O}m}RjgDVhXD1T_?9aEAQY)w@2L3PcM`R6HAO3Z?%byd5 zS`Cq=V>UQ|9}8nOy^QIW%dZyMlRZU(HA9)bDdj%bQY0q)ABRZp@w~b-`c(TPx>(xo zg!>PMt!3$#Ee_xZY7pZ*42K=ew!L<(*a4kgUDuyfAv?h4Y*Q9z8p_2h|Nn?X#H{~L z=-qKQ^yk6Oud^bX#F-hXYq8O-JUF0#EKRujp^#)&~cIKQ8u~jmHfz^2wUf zAg?CJzV>2a)2CSf-`Mg;q{oB|=iB}t#=!FfonPPI)#1{POqY_yA$B2s-HcnC7;x*Q zKN<)B8~+HGq$=g0E>(ul*(XnhNz=jWDdpYjHxa#gf;A;b#l4eG`7o5MKMr z@Y+Y=*EHd^zl2{?V|YykUb`pzHBJq0$a>8kkGTK6_P^Ku_uBtn``>H-d+mR({YSi3 z!{W6X39sFg{ThiGnBt9(vR@;y3R6rY68n&NNF+w;PF{->dX0*ugufs|Y^KH#!x>Vq z34LHJ?=|5ccr9tjpuYM#);jo~`u+R$8isEJ2Zojg{^dJk<3tX}j?=$aj}fz1{jq<} z_NK?@*uF1&_UO)+_kr)#+pC>E)Zh;*z4wVD`^B&8=TS6p=n{t?w~p^JXwa(bU22mi z9CzDk-qg*_ZP47M5#vvKS+vaUd11TbFpkRjv08Cfs_lQCx^3M1zhAsO5IwzhPWRHh z(r3?}{TcnTp0+Rln|R7`eA$W%Zt& z)6RtiPYUll@6ytUl*BTX6jeKpyOwdQhsda@t*vg*1fNgy`+1#px_GE1NA=aK@#|NV zuj=%y^k`0Du(?lf@25^#l`bc`x|^!MMYXt*w!csCwHLR}U*G@o&(BM5HV*GJ?QYQE^4lRDG`ts# zvf#wE-CT3cx?njs_u!VQh(A&)ZQE=2+x1fann&&6gi&#U@o{lm+USiw^YmH+Wg3lCo@7j3Quk+^p*Otr~iHQmf;w;U(=5JW^vu8=lgFB;obK^8@`52kN(A&Q_`W$*BZ;18E@O88?i4#?tC&D$+j_N;IW?GRQ`wA#eJV29h{z`}XoE`J*^_}i=ME-3XbDm;6%eMRZ6s%Bbo z*9{ihelBt9)BVlN>c?Yty-|4_S+mceU7w~CwzeE@=xDcU(?i$d3F^nq@=qm{-CXA0 zXV#pEGud-``?{2T=rvW-D}a-q9AudP@ zWc&5h=MC?_{qTJIoK9n-Mwu60Y*;jAr+3iPz*T-J+~cbtx}ZK!fSeq6h6kI-k^)UNN_BeToafiH>=G`M%AiAs-~ z)#DuPesjJUk;tvg=@lGdH04X)>aSU`5fAQuy)^cvZ?|2OGB@?)Y;SF?y3sH8())MS zpEUh|47#Xwzb)FqgIxO z1O}Gfzxdoaxqac($-W-ll3lL+;@K(V^1bGxHC;+#TTI<$wB6#xl{M%Sy8rlY^!rDi zYd($s@b>wgdG>Ml`&NI>dLHxW!a{-O^$s3#z2*6Q`pr+Rzr76F^yLfsquPGwr8m=} z9XR8E9Qkeldf@GZ-gp0$w)u}8N>kfC!lEQ1C;Q%pF9TIQBXV|}%sZ3r zu2T8TvBuH&^>1f(U)Rxg4c7T_8uv`%vB%Y4f>G49>0j@^tSY;@cmJ(T!Kwud3g4HG z{dOky$G3mw#WwLB#ns(+u_Cx~Tk}sNilR;0L~b#^y?xI9KelFZ0+v*&?@muEbJIRi zwN6$0NL58q;J}oFYgQh6*ktVIFVWvVRDYc~?eB=+Zr>?&QQhoaF}?inoV#DL;^(dT z)cSCuPxHTTPj$(Dxcsla0dYyLcGfwoxy!C{-q;M-I;8h6`IYO&Od0I%==w`?$%E4J zvQ>G3%N<-yatC=gud1z@+~0k;<|^)zXYQ`Y%}qxB`S9F1|9hW*T66R7n|Eh#-TAF* z$L(8%?NqL3J%1kj?SA#Qg?s1xK6ud?^^Ip8zHECo^3e1y8YRAgb}A383+`-rIAA~) zXTZ6S0K>bGkl@{Q@U-bCg6vGo0)tN-?neKK|5{iMx1IPSl@4xFs6@5nhg`-+#=jBe&_ zGu%I?&pkfA%T_nPUzRqVRMfeb_wIK4HY`n?! z{gEGD&i!rElZRE>ciQ9)DAtba+^X5Pv*_#Ivyc78YUe$id}4gi1^lVo4%}Sz()EVd zMjh=ZsT~G;&wRe3@sma_k(~>|%Af6?;P>XJ=f_TE zHs9rKQD!tOX048~#Vf7fBdR6_zKO`0Ff*)obwuLxDbGuNd?#G=^lRhTYUUUp*X^g1 z>_as-hy8il_UntYU#hJ>#*AB@SDLYQQT6MiNn<}c-PIeN%uP9|q18O6{MJ?fxT?Gn zCQn21)oV+8EU4_dval?Duv4nf8<=$Pte7#ux)g|G+-48FHqBBnMV@6#o-Tm-)zhuYrw!TX$T}n13 zeX=RP^Wj9{0HDON94DgZ{B#cfp=-<>+;O1_bI1}%9i|M^Sjg5smnuK;P3XW z*)gN2X+bx;f6MQ_ar(ZP(~eUd<1V!Rwm5XrZ^UG&!=Du+q$o+^^I2j z>^yr1)Xb@RzkpkHHK#?DSB+;;*~ab9t}k?ZV%VzpwVcb>j?6VY`?2)JXtek4=ezE& zZ+DH<8get@_xD@Y{GEep+y+#3{!^0^FvR1;h8r=nR+gpp&6}>aw2_Z#Wz5rGHGLBg z$9H>>Sr!`DIkbbx8#k+4b57lPf8)2-ts8d>^>r#N)_8Ea&Ffm}Cd#SsoLDV0?NO$z8jn}IpZd^3-#sFQ*$M35@ zu8-<=NzE=K)u7ww_Gs0P!ip11MtvAK(0$I@-jj4v4(^-&Csb8*N*j&16uJ`O$;~#e=zu3y# z)8st%KOLGTeY;ot%y~(m_JD(d4)LnsB9Exr{`s&fwc)onv2Ra3>ypIjH!V)@r`J1B z#wVMHT?Tl3^XymPJZ?m!#rn2edtL3Nn$yT--o`sVR@p0CoV_^MSG}rqS8L;B$w)yas%4_=z6T%L9JUEKax3%~d{h5Q{InOmB0p={fe z)FnN-m1N|Mc$&!FT2*^?m`UDWHFJHV{07_~U%RbRRr|@*$=G8K8h>EXuPc68dF}7Z zt^aQM>O(e1>uAl-V?PC2Xk0KqS$t!OX_|+du8EIV^P9TbR;jg)H?J*o_qtZQyJ%hM z6ITaq*S-VY4|%TUZr?efX2;eI?yuYKs%OZD$V#TRxg z<34QHdVp$!+g`&4w|M*Oq^UP{Ob>r~IPzF^Q|^%Jr{7wi|Fx!h&jWX=U(epY@xkj9 z=ZqYyK3j_`SE)x8);_D95Y!^n_0G}UIP1`os;whN+qX7d=rK~KGNiw||Ira%m1^sK zS6(q+Qn@u`6#gDb&oz=R;(4fhUbG9ck<9vrMuNrA!VstjIL(PLO&+rtb7aD43`8~&Ly_`+7GS#15MJMyX;T)!g$vRXnD;%@xqq3j{fI8 zleXr~aBX(G=HAE))936CKdjQU_0v&_n~T0=JH@|_C^&>aJI2xCT4QvQ1gLaZS5#E-z2wuIoEtTK@{Q+yNuM4mr9fv)0jqyZN|x zh0U+?z6_c&{Z2%a68(Yga{V~m?4~NK&vkJA)Msu=Ny&)(ZQ04%51+5-_k2#@WVIzB zfnnM!8ibqM4qwe(lHc@m?^n_HTlx5|cYCt_+PYecbobRQ&3_x_8kop+Zl=+*<*>WE zUo@Mt>1LGf+T0xPu()Ge3O4Qb=;L`=$J0S&LFFiu^C!l12pT#;+r7E^TC=ak*JIoU zs8lvO{>)ap*EyH$iX{WCPD|ug^>aN^v-5WJ#%=c(-aa{T&CsLfZn;Bt?Qd{pMxP6b zrUR>vr0BNY_j-3bjgbW|Yq{GW&#!7_l8)@O6P`q-KRd`N#@@roUyQIpt+UaI_aSP(L zw|?5q^&I9?QOPN}V&v-TfB%Kr$j&pk`KP>hzcJN`o~@O7W@XzMzlHozc~j3K!QJTL zZ_mJo*IrS{YgKRef`_Zeo4mzga(wY4}jMQ@s8S?rhPhqVsB`21qempwU= zQENl`TCMKzqI`JoorYEKgDqG0^X}PV@w9;VFCs%f?XkS)S9N6YC@*K*8H=2DSoaCp z(;ztJkFd^#h5k0J7nIfh+4B<)HZ~plv*(_w-1IB=>^#P#1vf288foE{ch0s+S?u$Y z$#E$v>SrAu2LAlX*eTxE_d-%kI;UwtZff%2pHiRZjZtycx!!SxX@Gl~e&pYyrheYD zrpLU$KL=-V3pS~J+FVuXmsL}15pcz%IInr!&=<2ues1!+Zs35WZF3s-zOkz9#K0SG zTYPG(VxG6Ij8l|XU>4!)8}+`+!0NwD?zv6;U32&Sj?bR>?n@XR`_}f}nbWE}a}E~U z26-P0j=y9cM-_j^@%&hVT4d+U3L#&%aX>h@>F zA0y4fbBbo_*f%-#y1!jkrzg&1uQt)P(h6?c|KwV?pmB>&otZG=lVeT6rsjSw*{^@e z2#6eD<#W5%DyQp*vt~ZIO6HKTm3mw0?NjWrPF1 zjm^U?p5}8*$30AUAGPafRkO(E{Z$)`&Mc@*cRzV#QsuT9chwnZEDSY|c;wFQJACLm zn-agF8#PYOnDFV-nEo|WM`+yWXE}cKNVjN%0ZxNemoBI@v5)(G+@JUC|7yKY>sRlC z#SyMHt&aLcImSCzP3&LeI=c9PSFXWx(++OBdX9D{o!^`tc0btJXLwl2V&9>~Ha}+M zRBUiDY5vG{puKBum_^U#xr5edxaHqb9kw&|@ySbCH>dseSFZa){NR6D-QRuGrnTvD z>krI`xN_ag%jb|a_EAL8(IR)_xXd4mIwx(%_M{piVxTgQ5-PV_T< z8WJ||&~OiqM$m-@%N_UoI@)<{y}&7M7(9QrhDr0JvaZ@%$5ZU5ez^Ym_%0pQgc!T8 z>5WeW)_h)6#x)+0mg_Pwqj%-hrY7fewk@vE^+iI(TWr%d&Gl4XAG`6j^Czcn zIggvDxsEw+Z|!M3&g{qB(z5c2ODmWBIwbVTs^TRoOR7vxscva`*{kA#ZfsQBopv=f zS1at6W{o#L<$J#AZwVT{%lE0|UFbGG@xe{=8^gxquUxtepxXo5ed$*wzkk2Q4+icdGW9q8+7nRnp8+lZQNOPtIz-Iuo1{u~&7<^Fu< zm0HUKRQK$TOSv7}Gb!!^r|1x8iOr9FExPnkFJ71V#Y6L%&5=c}YT8=oF4pRtORlvG z7_rW^X+em7kFwNL=enhtR)2{d|LU)y<)6O(zWILe*u)NP;y$j_%^9{g_0`@FYY)WO z^t^lSiSv~-i@ZMGiQMpDyRGq;SABN2HrV`Uc*|c@yX-cZ+@#govpHX#n%>a6kZd~i zyhoRZN%r&9vsE5MZ}RO}d;DF~lYKhHJ&t(rRCH?|bZ{=t)y2~me2OW`ZR{O!{9>2nxu>yPWKvuh{OoWu1k3m~VJ1-!&m2BU7+Iq(y^fS z*gLySPUi20MFHB`zwWK<*=dWdV;uLhQxaom%Zls+42qU}C5tXZMS^Pjo} z-SWeNk7!fVd9aP;+Lr;JA9=TV^DNlE{kBQB=9+qSf7Rg5vt2(f{$=&`j-!Ikt&Mn` z^2z*On}na*rDTi_=`rs&jURUUYDW8W*8Xm9Gd#I!YrmEktNpS(vfM{iCWYU)+F?V+ zi7Vzk^K&kGZftGs;=b)uZSGGVO$s}@;bXU$ic1^99_>B1cHZ00LuR)9w&&dVuXmzVJZ6{Ko-|Q4^4Rk6 z&X=!#oZ%ntzG*Y-TDS73x5fz-9S_y?JsZ{7vZl1<+_TP%6b342-Lpq=elKH?Wo z?<3t>-gmH2kFen+Y+5nF{%+8PS)4Wrt2Heax;WZ7j-J+IW`B;yxCwejI@*riYjb?( zb2H!E{@h^U=c}EyAYSO;|SPljoghtzBzIbx87D6Ro=7$4Cs69uGXL zwx}8F9=vE`k=E$`&6r2U>G1~o<3g_u;wC5i9|}I~rgr+0d6Mhaz{s*m;Les(ED(OP~~t7+kN0h{W{g`=+c%a zd%kq_s?eDJOsBHz-+rz(-Bc6oZ%rG$<*`#Y!^n5fCY9R;ufCHu%EDsss)Wd=9#t#T zT77D4y?0iP%^FT}_1O{rac;g7gG#QO8Z91K;TQ0H^@SaF&4y%h+s*0WJ^qGW_Jaqy zt?%EubZ*_uZ>p9S<6F(_u{inM{b~S{WiE}%4Or7qkVka z?io7&@%_W@_tbW__&m7H_MlGgR|XA?bWA^K`HnlI(SYQ#0)w|z&!Pf8>3f*_jT`MB zpY~hq$Dn0Vk2=)M`dB%jOG(7Hu1nEgq^o;;>A2_D4(N=(Fc>?zjFQ(&1N92D=PY*~ zQD`#l>Oj@zljDBPRd)-Dx3IM8^nUj5`A`2J4QCzK^cVH^6D z6;zOh(J0*wBc(eP5J9?Aq`SKX=`QK6XTRrpJ^$?Q`#txZ&v}3Dy=RnqZF?4ripD_~ z3%VM`q z0Kw2(h<8tw)uRzdPb=a9a9Uy@5-H~~eFAGb73J~OK2EGvC)!UC;O%n^3~>@g|w>x;ctY|PQ0bWa?gb0POt zzxss<5cy5Pa+*#EzaBIZ3-CdMsD3}_DT$mypoEZlpO$;r`f+##5kXXIBKF1!yy>X@t~Emim4* zMGU1>qIW^*Uq*5Nbze#m!%w}Ux!7O$S3(s=Mea$KJkTav_aFRP)8<+gB^+};<7k9{ z0nlusOOu~8c*qj}fHDX&58F^Sk_bc*1Q8{^UtzlKlmVX*9Byo2s;d3lH~;1`Nb?`* zvKi?a>nDdo5Mlb{gA)oNLN~8YpH5YWlYageBp?lnA+a;M@I_~WqOaN_!iW6v;3`F5 zw;G+WlmrFp!K7}C!0Qpz=icM^hs-o z0Jx>gh>%?c{F(>1)`W?aJd5{UhIG;wCgkp*20EN_HlLH2 z_gq~qvsR9n6~7tI@LI>8P2$XQFu_mTrfw>HSBbab=2>mDHy~*PKk-S0JX?6%s9;Z9 zHJsl5*WQ#bY(W{|OND0*`JWs)^*OihZ}MzrAs*(CA1{hdFvMnIPiku+ zkA`YQVnO?os4JnimMZ-qf;RKll23t>Y$$Cw3Fp+m3yn%!c4D?-VO^tyyr~C=vJwl|xKyRgH*0t+@hg&~u(RrCK zYt4&;IvJY6@^hQo>%Y}Js+yyk^}y|-_XPatPRkEYEj6=^`ZrSFdx4k2fFV-zBK@dk(R%JOH_|94D9G?AkzYqka`P4& zktQ0pkw0#pCi-P)g3RUI_0G>kSNpBCLVTvv5v=X1S1UC<%*-!IVz0a74n7%Oa5h{au> z2M@k&AhG&H9AEw`&PzeXWm|w;xQh5t&{C0|FUJ9~w77-(qo;x2A$ZJy?3K&LxO9J& zcSp@tAKTk}{gb_~Q}V(-1JiL`R^636&nr;@5d95uJBbmsZNm?6^Is!kE(^EE%Fq_w zUlc=^RQgxATXe#_@e)@b<#zIqe9Rkzwtt0^uim5DDA$Xgai84yz zzR%n=KQ<%_7uq_k^IUZoAwEbC)S3S4fjxsxceY84UOoFB$3WB8L8^?Nvhd8-uqVy= z8}#fV!jzXSP@I>*$9YQRo>+2X$_C)1gFbRI&uLBR?2P9zgOw-+=tO5@+$(X^(k=gk zAF-2CJES@VL@t5)1(w~ap&S6SSbGe%Jujtl)28EZuC zNIv|5AT2DZB~#tkfs_SPW^jN(n^%}En^?npU(MpMmmS$}MdaqRqF{{;AS?d&4L zMh(-sEBs!w$Rj<)>~yOleFC;~(>IDymVfDp+i%VdQJaxlyTTR6+45*W8Uf|NbAS>2 zOz7{cLbt3nNbAp5H?z0OPmWUNow`F#y^e`Ec;s2&I{DazQ|^h2d+FXTw1|j`{#pvmbZ!yWK4XU-Z8Liqc=aLGj0C?e}>Pz z7^iR)Eh+%9FozC}K=*z~q%+W>)ofY{rVS=_pxrptRvHmFejf?CAMPIf&&S?h=_#dK zd!x_3yAhrrLj6A8?z}=3V5N^ELSEMpT^f*muM7I}W_#1L6b}aA@s*IE=&`<6>Cz0g zYX7d^GNCUXXlutEq49-Lt}*CHS)9;Nv$sZjevea{O=jeC>$j9;U}pnakS!}KQdd3s zVR}j7JAB#iGaA9u!lulIZAS+VdxN+=6dj-!KyS#hx`x&1#JG2%;ovs3uy8f{D}jeG z_}>iG-w?an!l&=PC8K%ya6DWg=SZxH;-ia;tn5~>^wv>l@pDDJS;4DZec78^H;3Cy z6?MQMi;~zW;NzkPvERqXA*2?T94THrG3fWWvGY=-8I3`)X_PX`St%r!($Z3(Xo7Yn z5LzX+!U!wGVOtKV%O9->@+%3@KzXB6a=-ifTvb?#G(|1r{`*qt{9GRpu%a-?3iLyH z16vKp|6-vC|9yQmdg~J^XoN<%f9(ATslocJxux^l@5*1i<8P}JW~bM3vyxEPi7T9* zRQ8!g)5}WX?HIEYaki_XUr+I`K+U%6HEQy4MkJPG)~g4Do_#boS66IO!Ag8c<_G*R zmDJnuQltA8iHy-LJ)1>b5n@u}g{a-8^YI4Ut_V6e=4cA9kaYBh=-0Kk(`dP2gzb9|_(4&OR0Jed+SJ{Nz2ttC z(iZT{hV?pui!~RnyB&-5RB;Gkm~x5_eW3)XwU|cDuSOZ3VO<nwGuZazVV+770chqx}?hiE;mgVEDi~0Wg z`}Cy3Lc7%GYj}{`6zY?Ohj8Co&b`rh^Z{hHIX=mfAaoT?ED2gVa+vBX>s!O~8ykb8 zcH&kotA*Le3n2&WfsB8hEVyf(I512pVbty}V3Y;7j=0g7I3pH~B1IWeG*%~1wE%LO zni(k5Z{G^?oQMdJl2R>HW9?&v?-qV7G|n94ve&W|;jt7M3E!&fQ`^Ubc4N#C?S`IrAZ~bv~Uw4kb zZyvX1PA&elpta*~qQ!3cp7JV*&nN>6tLzI-W{GXhbgY?bd8Nu1o3+0p?u5KH-VB3J zWPgwU{&$QRITD%Rvu>Hvzn0O!dZb6;-O~Jy$|_hAATj-UC{`9U;Z*K=3x4Z!_xN@t z6OePz{`qCu8KpPTlX~YPbx+{ip_)O;8&~?gg%m*@%PUtA?T7k;)8Ma=Cz!3lmnqJl~X$gEEc6swzwDOol@6zQz(Oy zYY+5BUemR{=lDE<9WHnyq-sZz0LG}2woXT{_#H2O)$`AH{?LvJ+xg)KM39T+m*(2! zsYi_OJaO~KPr50#*a>^zh}iOYWb)^q;c-6tIj>hb)Il#GIkx2~jk&?bVB6q{St zD&=$>nlnR$1Uf?hj=jse5nI9`#3;<&WUQqkv_z8bXM~cI&a@ETp(ya?HHeGHW11WI z<1jA6%G8KNPrk!xRLGu85trCRI}(n5B^K%X&1Nm^P3+In1dpFJnW`{&0DQ}_Hjc^+ z0I)D;^Z#I`8gRJXFtpyEe7EpeL*P_OOvf(upTCE93zp&NYt{@^WhQ#!%ZR;t(%q)R zD>nDI)Kxox>SH2Puv<8n%S0xSxTvrMj-wpu!CCkdtzt#1_PSx=F zAY8WN5gAfakh%Q{Ho{tk805rYxEZNZFAc5I^0V3&sF(4COxaAwUSX-hzv8=F6WZzh zp(AR=g?^W*qOP7n{{N065Dho=M}FT2)Q6a&D0 zL07J4{A*ZX`r)o|X8bhr}|988GS7@7G+0=3Tse z`l35iMsc5yX0Eh5r}S2}%1Wca?AkLB99>;p;tpc+2xA67B3=XW$X;@(bKrgR!)J!C z-AIAHrUTI4)(A;j%6(GSveSPa?yE;$FP--TUh#Bb`9sAeFKaAu%B`CIn9^PVP{c%r zuEX^35?skk$Ml1f_41uti%({wJQ8Af(0jT-hedbT{MGcN!IC>DeyY9+f}kq# zMOJDMthX|IJ6W31) z7HVF|M4Mz?Et4oUU&0y)DC-CgJ0rzk1}uBI7@kJXU2zObuoc;c-;=uw$k((0-qaJQ z9q^f%gzRTv9O;nHZfVw}2X@!uHfmq_R^YYutUv10GyUn>fk(_LG?W0RO?GN^tw{0D zqQ1tE3=eKuB6sst?C5X>Tqt+O#7YG-x*~u?R54G$@Ey34S3|^>%Lk8>#e8puaXV*s zD4~-@jZ98jo>2Z`JbHF>s8zEO-=IDsB}u=vO&Is8TCM&j9GFJ5)dC1Q_!|}@e1I1I z9eVKnd&3xFtKd_D!ee+`e=4P?yczKQ%1|`m#oWoFH|&1mrOJz~>tC}}P3xvbZl@&l zr&3cj%(|=geT=?iwXt)mEWn-$ayjJZ+eB`8`ZzjbDec}*zo=v1IXygORD9(3eSp<> zOQe|`h#(mJMU3u@v44dPEb5gP*qxTuUwr5EhN=w2`7=mT05331aU(X;n(J=f>?%I} zptadHW>+UWGi!<7PM2`M5i84OU&?CmYtK=Za>BZxa}YRy=Y<5Q&NICdZdQzBC(x)C z%2~J-+gK1(*}F|AHalbHyPHV5JQM4$E;fWAGYjb{dFnvYU24*xU_7kQ=Q7S5Q^1bw z1-aAFM=3XHKeJo%v4TZ?8$R(jzB{`Xvdj0v$ zr$6$_E8vf2m3I7xw@BX7rqHYDMZ`s2u}Q12Pr7*3=R$@@`Z!|M1V$J0Vb@)cZnGYb zDe#X1!fYk%D0uNy|2WeMUKuwAS!IbjVPov~?Co{cVd^9OYo^qYkl?N8&Olmi_K^gc zx8IdDdZTzaN%7=po;ec_+LCJOR+}tr8t(#3^Cu*#Lek$YbAyvxs?|i`!WM1H(B)zV-$1x{K^R`(#g})_Q_+j(4B&!#T zVZL;8KxtlL=8w7BI(bTZ7>$MPtmr2H`b z436f6{As57np<0BmIc%EN#C8P>R-T?6rv3SLk);NvtP%YvY>0WPcF1m+CF z=?5wj0FQl|nK)8UZi4Yu9$|ttp5P(PNdMpb3u{CA)sOZ0I#+LFNW$kG_=9Zf6Kt4Q z+@d$qTJ3%qbErmw;R0bh8U>ZAPk>r3+!qTs$@35XD|#y3QeOQ|8aFB3aizS}c4tZ; zd+qE9{k+;xcM#2-!cT`)p8S$qTQ@oQ5v{IFN}7!9f9`^e3Ly{b`AgaR_hyXPr>DNt z?dC6rE8l#(oY2mVWBVG$;TB-7s;OxxXr3nMkJ;L#=MVM*%?DTksKXj%4dK7X5Eo2 zOebJ5ZFu_wm%GeJSc8hmJZa_^5mMwKotynp$xkI!7}A+h)t(UIMu&&oMU)q<*VW$6 ztB;5|)z$7|#Oe5`o2%+BC~sH4BrHwQ`gd)n4>+}x8LAfaSy9C#?lFX&A_idZ^afI* z%#1RlSMdeE9Biub_@qaX9}@_8%jCr^O|H(4hq$@Ja~g^dR-an?MfUU2dow zVv^->Pdm%1eC89?*wY$x(1fXz1uP2`+^ZRBUqq{mjiAmNTkdn9sJ_T4^w)u*_W-Ei zlrRlGElN(*PJ%ih7cC%4Ts`=${5s}~Yd^y-cHm!t+iO;nfIPx6&sREaN^umF@BI|} z;Q4v<@^JlW)r9+KBu#bbA5lT2lLj44`_ux&X6u-En*{swHSGK%L>!oOo(iB6igHf0 zIWob|2?Xv<7D`w+Azd%@no8-D5dC0HKzxSNZ5A8TVsi-<6;`=b(ftY~*U?NCJTh*; zDfhE^pNQlqCIa*_-;(T>Qh$g3(D#vHCZ^huc2C z%xt_V*k#YF2(Kl(iQYl#>#KPNa5q{jO;-0yuWvfZf_@LZ2dmWS3c}zu5$EMbsIaiW zcP?i0LbeJf+aDBGVD!&Y;V238P-j?wWhb9eS@-K!kp3SYC6T>AaHf7@7&uu^9S1E$ zL=i>5?r<;T+wA<<$YsHIS+^cqHCy2o-^Q|AbGw!(1%euTz=7MjVsU_VqbE5&$QMI~ zfIQw79hk~tU@^t4Y*Y$=JBrYjR-llzRb!!>pV>m8KLemXb9C9okaKZgFCTz*62c^B zjGtAV&{i6Fgc3GFoe>E}3fSp+-*+#M6MwZ0);;f$?fBucJb6n@Yw21 z@WP%AA4GyrYsqvup0e*`dA0CFqjfF`?Zk6~xiA(&*n_BbB0dGs2Avv#ZXE_6M~GLJ z1V+jLHrzT(@0`E2K_v>w2fcfoqAk#VG1(O$NO7fkUj_k8PK`Sb;6yjjctij7OB4m} z8!v`J@jEC+Va?Zbx)O~C*<%45&kC1#CVWT#Ws5h)wAd5k{-i<^sxbOCw2e?@WPFr| z$qhd?p}w4HhFDUy(L7%N9ur!#IOIUlyGlSy5bmo%)=^Q%X=@Z}DgbL#a4zrkWFip! zF86JxkR4C16BF5ZCd+EO^=$P3fco1V*F2lK&~o7ea7WkzB)RcAv*vH;jp~0 z>y}t%6c0N&-1VZ_;ha#TQj->)6<~Ay%-TsU1^jYIQvOtenCzFS+6OAXiQTg9?#$

    M+j4sc7@?u=^?q9iO)9cr8U#o_ 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 huiX?#yNCK%fUE=(^49PTweeH literal 0 HcmV?d00001 diff --git a/tests/storage/study_upgrader/upgrade_870/nominal_case/little_study_860.expected.zip b/tests/storage/study_upgrader/upgrade_870/nominal_case/little_study_860.expected.zip new file mode 100644 index 0000000000000000000000000000000000000000..e8e657ae9819a8c23051efbef4c69b82d5304191 GIT binary patch literal 126978 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 IK|%oVKjz2PHvj+t literal 0 HcmV?d00001 diff --git a/tests/storage/study_upgrader/upgrade_870/nominal_case/little_study_860.zip b/tests/storage/study_upgrader/upgrade_870/nominal_case/little_study_860.zip new file mode 100644 index 0000000000000000000000000000000000000000..ee4aef0d3469bf3ebd76dd6b56244d6d3cf4a4d1 GIT binary patch literal 126416 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 uZKCaZw8MZg|)!q+l55(B?ynF76^OZD< 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%0AT#XkpzsROgSOD;u;(5tMB8R+?!yo(88+ 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 Wh$A6y|M$<3Up<-tzyPEH0N{U5)ts#W literal 0 HcmV?d00001 diff --git a/tests/study/business/areas/test_thermal_management.py b/tests/study/business/areas/test_thermal_management.py index a0f7183542..dd52ec9538 100644 --- a/tests/study/business/areas/test_thermal_management.py +++ b/tests/study/business/areas/test_thermal_management.py @@ -23,6 +23,34 @@ from tests.study.business.areas.assets import ASSETS_DIR +class TestThermalClusterGroup: + """ + Tests for the `ThermalClusterGroup` enumeration. + """ + + def test_nominal_case(self): + """ + When a group is read from a INI file, the group should be the same as the one in the file. + """ + group = ThermalClusterGroup("gas") # different case: original is "Gas" + assert group == ThermalClusterGroup.GAS + + def test_unknown(self): + """ + When an unknown group is read from a INI file, the group should be `OTHER1`. + Note that this is the current behavior in Antares Solver. + """ + group = ThermalClusterGroup("unknown") + assert group == ThermalClusterGroup.OTHER1 + + def test_invalid_type(self): + """ + When an invalid type is used to create a group, a `ValueError` should be raised. + """ + with pytest.raises(ValueError): + ThermalClusterGroup(123) + + @pytest.fixture(name="zip_legacy_path") def zip_legacy_path_fixture(tmp_path: Path) -> Path: target_dir = tmp_path.joinpath("resources") @@ -141,6 +169,10 @@ def test_get_cluster__study_legacy( "op3": None, "op4": None, "op5": None, + # These values are also None as they are defined in v8.7+ + "costGeneration": None, + "efficiency": None, + "variableOMCost": None, } assert actual == expected @@ -203,6 +235,9 @@ def test_get_clusters__study_legacy( "op3": None, "op4": None, "op5": None, + "costGeneration": None, + "efficiency": None, + "variableOMCost": None, }, { "id": "on and must 2", @@ -239,6 +274,9 @@ def test_get_clusters__study_legacy( "op3": None, "op4": None, "op5": None, + "costGeneration": None, + "efficiency": None, + "variableOMCost": None, }, { "id": "2 avail and must 2", @@ -275,6 +313,9 @@ def test_get_clusters__study_legacy( "op3": None, "op4": None, "op5": None, + "costGeneration": None, + "efficiency": None, + "variableOMCost": None, }, ] assert actual == expected @@ -343,6 +384,9 @@ def test_create_cluster__study_legacy( "pm25": None, "pm5": None, "so2": None, + "costGeneration": None, + "efficiency": None, + "variableOMCost": None, "spinning": 0.0, "spreadCost": 0.0, "startupCost": 0.0, @@ -407,6 +451,10 @@ def test_update_cluster( "op3": None, "op4": None, "op5": None, + # These values are also None as they are defined in v8.7+ + "costGeneration": None, + "efficiency": None, + "variableOMCost": None, } assert actual == expected diff --git a/tests/study/storage/variantstudy/business/test_matrix_constants_generator.py b/tests/study/storage/variantstudy/business/test_matrix_constants_generator.py index a216571510..da5ebd91d6 100644 --- a/tests/study/storage/variantstudy/business/test_matrix_constants_generator.py +++ b/tests/study/storage/variantstudy/business/test_matrix_constants_generator.py @@ -46,7 +46,7 @@ def test_get_st_storage(self, tmp_path): matrix_dto5 = generator.matrix_service.get(matrix_id5) assert np.array(matrix_dto5.data).all() == matrix_constants.st_storage.series.inflows.all() - def test_get_binding_constraint(self, tmp_path): + def test_get_binding_constraint_before_v87(self, tmp_path): matrix_content_repository = MatrixContentRepository( bucket_dir=tmp_path, ) @@ -56,19 +56,14 @@ def test_get_binding_constraint(self, tmp_path): ) ) generator.init_constant_matrices() - series = matrix_constants.binding_constraint.series + series = matrix_constants.binding_constraint.series_before_v87 - hourly = generator.get_binding_constraint_hourly() + hourly = generator.get_binding_constraint_hourly_86() hourly_matrix_id = hourly.split(MATRIX_PROTOCOL_PREFIX)[1] hourly_matrix_dto = generator.matrix_service.get(hourly_matrix_id) assert np.array(hourly_matrix_dto.data).all() == series.default_bc_hourly.all() - daily = generator.get_binding_constraint_daily() - daily_matrix_id = daily.split(MATRIX_PROTOCOL_PREFIX)[1] - daily_matrix_dto = generator.matrix_service.get(daily_matrix_id) - assert np.array(daily_matrix_dto.data).all() == series.default_bc_weekly_daily.all() - - weekly = generator.get_binding_constraint_weekly() - weekly_matrix_id = weekly.split(MATRIX_PROTOCOL_PREFIX)[1] - weekly_matrix_dto = generator.matrix_service.get(weekly_matrix_id) - assert np.array(weekly_matrix_dto.data).all() == series.default_bc_weekly_daily.all() + daily_weekly = generator.get_binding_constraint_daily_weekly_86() + matrix_id = daily_weekly.split(MATRIX_PROTOCOL_PREFIX)[1] + matrix_dto = generator.matrix_service.get(matrix_id) + assert np.array(matrix_dto.data).all() == series.default_bc_weekly_daily.all() diff --git a/tests/study/storage/variantstudy/model/test_dbmodel.py b/tests/study/storage/variantstudy/model/test_dbmodel.py index 1e18e4fef1..6ed1bbcba1 100644 --- a/tests/study/storage/variantstudy/model/test_dbmodel.py +++ b/tests/study/storage/variantstudy/model/test_dbmodel.py @@ -156,7 +156,7 @@ def test_init(self, db_session: Session, variant_study_id: str) -> None: "id": command_id, "action": command, "args": json.loads(args), - "version": 1, + "version": 42, } diff --git a/tests/variantstudy/conftest.py b/tests/variantstudy/conftest.py index 011a6bb68d..e963c83879 100644 --- a/tests/variantstudy/conftest.py +++ b/tests/variantstudy/conftest.py @@ -70,11 +70,23 @@ def delete(matrix_id: str) -> None: """ del matrix_map[matrix_id] + def get_matrix_id(matrix: t.Union[t.List[t.List[float]], str]) -> str: + """ + Get the matrix ID from a matrix or a matrix link. + """ + if isinstance(matrix, str): + return matrix.lstrip("matrix://") + elif isinstance(matrix, list): + return create(matrix) + else: + raise TypeError(f"Invalid type for matrix: {type(matrix)}") + matrix_service = Mock(spec=MatrixService) matrix_service.create.side_effect = create matrix_service.get.side_effect = get matrix_service.exists.side_effect = exists matrix_service.delete.side_effect = delete + matrix_service.get_matrix_id.side_effect = get_matrix_id return matrix_service diff --git a/tests/variantstudy/model/command/test_manage_binding_constraints.py b/tests/variantstudy/model/command/test_manage_binding_constraints.py index c28b50b69d..bab8ff1681 100644 --- a/tests/variantstudy/model/command/test_manage_binding_constraints.py +++ b/tests/variantstudy/model/command/test_manage_binding_constraints.py @@ -7,7 +7,7 @@ from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy from antarest.study.storage.variantstudy.business.command_extractor import CommandExtractor from antarest.study.storage.variantstudy.business.command_reverter import CommandReverter -from antarest.study.storage.variantstudy.business.matrix_constants.binding_constraint.series import ( +from antarest.study.storage.variantstudy.business.matrix_constants.binding_constraint.series_before_v87 import ( default_bc_hourly, default_bc_weekly_daily, ) @@ -350,13 +350,14 @@ def test_create_diff(command_context: CommandContext): ) values_b = np.random.rand(8784, 3).tolist() + matrix_b_id = command_context.matrix_service.create(values_b) other_match = CreateBindingConstraint( name="foo", enabled=True, time_step=BindingConstraintFrequency.HOURLY, operator=BindingConstraintOperator.EQUAL, coeffs={"b": [0.3]}, - values=values_b, + values=matrix_b_id, command_context=command_context, ) assert base.create_diff(other_match) == [ @@ -366,7 +367,7 @@ def test_create_diff(command_context: CommandContext): time_step=BindingConstraintFrequency.HOURLY, operator=BindingConstraintOperator.EQUAL, coeffs={"b": [0.3]}, - values=values_b, + values=matrix_b_id, command_context=command_context, ) ] diff --git a/tests/variantstudy/test_command_factory.py b/tests/variantstudy/test_command_factory.py index aac2be6c59..64ec3b799f 100644 --- a/tests/variantstudy/test_command_factory.py +++ b/tests/variantstudy/test_command_factory.py @@ -402,27 +402,32 @@ def setup_class(self): ) @pytest.mark.unit_test def test_command_factory(self, command_dto: CommandDTO): + def get_matrix_id(matrix: str) -> str: + return matrix.lstrip("matrix://") + command_factory = CommandFactory( generator_matrix_constants=Mock(spec=GeneratorMatrixConstants), - matrix_service=Mock(spec=MatrixService), + matrix_service=Mock(spec=MatrixService, get_matrix_id=get_matrix_id), patch_service=Mock(spec=PatchService), ) commands = command_factory.to_command(command_dto=command_dto) if isinstance(command_dto.args, dict): - exp_action_args_list = [(command_dto.action, command_dto.args)] + exp_action_args_list = [(command_dto.action, command_dto.args, command_dto.version)] else: - exp_action_args_list = [(command_dto.action, args) for args in command_dto.args] + exp_action_args_list = [(command_dto.action, args, command_dto.version) for args in command_dto.args] actual_cmd: ICommand - for actual_cmd, exp_action_args in itertools.zip_longest(commands, exp_action_args_list): - assert actual_cmd is not None, f"Missing action/args for {exp_action_args=}" - assert exp_action_args is not None, f"Missing command for {actual_cmd=}" - expected_action, expected_args = exp_action_args + for actual_cmd, exp_action_args_version in itertools.zip_longest(commands, exp_action_args_list): + assert actual_cmd is not None, f"Missing action/args for {exp_action_args_version=}" + assert exp_action_args_version is not None, f"Missing command for {actual_cmd=}" + expected_action, expected_args, expected_version = exp_action_args_version actual_dto = actual_cmd.to_dto() actual_args = {k: v for k, v in actual_dto.args.items() if v is not None} + actual_version = actual_dto.version assert actual_dto.action == expected_action assert actual_args == expected_args + assert actual_version == expected_version self.command_class_set.discard(type(commands[0]).__name__) From ed1317278b2334a97d6a2233d6ef702928933de2 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 15 Mar 2024 09:23:19 +0100 Subject: [PATCH 066/248] fix(commands): added support for removing BC matrices in v8.7 when deleting a thermal cluster --- .../model/command/remove_cluster.py | 68 ++++++++++++------ tests/variantstudy/assets/empty_study_870.zip | Bin 0 -> 64653 bytes tests/variantstudy/conftest.py | 17 ++++- .../model/command/test_remove_cluster.py | 14 ++++ 4 files changed, 74 insertions(+), 25 deletions(-) create mode 100644 tests/variantstudy/assets/empty_study_870.zip diff --git a/antarest/study/storage/variantstudy/model/command/remove_cluster.py b/antarest/study/storage/variantstudy/model/command/remove_cluster.py index 095e62f526..fbefaea312 100644 --- a/antarest/study/storage/variantstudy/model/command/remove_cluster.py +++ b/antarest/study/storage/variantstudy/model/command/remove_cluster.py @@ -134,26 +134,50 @@ def _create_diff(self, other: "ICommand") -> t.List["ICommand"]: def get_inner_matrices(self) -> t.List[str]: return [] - # noinspection SpellCheckingInspection def _remove_cluster_from_binding_constraints(self, study_data: FileStudy) -> None: - config = study_data.tree.get(["input", "bindingconstraints", "bindingconstraints"]) - - # Binding constraints IDs to remove - ids_to_remove = set() - - # Cluster IDs are stored in lower case in the binding contraints configuration file. - cluster_id = self.cluster_id.lower() - for bc_id, bc_props in config.items(): - if f"{self.area_id}.{cluster_id}" in bc_props.keys(): - ids_to_remove.add(bc_id) - - for bc_id in ids_to_remove: - study_data.tree.delete(["input", "bindingconstraints", config[bc_id]["id"]]) - bc = next(iter([bind for bind in study_data.config.bindings if bind.id == config[bc_id]["id"]])) - study_data.config.bindings.remove(bc) - del config[bc_id] - - study_data.tree.save( - config, - ["input", "bindingconstraints", "bindingconstraints"], - ) + """ + Remove the binding constraints that are related to the thermal cluster. + + Notes: + A binding constraint has properties, a list of terms (which form a linear equation) and + a right-hand side (which is the matrix of the binding constraint). + The terms are of the form `area1%area2` or `area.cluster` where `area` is the ID of the area + and `cluster` is the ID of the cluster. + + When a thermal cluster is removed, it has an impact on the terms of the binding constraints. + At first, we could decide to remove the terms that are related to the area. + However, this would lead to a linear equation that is not valid anymore. + + Instead, we decide to remove the binding constraints that are related to the cluster. + """ + # See also `RemoveCluster` + # noinspection SpellCheckingInspection + url = ["input", "bindingconstraints", "bindingconstraints"] + binding_constraints = study_data.tree.get(url) + + # Collect the binding constraints that are related to the area to remove + # by searching the terms that contain the ID of the area. + bc_to_remove = {} + lower_area_id = self.area_id.lower() + lower_cluster_id = self.cluster_id.lower() + for bc_index, bc in list(binding_constraints.items()): + for key in bc: + if "." not in key: + # This key identifies a link or belongs to the set of properties. + # It isn't a cluster ID, so we skip it. + continue + # Term IDs are in the form `area1%area2` or `area.cluster` + # noinspection PyTypeChecker + related_area_id, related_cluster_id = map(str.lower, key.split(".")) + if (lower_area_id, lower_cluster_id) == (related_area_id, related_cluster_id): + bc_to_remove[bc_index] = binding_constraints.pop(bc_index) + break + + matrix_suffixes = ["_lt", "_gt", "_eq"] if study_data.config.version >= 870 else [""] + + for bc_index, bc in bc_to_remove.items(): + for suffix in matrix_suffixes: + # noinspection SpellCheckingInspection + study_data.tree.delete(["input", "bindingconstraints", f"{bc['id']}{suffix}"]) + + study_data.tree.save(binding_constraints, url) diff --git a/tests/variantstudy/assets/empty_study_870.zip b/tests/variantstudy/assets/empty_study_870.zip new file mode 100644 index 0000000000000000000000000000000000000000..78381529946171a8e3ac0b1be0c8c301d0b8aeaa GIT binary patch literal 64653 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 ubCjt^V? literal 0 HcmV?d00001 diff --git a/tests/variantstudy/conftest.py b/tests/variantstudy/conftest.py index e963c83879..1bcbb13cb1 100644 --- a/tests/variantstudy/conftest.py +++ b/tests/variantstudy/conftest.py @@ -8,6 +8,12 @@ import numpy.typing as npt import pytest +from antarest.study.storage.study_upgrader import get_current_version + +if t.TYPE_CHECKING: + # noinspection PyPackageRequirements + from _pytest.fixtures import SubRequest + from antarest.matrixstore.model import MatrixDTO from antarest.matrixstore.service import MatrixService from antarest.matrixstore.uri_resolver_service import UriResolverService @@ -134,27 +140,32 @@ def command_factory_fixture(matrix_service: MatrixService) -> CommandFactory: @pytest.fixture(name="empty_study") -def empty_study_fixture(tmp_path: Path, matrix_service: MatrixService) -> FileStudy: +def empty_study_fixture(request: "SubRequest", tmp_path: Path, matrix_service: MatrixService) -> FileStudy: """ Fixture for creating an empty FileStudy object. Args: + request: pytest's request object. tmp_path: The temporary path for extracting the empty study. matrix_service: The MatrixService object. Returns: FileStudy: The empty FileStudy object. """ - empty_study_path: Path = ASSETS_DIR / "empty_study_720.zip" + zip_name = getattr(request, "param", "empty_study_720.zip") + empty_study_path: Path = ASSETS_DIR / zip_name empty_study_destination_path = tmp_path.joinpath("empty-study") with zipfile.ZipFile(empty_study_path, "r") as zip_empty_study: zip_empty_study.extractall(empty_study_destination_path) + # Detect the version of the study from `study.antares` file. + version = get_current_version(empty_study_destination_path) + config = FileStudyTreeConfig( study_path=empty_study_destination_path, path=empty_study_destination_path, study_id="", - version=720, + version=int(version), areas={}, sets={}, ) diff --git a/tests/variantstudy/model/command/test_remove_cluster.py b/tests/variantstudy/model/command/test_remove_cluster.py index 99333d811a..faae51f5c7 100644 --- a/tests/variantstudy/model/command/test_remove_cluster.py +++ b/tests/variantstudy/model/command/test_remove_cluster.py @@ -1,3 +1,5 @@ +import numpy as np +import pytest from checksumdir import dirhash from antarest.study.storage.rawstudy.model.filesystem.config.binding_constraint import BindingConstraintFrequency @@ -13,6 +15,7 @@ class TestRemoveCluster: + @pytest.mark.parametrize("empty_study", ["empty_study_720.zip", "empty_study_870.zip"], indirect=True) def test_apply(self, empty_study: FileStudy, command_context: CommandContext): area_name = "Area_name" area_id = transform_name_to_id(area_name) @@ -39,6 +42,15 @@ def test_apply(self, empty_study: FileStudy, command_context: CommandContext): modulation=[[0]], ).apply(empty_study) + # Binding constraint 2nd member: array of shape (8784, 3) + array = np.random.rand(8784, 3) * 1000 + if empty_study.config.version < 870: + values = array.tolist() + less_term_matrix = None + else: + values = None + less_term_matrix = array.tolist() + bind1_cmd = CreateBindingConstraint( name="BD 1", time_step=BindingConstraintFrequency.HOURLY, @@ -48,6 +60,8 @@ def test_apply(self, empty_study: FileStudy, command_context: CommandContext): }, comments="Hello", command_context=command_context, + values=values, + less_term_matrix=less_term_matrix, ) output = bind1_cmd.apply(study_data=empty_study) assert output.status From 8b8f40d9471674b1c2a042d2d3cb47aed9a3330a Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 15 Mar 2024 09:37:47 +0100 Subject: [PATCH 067/248] test(commands): improve unit tests on BC management --- .../test_manage_binding_constraints.py | 82 +++++++++++-------- 1 file changed, 46 insertions(+), 36 deletions(-) diff --git a/tests/variantstudy/model/command/test_manage_binding_constraints.py b/tests/variantstudy/model/command/test_manage_binding_constraints.py index bab8ff1681..aab13307b1 100644 --- a/tests/variantstudy/model/command/test_manage_binding_constraints.py +++ b/tests/variantstudy/model/command/test_manage_binding_constraints.py @@ -1,12 +1,16 @@ from unittest.mock import Mock import numpy as np +import pytest from antarest.study.storage.rawstudy.ini_reader import IniReader from antarest.study.storage.rawstudy.model.filesystem.config.binding_constraint import BindingConstraintFrequency from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy from antarest.study.storage.variantstudy.business.command_extractor import CommandExtractor from antarest.study.storage.variantstudy.business.command_reverter import CommandReverter +from antarest.study.storage.variantstudy.business.matrix_constants.binding_constraint.series_after_v87 import ( + default_bc_weekly_daily as default_bc_weekly_daily_870, +) from antarest.study.storage.variantstudy.business.matrix_constants.binding_constraint.series_before_v87 import ( default_bc_hourly, default_bc_weekly_daily, @@ -24,41 +28,18 @@ # noinspection SpellCheckingInspection -def test_manage_binding_constraint( - empty_study: FileStudy, - command_context: CommandContext, -): +@pytest.mark.parametrize("empty_study", ["empty_study_720.zip", "empty_study_870.zip"], indirect=True) +def test_manage_binding_constraint(empty_study: FileStudy, command_context: CommandContext): study_path = empty_study.config.study_path area1 = "area1" area2 = "area2" cluster = "cluster" - CreateArea.parse_obj( - { - "area_name": area1, - "command_context": command_context, - } - ).apply(empty_study) - CreateArea.parse_obj( - { - "area_name": area2, - "command_context": command_context, - } - ).apply(empty_study) - CreateLink.parse_obj( - { - "area1": area1, - "area2": area2, - "command_context": command_context, - } - ).apply(empty_study) + CreateArea.parse_obj({"area_name": area1, "command_context": command_context}).apply(empty_study) + CreateArea.parse_obj({"area_name": area2, "command_context": command_context}).apply(empty_study) + CreateLink.parse_obj({"area1": area1, "area2": area2, "command_context": command_context}).apply(empty_study) CreateCluster.parse_obj( - { - "area_id": area1, - "cluster_name": cluster, - "parameters": {}, - "command_context": command_context, - } + {"area_id": area1, "cluster_name": cluster, "parameters": {}, "command_context": command_context} ).apply(empty_study) bind1_cmd = CreateBindingConstraint( @@ -83,10 +64,18 @@ def test_manage_binding_constraint( res2 = bind2_cmd.apply(empty_study) assert res2.status - bc1_matrix_path = study_path / "input/bindingconstraints/bd 1.txt.link" - bc2_matrix_path = study_path / "input/bindingconstraints/bd 2.txt.link" - assert bc1_matrix_path.exists() - assert bc2_matrix_path.exists() + if empty_study.config.version < 870: + matrix_links = ["bd 1.txt.link", "bd 2.txt.link"] + else: + matrix_links = [ + # fmt: off + "bd 1_lt.txt.link", "bd 1_eq.txt.link", "bd 1_gt.txt.link", + "bd 2_lt.txt.link", "bd 2_eq.txt.link", "bd 2_gt.txt.link", + # fmt: on + ] + for matrix_link in matrix_links: + link_path = study_path / f"input/bindingconstraints/{matrix_link}" + assert link_path.exists(), f"Missing matrix link: {matrix_link!r}" cfg_path = study_path / "input/bindingconstraints/bindingconstraints.ini" bd_config = IniReader().read(cfg_path) @@ -108,14 +97,26 @@ def test_manage_binding_constraint( "type": "daily", } - weekly_values = default_bc_weekly_daily.tolist() + if empty_study.config.version < 870: + weekly_values = default_bc_weekly_daily.tolist() + values = weekly_values + less_term_matrix = None + greater_term_matrix = None + else: + weekly_values = default_bc_weekly_daily_870.tolist() + values = None + less_term_matrix = weekly_values + greater_term_matrix = weekly_values + bind_update = UpdateBindingConstraint( id="bd 1", enabled=False, time_step=BindingConstraintFrequency.WEEKLY, operator=BindingConstraintOperator.BOTH, coeffs={"area1%area2": [800, 30]}, - values=weekly_values, + values=values, + less_term_matrix=less_term_matrix, + greater_term_matrix=greater_term_matrix, command_context=command_context, ) res = bind_update.apply(empty_study) @@ -133,7 +134,16 @@ def test_manage_binding_constraint( remove_bind = RemoveBindingConstraint(id="bd 1", command_context=command_context) res3 = remove_bind.apply(empty_study) assert res3.status - assert not bc1_matrix_path.exists() + + for matrix_link in matrix_links: + link_path = study_path / f"input/bindingconstraints/{matrix_link}" + if matrix_link.startswith("bd 1"): + assert not link_path.exists(), f"Matrix link not removed: {matrix_link!r}" + elif matrix_link.startswith("bd 2"): + assert link_path.exists(), f"Matrix link removed: {matrix_link!r}" + else: + raise NotImplementedError(f"Unexpected matrix link: {matrix_link!r}") + bd_config = IniReader().read(cfg_path) assert len(bd_config) == 1 assert bd_config.get("0") == { From b8bc294214acca0aacecbbaecc98c10b4bfd7083 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 15 Mar 2024 09:44:43 +0100 Subject: [PATCH 068/248] test(commands): improve unit tests for `remove_area` command --- .../variantstudy/model/command/remove_area.py | 5 ++++- tests/variantstudy/assets/empty_study_810.zip | Bin 0 -> 62028 bytes tests/variantstudy/assets/empty_study_840.zip | Bin 0 -> 61082 bytes .../model/command/test_remove_area.py | 15 +++------------ 4 files changed, 7 insertions(+), 13 deletions(-) create mode 100644 tests/variantstudy/assets/empty_study_810.zip create mode 100644 tests/variantstudy/assets/empty_study_840.zip diff --git a/antarest/study/storage/variantstudy/model/command/remove_area.py b/antarest/study/storage/variantstudy/model/command/remove_area.py index a93c3bcd84..8703137a5a 100644 --- a/antarest/study/storage/variantstudy/model/command/remove_area.py +++ b/antarest/study/storage/variantstudy/model/command/remove_area.py @@ -93,6 +93,7 @@ def _remove_area_from_binding_constraints(self, study_data: FileStudy) -> None: Instead, we decide to remove the binding constraints that are related to the area. """ + # See also `RemoveArea` # noinspection SpellCheckingInspection url = ["input", "bindingconstraints", "bindingconstraints"] binding_constraints = study_data.tree.get(url) @@ -100,6 +101,7 @@ def _remove_area_from_binding_constraints(self, study_data: FileStudy) -> None: # Collect the binding constraints that are related to the area to remove # by searching the terms that contain the ID of the area. bc_to_remove = {} + lower_area_id = self.id.lower() for bc_index, bc in list(binding_constraints.items()): for key in bc: # Term IDs are in the form `area1%area2` or `area.cluster` @@ -110,7 +112,8 @@ def _remove_area_from_binding_constraints(self, study_data: FileStudy) -> None: else: # This key belongs to the set of properties, it isn't a term ID, so we skip it continue - if self.id.lower() in related_areas: + related_areas = [area.lower() for area in related_areas] + if lower_area_id in related_areas: bc_to_remove[bc_index] = binding_constraints.pop(bc_index) break diff --git a/tests/variantstudy/assets/empty_study_810.zip b/tests/variantstudy/assets/empty_study_810.zip new file mode 100644 index 0000000000000000000000000000000000000000..5d354af139c5f4a60623a83ec1fbecd634518312 GIT binary patch literal 62028 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 literal 0 HcmV?d00001 diff --git a/tests/variantstudy/assets/empty_study_840.zip b/tests/variantstudy/assets/empty_study_840.zip new file mode 100644 index 0000000000000000000000000000000000000000..6954ff5841715c14cbdb98d279304897fdbfccf8 GIT binary patch literal 61082 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

    C1@h~wX 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 fW&eS{`c?n#O0iLMBLKjUx^= 830: + if empty_study.config.version >= 830: empty_study_cfg["input"]["areas"][area_id]["adequacy_patch"] = { "adequacy-patch": {"adequacy-patch-mode": "outside"} } @@ -84,7 +76,6 @@ def test_apply( area_name2 = "Area2" area_id2 = transform_name_to_id(area_name2) - empty_study.config.version = version create_area_command: ICommand = CreateArea.parse_obj( { "area_name": area_name2, From 93e1e694bf1d0c51e5cbb6478eb8c18110e5c09a Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 15 Mar 2024 17:08:59 +0100 Subject: [PATCH 069/248] feat(commands): binding constraint commands can accept matrix names in camelCase --- .../variantstudy/model/command/create_binding_constraint.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 495b48e557..75fe563911 100644 --- a/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py +++ b/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py @@ -84,7 +84,7 @@ class BindingConstraintProperties870(BindingConstraintProperties): group: t.Optional[str] = None -class BindingConstraintMatrices(BaseModel, extra=Extra.forbid): +class BindingConstraintMatrices(BaseModel, extra=Extra.forbid, allow_population_by_field_name=True): """ Class used to store the matrices of a binding constraint. """ @@ -96,14 +96,17 @@ class BindingConstraintMatrices(BaseModel, extra=Extra.forbid): less_term_matrix: t.Optional[t.Union[MatrixType, str]] = Field( None, description="less term matrix for v8.7+ studies", + alias="lessTermMatrix", ) greater_term_matrix: t.Optional[t.Union[MatrixType, str]] = Field( None, description="greater term matrix for v8.7+ studies", + alias="greaterTermMatrix", ) equal_term_matrix: t.Optional[t.Union[MatrixType, str]] = Field( None, description="equal term matrix for v8.7+ studies", + alias="equalTermMatrix", ) @root_validator(pre=True) From fc31eb5a3fdb39cfed23891d6f25d53bd424dd2c Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 15 Mar 2024 17:10:07 +0100 Subject: [PATCH 070/248] test(api-thermals): add case with a BC referencing a thermal --- .../study_data_blueprint/test_thermal.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/integration/study_data_blueprint/test_thermal.py b/tests/integration/study_data_blueprint/test_thermal.py index a44d7058ac..a164b58d8f 100644 --- a/tests/integration/study_data_blueprint/test_thermal.py +++ b/tests/integration/study_data_blueprint/test_thermal.py @@ -530,6 +530,29 @@ def test_lifecycle( # THERMAL CLUSTER DELETION # ============================= + # Here is a Binding Constraint that references the thermal cluster.: + bc_obj = { + "name": "Binding Constraint", + "enabled": True, + "time_step": "hourly", + "operator": "less", + "coeffs": {f"{area_id}.{fr_gas_conventional_id.lower()}": [2.0, 4]}, + "comments": "New API", + } + matrix = np.random.randint(0, 1000, size=(8784, 3)) + if version < 870: + bc_obj["values"] = matrix.tolist() + else: + bc_obj["lessTermMatrix"] = matrix.tolist() + + # noinspection SpellCheckingInspection + res = client.post( + f"/v1/studies/{study_id}/bindingconstraints", + json=bc_obj, + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code in {200, 201}, res.json() + # To delete a thermal cluster, we need to provide its ID. res = client.request( "DELETE", @@ -540,6 +563,15 @@ def test_lifecycle( assert res.status_code == 204, res.json() assert res.text in {"", "null"} # Old FastAPI versions return 'null'. + # When we delete a thermal cluster, we should also delete the binding constraints that reference it. + # noinspection SpellCheckingInspection + res = client.get( + f"/v1/studies/{study_id}/bindingconstraints", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == 200, res.json() + assert len(res.json()) == 0 + # If the thermal cluster list is empty, the deletion should be a no-op. res = client.request( "DELETE", From 019f2bd1e8ded3f3f1bc293c8c620314476ae346 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Thu, 18 Jan 2024 14:04:33 +0100 Subject: [PATCH 071/248] refactor(api): refactor and rename the exceptions related to `.ini` file configuration --- antarest/core/exceptions.py | 247 ++++++++++++++---- .../business/areas/renewable_management.py | 18 +- .../business/areas/st_storage_management.py | 105 ++++---- .../business/areas/thermal_management.py | 16 +- .../study_data_blueprint/test_renewable.py | 6 +- .../study_data_blueprint/test_st_storage.py | 12 +- .../study_data_blueprint/test_thermal.py | 6 +- .../areas/test_st_storage_management.py | 51 ++-- 8 files changed, 292 insertions(+), 169 deletions(-) diff --git a/antarest/core/exceptions.py b/antarest/core/exceptions.py index dd9cd45a5e..079263ae91 100644 --- a/antarest/core/exceptions.py +++ b/antarest/core/exceptions.py @@ -1,3 +1,4 @@ +import re from http import HTTPStatus from typing import Optional @@ -8,64 +9,227 @@ class ShouldNotHappenException(Exception): pass -class STStorageFieldsNotFoundError(HTTPException): - """Fields of the short-term storage are not found""" +# ============================================================ +# Exceptions related to the study configuration (`.ini` files) +# ============================================================ - def __init__(self, storage_id: str) -> None: - detail = f"Fields of storage '{storage_id}' not found" - super().__init__(HTTPStatus.NOT_FOUND, detail) +# Naming convention for exceptions related to the study configuration: +# +# | Topic | NotFound (404) | Duplicate (409) | Invalid (422) | +# |---------------|-----------------------|------------------------|----------------------| +# | ConfigFile | ConfigFileNotFound | N/A | InvalidConfigFile | +# | ConfigSection | ConfigSectionNotFound | DuplicateConfigSection | InvalidConfigSection | +# | ConfigOption | ConfigOptionNotFound | DuplicateConfigOption | InvalidConfigOption | +# | Matrix | MatrixNotFound | DuplicateMatrix | InvalidMatrix | - def __str__(self) -> str: - return self.detail +THERMAL_CLUSTER = "thermal cluster" +RENEWABLE_CLUSTER = "renewable cluster" +SHORT_TERM_STORAGE = "short-term storage" + +# ============================================================ +# NotFound (404) +# ============================================================ + +_match_input_path = re.compile(r"input(?:/[\w*-]+)+").fullmatch -class STStorageMatrixNotFoundError(HTTPException): - """Matrix of the short-term storage is not found""" - def __init__(self, study_id: str, area_id: str, storage_id: str, ts_name: str) -> None: - detail = f"Time series '{ts_name}' of storage '{storage_id}' not found" +class ConfigFileNotFound(HTTPException): + """ + Exception raised when a configuration file is not found (404 Not Found). + + Notes: + The study ID is not provided because it is implicit. + + Attributes: + path: Path of the missing file(s) relative to the study directory. + area_ids: Sequence of area IDs for which the file(s) is/are missing. + """ + + object_name = "" + """Name of the object that is not found: thermal, renewables, etc.""" + + def __init__(self, path: str, *area_ids: str): + assert _match_input_path(path), f"Invalid path: '{path}'" + self.path = path + self.area_ids = area_ids + ids = ", ".join(f"'{a}'" for a in area_ids) + detail = { + 0: f"Path '{path}' not found", + 1: f"Path '{path}' not found for area {ids}", + 2: f"Path '{path}' not found for areas {ids}", + }[min(len(area_ids), 2)] + if self.object_name: + detail = f"{self.object_name.title()} {detail}" super().__init__(HTTPStatus.NOT_FOUND, detail) def __str__(self) -> str: + """Return a string representation of the exception.""" return self.detail -class STStorageConfigNotFoundError(HTTPException): - """Configuration for short-term storage is not found""" +class ThermalClusterConfigNotFound(ConfigFileNotFound): + """Configuration for thermal cluster is not found (404 Not Found)""" + + object_name = THERMAL_CLUSTER + + +class RenewableClusterConfigNotFound(ConfigFileNotFound): + """Configuration for renewable cluster is not found (404 Not Found)""" + + object_name = RENEWABLE_CLUSTER + + +class STStorageConfigNotFound(ConfigFileNotFound): + """Configuration for short-term storage is not found (404 Not Found)""" + + object_name = SHORT_TERM_STORAGE + + +class ConfigSectionNotFound(HTTPException): + """ + Exception raised when a configuration section is not found (404 Not Found). - def __init__(self, study_id: str, area_id: str) -> None: - detail = f"The short-term storage configuration of area '{area_id}' not found" + Notes: + The study ID is not provided because it is implicit. + + Attributes: + path: Path of the missing file(s) relative to the study directory. + section_id: ID of the missing section. + """ + + object_name = "" + """Name of the object that is not found: thermal, renewables, etc.""" + + def __init__(self, path: str, section_id: str): + assert _match_input_path(path), f"Invalid path: '{path}'" + self.path = path + self.section_id = section_id + object_name = self.object_name or "section" + detail = f"{object_name.title()} '{section_id}' not found in '{path}'" super().__init__(HTTPStatus.NOT_FOUND, detail) def __str__(self) -> str: + """Return a string representation of the exception.""" return self.detail -class STStorageNotFoundError(HTTPException): - """Short-term storage is not found""" +class ThermalClusterNotFound(ConfigSectionNotFound): + """Thermal cluster is not found (404 Not Found)""" + + object_name = THERMAL_CLUSTER + + +class RenewableClusterNotFound(ConfigSectionNotFound): + """Renewable cluster is not found (404 Not Found)""" + + object_name = RENEWABLE_CLUSTER + + +class STStorageNotFound(ConfigSectionNotFound): + """Short-term storage is not found (404 Not Found)""" + + object_name = SHORT_TERM_STORAGE - def __init__(self, study_id: str, area_id: str, st_storage_id: str) -> None: - detail = f"Short-term storage '{st_storage_id}' not found in area '{area_id}'" + +class MatrixNotFound(HTTPException): + """ + Exception raised when a matrix is not found (404 Not Found). + + Notes: + The study ID is not provided because it is implicit. + + Attributes: + path: Path of the missing file(s) relative to the study directory. + """ + + object_name = "" + """Name of the object that is not found: thermal, renewables, etc.""" + + def __init__(self, path: str): + assert _match_input_path(path), f"Invalid path: '{path}'" + self.path = path + detail = f"Matrix '{path}' not found" + if self.object_name: + detail = f"{self.object_name.title()} {detail}" super().__init__(HTTPStatus.NOT_FOUND, detail) def __str__(self) -> str: return self.detail -class DuplicateSTStorageId(HTTPException): - """Exception raised when trying to create a short-term storage with an already existing id.""" +class ThermalClusterMatrixNotFound(MatrixNotFound): + """Matrix of the thermal cluster is not found (404 Not Found)""" + + object_name = THERMAL_CLUSTER + + +class RenewableClusterMatrixNotFound(MatrixNotFound): + """Matrix of the renewable cluster is not found (404 Not Found)""" + + object_name = RENEWABLE_CLUSTER - def __init__(self, study_id: str, area_id: str, st_storage_id: str) -> None: - detail = f"Short term storage '{st_storage_id}' already exists in area '{area_id}'" + +class STStorageMatrixNotFound(MatrixNotFound): + """Matrix of the short-term storage is not found (404 Not Found)""" + + object_name = SHORT_TERM_STORAGE + + +# ============================================================ +# Duplicate (409) +# ============================================================ + + +class DuplicateConfigSection(HTTPException): + """ + Exception raised when a configuration section is duplicated (409 Conflict). + + Notes: + The study ID is not provided because it is implicit. + + Attributes: + area_id: ID of the area in which the section is duplicated. + duplicates: Sequence of duplicated IDs. + """ + + object_name = "" + """Name of the object that is duplicated: thermal, renewables, etc.""" + + def __init__(self, area_id: str, *duplicates: str): + self.area_id = area_id + self.duplicates = duplicates + ids = ", ".join(f"'{a}'" for a in duplicates) + detail = { + 0: f"Duplicates found in '{area_id}'", + 1: f"Duplicate found in '{area_id}': {ids}", + 2: f"Duplicates found in '{area_id}': {ids}", + }[min(len(duplicates), 2)] + if self.object_name: + detail = f"{self.object_name.title()} {detail}" super().__init__(HTTPStatus.CONFLICT, detail) def __str__(self) -> str: + """Return a string representation of the exception.""" return self.detail -class UnknownModuleError(Exception): - def __init__(self, message: str) -> None: - super(UnknownModuleError, self).__init__(message) +class DuplicateThermalCluster(DuplicateConfigSection): + """Duplicate Thermal cluster (409 Conflict)""" + + object_name = THERMAL_CLUSTER + + +class DuplicateRenewableCluster(DuplicateConfigSection): + """Duplicate Renewable cluster (409 Conflict)""" + + object_name = RENEWABLE_CLUSTER + + +class DuplicateSTStorage(DuplicateConfigSection): + """Duplicate Short-term storage (409 Conflict)""" + + object_name = SHORT_TERM_STORAGE class StudyNotFoundError(HTTPException): @@ -108,11 +272,6 @@ def __init__(self, message: str) -> None: super().__init__(HTTPStatus.LOCKED, message) -class StudyAlreadyExistError(HTTPException): - def __init__(self, message: str) -> None: - super().__init__(HTTPStatus.CONFLICT, message) - - class StudyValidationError(HTTPException): def __init__(self, message: str) -> None: super().__init__(HTTPStatus.UNPROCESSABLE_ENTITY, message) @@ -328,29 +487,3 @@ def __init__(self) -> None: HTTPStatus.BAD_REQUEST, "You cannot scan the default internal workspace", ) - - -class ClusterNotFound(HTTPException): - def __init__(self, cluster_id: str) -> None: - super().__init__( - HTTPStatus.NOT_FOUND, - f"Cluster: '{cluster_id}' not found", - ) - - -class ClusterConfigNotFound(HTTPException): - def __init__(self, area_id: str) -> None: - super().__init__( - HTTPStatus.NOT_FOUND, - f"Cluster configuration for area: '{area_id}' not found", - ) - - -class ClusterAlreadyExists(HTTPException): - """Exception raised when attempting to create a cluster with an already existing ID.""" - - def __init__(self, cluster_type: str, cluster_id: str) -> None: - super().__init__( - HTTPStatus.CONFLICT, - f"{cluster_type} cluster with ID '{cluster_id}' already exists and could not be created.", - ) diff --git a/antarest/study/business/areas/renewable_management.py b/antarest/study/business/areas/renewable_management.py index c4152924bf..7858409f17 100644 --- a/antarest/study/business/areas/renewable_management.py +++ b/antarest/study/business/areas/renewable_management.py @@ -3,7 +3,7 @@ from pydantic import validator -from antarest.core.exceptions import ClusterAlreadyExists, ClusterConfigNotFound, ClusterNotFound +from antarest.core.exceptions import DuplicateRenewableCluster, RenewableClusterConfigNotFound, RenewableClusterNotFound from antarest.study.business.enum_ignore_case import EnumIgnoreCase from antarest.study.business.utils import AllOptionalMetaclass, camel_case_model, execute_or_add_commands from antarest.study.model import Study @@ -132,7 +132,7 @@ def get_clusters(self, study: Study, area_id: str) -> t.Sequence[RenewableCluste List of cluster output for all clusters. Raises: - ClusterConfigNotFound: If the clusters configuration for the specified area is not found. + RenewableClusterConfigNotFound: If the clusters configuration for the specified area is not found. """ file_study = self._get_file_study(study) path = _CLUSTERS_PATH.format(area_id=area_id) @@ -140,7 +140,7 @@ def get_clusters(self, study: Study, area_id: str) -> t.Sequence[RenewableCluste try: clusters = file_study.tree.get(path.split("/"), depth=3) except KeyError: - raise ClusterConfigNotFound(area_id) + raise RenewableClusterConfigNotFound(path, area_id) return [create_renewable_output(study.version, cluster_id, cluster) for cluster_id, cluster in clusters.items()] @@ -192,14 +192,14 @@ def get_cluster(self, study: Study, area_id: str, cluster_id: str) -> RenewableC The cluster output representation. Raises: - ClusterNotFound: If the specified cluster is not found within the area. + RenewableClusterNotFound: If the specified cluster is not found within the area. """ file_study = self._get_file_study(study) path = _CLUSTER_PATH.format(area_id=area_id, cluster_id=cluster_id) try: cluster = file_study.tree.get(path.split("/"), depth=1) except KeyError: - raise ClusterNotFound(cluster_id) + raise RenewableClusterNotFound(path, cluster_id) return create_renewable_output(study.version, cluster_id, cluster) def update_cluster( @@ -222,7 +222,7 @@ def update_cluster( The updated cluster configuration. Raises: - ClusterNotFound: If the cluster to update is not found. + RenewableClusterNotFound: If the cluster to update is not found. """ study_version = study.version @@ -232,7 +232,7 @@ def update_cluster( try: values = file_study.tree.get(path.split("/"), depth=1) except KeyError: - raise ClusterNotFound(cluster_id) from None + raise RenewableClusterNotFound(path, cluster_id) from None else: old_config = create_renewable_config(study_version, **values) @@ -298,12 +298,12 @@ def duplicate_cluster( The duplicated cluster configuration. Raises: - ClusterAlreadyExists: If a cluster with the new name already exists in the area. + DuplicateRenewableCluster: If a cluster with the new name already exists in the area. """ new_id = transform_name_to_id(new_cluster_name, lower=False) lower_new_id = new_id.lower() if any(lower_new_id == cluster.id.lower() for cluster in self.get_clusters(study, area_id)): - raise ClusterAlreadyExists("Renewable", new_id) + raise DuplicateRenewableCluster(area_id, new_id) # Cluster duplication current_cluster = self.get_cluster(study, area_id, source_id) diff --git a/antarest/study/business/areas/st_storage_management.py b/antarest/study/business/areas/st_storage_management.py index 7109d8c668..c6d4e9f868 100644 --- a/antarest/study/business/areas/st_storage_management.py +++ b/antarest/study/business/areas/st_storage_management.py @@ -5,16 +5,15 @@ import numpy as np from pydantic import BaseModel, Extra, root_validator, validator +from requests.structures import CaseInsensitiveDict from typing_extensions import Literal from antarest.core.exceptions import ( AreaNotFound, - ClusterAlreadyExists, - DuplicateSTStorageId, - STStorageConfigNotFoundError, - STStorageFieldsNotFoundError, - STStorageMatrixNotFoundError, - STStorageNotFoundError, + DuplicateSTStorage, + STStorageConfigNotFound, + STStorageMatrixNotFound, + STStorageNotFound, ) from antarest.study.business.utils import AllOptionalMetaclass, camel_case_model, execute_or_add_commands from antarest.study.model import Study @@ -26,6 +25,7 @@ create_st_storage_config, ) from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy +from antarest.study.storage.rawstudy.model.filesystem.folder_node import ChildNotFoundError from antarest.study.storage.storage_service import StudyStorageService from antarest.study.storage.variantstudy.model.command.create_st_storage import CreateSTStorage from antarest.study.storage.variantstudy.model.command.remove_st_storage import RemoveSTStorage @@ -227,8 +227,18 @@ def validate_rule_curve( # ============================ -STORAGE_LIST_PATH = "input/st-storage/clusters/{area_id}/list/{storage_id}" -STORAGE_SERIES_PATH = "input/st-storage/series/{area_id}/{storage_id}/{ts_name}" +_STORAGE_LIST_PATH = "input/st-storage/clusters/{area_id}/list/{storage_id}" +_STORAGE_SERIES_PATH = "input/st-storage/series/{area_id}/{storage_id}/{ts_name}" + + +def _get_values_by_ids(file_study: FileStudy, area_id: str) -> t.Mapping[str, t.Mapping[str, t.Any]]: + path = _STORAGE_LIST_PATH.format(area_id=area_id, storage_id="")[:-1] + try: + return CaseInsensitiveDict(file_study.tree.get(path.split("/"), depth=3)) + except ChildNotFoundError: + raise AreaNotFound(area_id) from None + except KeyError: + raise STStorageConfigNotFound(path, area_id) from None class STStorageManager: @@ -264,8 +274,13 @@ def create_storage( The ID of the newly created short-term storage. """ file_study = self._get_file_study(study) + values_by_ids = _get_values_by_ids(file_study, area_id) + storage = form.to_config(study.version) - _check_creation_feasibility(file_study, area_id, storage.id) + values = values_by_ids.get(storage.id) + if values is not None: + raise DuplicateSTStorage(area_id, storage.id) + command = self._make_create_cluster_cmd(area_id, storage) execute_or_add_commands( study, @@ -301,11 +316,13 @@ def get_storages( """ file_study = self._get_file_study(study) - path = STORAGE_LIST_PATH.format(area_id=area_id, storage_id="")[:-1] + path = _STORAGE_LIST_PATH.format(area_id=area_id, storage_id="")[:-1] try: config = file_study.tree.get(path.split("/"), depth=3) + except ChildNotFoundError: + raise AreaNotFound(area_id) from None except KeyError: - raise STStorageConfigNotFoundError(study.id, area_id) from None + raise STStorageConfigNotFound(path, area_id) from None # Sort STStorageConfig by groups and then by name order_by = operator.attrgetter("group", "name") @@ -334,11 +351,11 @@ def get_storage( """ file_study = self._get_file_study(study) - path = STORAGE_LIST_PATH.format(area_id=area_id, storage_id=storage_id) + path = _STORAGE_LIST_PATH.format(area_id=area_id, storage_id=storage_id) try: config = file_study.tree.get(path.split("/"), depth=1) except KeyError: - raise STStorageFieldsNotFoundError(storage_id) from None + raise STStorageNotFound(path, storage_id) from None return STStorageOutput.from_config(storage_id, config) def update_storage( @@ -365,15 +382,13 @@ def update_storage( # But sadly, there's no other way to prevent creating wrong commands. file_study = self._get_file_study(study) - _check_update_feasibility(file_study, area_id, storage_id) + values_by_ids = _get_values_by_ids(file_study, area_id) - path = STORAGE_LIST_PATH.format(area_id=area_id, storage_id=storage_id) - try: - values = file_study.tree.get(path.split("/"), depth=1) - except KeyError: - raise STStorageFieldsNotFoundError(storage_id) from None - else: - old_config = create_st_storage_config(study_version, **values) + values = values_by_ids.get(storage_id) + if values is None: + path = _STORAGE_LIST_PATH.format(area_id=area_id, storage_id=storage_id) + raise STStorageNotFound(path, storage_id) + old_config = create_st_storage_config(study_version, **values) # use Python values to synchronize Config and Form values new_values = form.dict(by_alias=False, exclude_none=True) @@ -389,6 +404,7 @@ def update_storage( # create the update config commands with the modified data command_context = self.storage_service.variant_study_service.command_factory.command_context + path = _STORAGE_LIST_PATH.format(area_id=area_id, storage_id=storage_id) commands = [ UpdateConfig(target=f"{path}/{key}", data=value, command_context=command_context) for key, value in data.items() @@ -413,7 +429,12 @@ def delete_storages( storage_ids: IDs list of short-term storages to remove. """ file_study = self._get_file_study(study) - _check_deletion_feasibility(file_study, area_id, storage_ids) + values_by_ids = _get_values_by_ids(file_study, area_id) + + for storage_id in storage_ids: + if storage_id not in values_by_ids: + path = _STORAGE_LIST_PATH.format(area_id=area_id, storage_id=storage_id) + raise STStorageNotFound(path, storage_id) command_context = self.storage_service.variant_study_service.command_factory.command_context for storage_id in storage_ids: @@ -443,7 +464,7 @@ def duplicate_cluster(self, study: Study, area_id: str, source_id: str, new_clus new_id = transform_name_to_id(new_cluster_name) lower_new_id = new_id.lower() if any(lower_new_id == storage.id.lower() for storage in self.get_storages(study, area_id)): - raise ClusterAlreadyExists("Short-term storage", new_id) + raise DuplicateSTStorage(area_id, new_id) # Cluster duplication current_cluster = self.get_storage(study, area_id, source_id) @@ -457,11 +478,11 @@ def duplicate_cluster(self, study: Study, area_id: str, source_id: str, new_clus # noinspection SpellCheckingInspection ts_names = ["pmax_injection", "pmax_withdrawal", "lower_rule_curve", "upper_rule_curve", "inflows"] source_paths = [ - STORAGE_SERIES_PATH.format(area_id=area_id, storage_id=lower_source_id, ts_name=ts_name) + _STORAGE_SERIES_PATH.format(area_id=area_id, storage_id=lower_source_id, ts_name=ts_name) for ts_name in ts_names ] new_paths = [ - STORAGE_SERIES_PATH.format(area_id=area_id, storage_id=lower_new_id, ts_name=ts_name) + _STORAGE_SERIES_PATH.format(area_id=area_id, storage_id=lower_new_id, ts_name=ts_name) for ts_name in ts_names ] @@ -508,11 +529,11 @@ def _get_matrix_obj( ts_name: STStorageTimeSeries, ) -> t.MutableMapping[str, t.Any]: file_study = self._get_file_study(study) - path = STORAGE_SERIES_PATH.format(area_id=area_id, storage_id=storage_id, ts_name=ts_name) + path = _STORAGE_SERIES_PATH.format(area_id=area_id, storage_id=storage_id, ts_name=ts_name) try: matrix = file_study.tree.get(path.split("/"), depth=1) except KeyError: - raise STStorageMatrixNotFoundError(study.id, area_id, storage_id, ts_name) from None + raise STStorageMatrixNotFound(path) from None return matrix def update_matrix( @@ -545,7 +566,7 @@ def _save_matrix_obj( ) -> None: file_study = self._get_file_study(study) command_context = self.storage_service.variant_study_service.command_factory.command_context - path = STORAGE_SERIES_PATH.format(area_id=area_id, storage_id=storage_id, ts_name=ts_name) + path = _STORAGE_SERIES_PATH.format(area_id=area_id, storage_id=storage_id, ts_name=ts_name) command = ReplaceMatrix(target=path, matrix=matrix_data, command_context=command_context) execute_or_add_commands(study, file_study, [command], self.storage_service) @@ -592,31 +613,3 @@ def validate_matrices( # Validation successful return True - - -def _get_existing_storage_ids(file_study: FileStudy, area_id: str) -> t.Set[str]: - try: - area = file_study.config.areas[area_id] - except KeyError: - raise AreaNotFound(area_id) from None - else: - return {s.id for s in area.st_storages} - - -def _check_deletion_feasibility(file_study: FileStudy, area_id: str, storage_ids: t.Sequence[str]) -> None: - existing_ids = _get_existing_storage_ids(file_study, area_id) - for storage_id in storage_ids: - if storage_id not in existing_ids: - raise STStorageNotFoundError(file_study.config.study_id, area_id, storage_id) - - -def _check_update_feasibility(file_study: FileStudy, area_id: str, storage_id: str) -> None: - existing_ids = _get_existing_storage_ids(file_study, area_id) - if storage_id not in existing_ids: - raise STStorageNotFoundError(file_study.config.study_id, area_id, storage_id) - - -def _check_creation_feasibility(file_study: FileStudy, area_id: str, storage_id: str) -> None: - existing_ids = _get_existing_storage_ids(file_study, area_id) - if storage_id in existing_ids: - raise DuplicateSTStorageId(file_study.config.study_id, area_id, storage_id) diff --git a/antarest/study/business/areas/thermal_management.py b/antarest/study/business/areas/thermal_management.py index 5a106e7fa7..9fea2c568d 100644 --- a/antarest/study/business/areas/thermal_management.py +++ b/antarest/study/business/areas/thermal_management.py @@ -3,7 +3,7 @@ from pydantic import validator -from antarest.core.exceptions import ClusterAlreadyExists, ClusterConfigNotFound, ClusterNotFound +from antarest.core.exceptions import DuplicateThermalCluster, ThermalClusterConfigNotFound, ThermalClusterNotFound from antarest.study.business.utils import AllOptionalMetaclass, camel_case_model, execute_or_add_commands from antarest.study.model import Study from antarest.study.storage.rawstudy.model.filesystem.config.model import transform_name_to_id @@ -138,7 +138,7 @@ def get_cluster(self, study: Study, area_id: str, cluster_id: str) -> ThermalClu The cluster with the specified ID. Raises: - ClusterNotFound: If the specified cluster does not exist. + ThermalClusterNotFound: If the specified cluster does not exist. """ file_study = self._get_file_study(study) @@ -146,7 +146,7 @@ def get_cluster(self, study: Study, area_id: str, cluster_id: str) -> ThermalClu try: cluster = file_study.tree.get(path.split("/"), depth=1) except KeyError: - raise ClusterNotFound(cluster_id) + raise ThermalClusterNotFound(path, cluster_id) from None study_version = study.version return create_thermal_output(study_version, cluster_id, cluster) @@ -166,7 +166,7 @@ def get_clusters( A list of thermal clusters within the specified area. Raises: - ClusterConfigNotFound: If no clusters are found in the specified area. + ThermalClusterConfigNotFound: If no clusters are found in the specified area. """ file_study = self._get_file_study(study) @@ -174,7 +174,7 @@ def get_clusters( try: clusters = file_study.tree.get(path.split("/"), depth=3) except KeyError: - raise ClusterConfigNotFound(area_id) + raise ThermalClusterConfigNotFound(path, area_id) from None study_version = study.version return [create_thermal_output(study_version, cluster_id, cluster) for cluster_id, cluster in clusters.items()] @@ -235,7 +235,7 @@ def update_cluster( The updated cluster. Raises: - ClusterNotFound: If the provided `cluster_id` does not match the ID of the cluster + ThermalClusterNotFound: If the provided `cluster_id` does not match the ID of the cluster in the provided cluster_data. """ @@ -245,7 +245,7 @@ def update_cluster( try: values = file_study.tree.get(path.split("/"), depth=1) except KeyError: - raise ClusterNotFound(cluster_id) from None + raise ThermalClusterNotFound(path, cluster_id) from None else: old_config = create_thermal_config(study_version, **values) @@ -317,7 +317,7 @@ def duplicate_cluster( new_id = transform_name_to_id(new_cluster_name, lower=False) lower_new_id = new_id.lower() if any(lower_new_id == cluster.id.lower() for cluster in self.get_clusters(study, area_id)): - raise ClusterAlreadyExists("Thermal", new_id) + raise DuplicateThermalCluster(area_id, new_id) # Cluster duplication source_cluster = self.get_cluster(study, area_id, source_id) diff --git a/tests/integration/study_data_blueprint/test_renewable.py b/tests/integration/study_data_blueprint/test_renewable.py index 8447c0430f..0e57e1464b 100644 --- a/tests/integration/study_data_blueprint/test_renewable.py +++ b/tests/integration/study_data_blueprint/test_renewable.py @@ -475,8 +475,8 @@ def test_lifecycle( ) assert res.status_code == 404 obj = res.json() - assert obj["description"] == f"Cluster: '{unknown_id}' not found" - assert obj["exception"] == "ClusterNotFound" + assert f"'{unknown_id}' not found" in obj["description"] + assert obj["exception"] == "RenewableClusterNotFound" # Cannot duplicate with an existing id res = client.post( @@ -488,7 +488,7 @@ def test_lifecycle( obj = res.json() description = obj["description"] assert other_cluster_name.upper() in description - assert obj["exception"] == "ClusterAlreadyExists" + assert obj["exception"] == "DuplicateRenewableCluster" @pytest.fixture(name="base_study_id") def base_study_id_fixture(self, request: t.Any, client: TestClient, user_access_token: str) -> str: diff --git a/tests/integration/study_data_blueprint/test_st_storage.py b/tests/integration/study_data_blueprint/test_st_storage.py index 161e6417b8..9566c4ba71 100644 --- a/tests/integration/study_data_blueprint/test_st_storage.py +++ b/tests/integration/study_data_blueprint/test_st_storage.py @@ -477,8 +477,10 @@ def test_lifecycle__nominal( ) assert res.status_code == 404 obj = res.json() - assert obj["description"] == f"Short-term storage '{bad_storage_id}' not found in area '{area_id}'" - assert obj["exception"] == "STStorageNotFoundError" + description = obj["description"] + assert bad_storage_id in description + assert re.search(r"'bad_storage'", description, flags=re.IGNORECASE) + assert re.search(r"not found", description, flags=re.IGNORECASE) # Check PATCH with the wrong `study_id` res = client.patch( @@ -500,8 +502,8 @@ def test_lifecycle__nominal( ) assert res.status_code == 404, res.json() obj = res.json() - assert obj["description"] == f"Fields of storage '{unknown_id}' not found" - assert obj["exception"] == "STStorageFieldsNotFoundError" + assert f"'{unknown_id}' not found" in obj["description"] + assert obj["exception"] == "STStorageNotFound" # Cannot duplicate with an existing id res = client.post( @@ -513,7 +515,7 @@ def test_lifecycle__nominal( obj = res.json() description = obj["description"] assert siemens_battery.lower() in description - assert obj["exception"] == "ClusterAlreadyExists" + assert obj["exception"] == "DuplicateSTStorage" @pytest.mark.parametrize("study_type", ["raw", "variant"]) def test__default_values(self, client: TestClient, user_access_token: str, study_type: str) -> None: diff --git a/tests/integration/study_data_blueprint/test_thermal.py b/tests/integration/study_data_blueprint/test_thermal.py index a164b58d8f..50b17600ab 100644 --- a/tests/integration/study_data_blueprint/test_thermal.py +++ b/tests/integration/study_data_blueprint/test_thermal.py @@ -777,8 +777,8 @@ def test_lifecycle( ) assert res.status_code == 404, res.json() obj = res.json() - assert obj["description"] == f"Cluster: '{unknown_id}' not found" - assert obj["exception"] == "ClusterNotFound" + assert f"'{unknown_id}' not found" in obj["description"] + assert obj["exception"] == "ThermalClusterNotFound" # Cannot duplicate with an existing id res = client.post( @@ -790,7 +790,7 @@ def test_lifecycle( obj = res.json() description = obj["description"] assert new_name.upper() in description - assert obj["exception"] == "ClusterAlreadyExists" + assert obj["exception"] == "DuplicateThermalCluster" @pytest.fixture(name="base_study_id") def base_study_id_fixture(self, request: t.Any, client: TestClient, user_access_token: str) -> str: diff --git a/tests/study/business/areas/test_st_storage_management.py b/tests/study/business/areas/test_st_storage_management.py index 5c3e7e660c..ee9a79287e 100644 --- a/tests/study/business/areas/test_st_storage_management.py +++ b/tests/study/business/areas/test_st_storage_management.py @@ -10,20 +10,14 @@ from pydantic import ValidationError from sqlalchemy.orm.session import Session # type: ignore -from antarest.core.exceptions import ( - AreaNotFound, - STStorageConfigNotFoundError, - STStorageFieldsNotFoundError, - STStorageMatrixNotFoundError, - STStorageNotFoundError, -) +from antarest.core.exceptions import AreaNotFound, STStorageConfigNotFound, STStorageMatrixNotFound, STStorageNotFound from antarest.core.model import PublicMode from antarest.login.model import Group, User from antarest.study.business.areas.st_storage_management import STStorageInput, STStorageManager from antarest.study.model import RawStudy, Study, StudyContentStatus from antarest.study.storage.rawstudy.ini_reader import IniReader -from antarest.study.storage.rawstudy.model.filesystem.config.model import Area, FileStudyTreeConfig -from antarest.study.storage.rawstudy.model.filesystem.config.st_storage import STStorageConfig, STStorageGroup +from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfig +from antarest.study.storage.rawstudy.model.filesystem.config.st_storage import STStorageGroup from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy from antarest.study.storage.rawstudy.model.filesystem.ini_file_node import IniFileNode from antarest.study.storage.rawstudy.model.filesystem.root.filestudytree import FileStudyTree @@ -183,7 +177,7 @@ def test_get_st_storages__config_not_found( This test verifies that when the `get_storages` method is called with a study and area ID, and the corresponding configuration is not found (indicated by the `KeyError` raised by the mock), it correctly - raises the `STStorageConfigNotFoundError` exception with the expected error + raises the `STStorageConfigNotFound` exception with the expected error message containing the study ID and area ID. """ # The study must be fetched from the database @@ -201,7 +195,7 @@ def test_get_st_storages__config_not_found( manager = STStorageManager(study_storage_service) # run - with pytest.raises(STStorageConfigNotFoundError, match="not found") as ctx: + with pytest.raises(STStorageConfigNotFound, match="not found") as ctx: manager.get_storages(study, area_id="West") # ensure the error message contains at least the study ID and area ID @@ -286,11 +280,10 @@ def test_update_storage__nominal_case( ini_file_node = IniFileNode(context=Mock(), config=Mock()) file_study.tree = Mock( spec=FileStudyTree, - get=Mock(return_value=LIST_CFG["storage1"]), + get=Mock(return_value=LIST_CFG), get_node=Mock(return_value=ini_file_node), ) - area = Mock(spec=Area) mock_config = Mock(spec=FileStudyTreeConfig, study_id=study.id) file_study.config = mock_config @@ -299,20 +292,22 @@ def test_update_storage__nominal_case( edit_form = STStorageInput(initial_level=0, initial_level_optim=False) # Test behavior for area not in study - mock_config.areas = {"fake_area": area} - with pytest.raises(AreaNotFound) as ctx: - manager.update_storage(study, area_id="West", storage_id="storage1", form=edit_form) - assert ctx.value.detail == "Area is not found: 'West'" + # noinspection PyTypeChecker + file_study.tree.get.return_value = {} + with pytest.raises((AreaNotFound, STStorageNotFound)) as ctx: + manager.update_storage(study, area_id="unknown_area", storage_id="storage1", form=edit_form) + assert "unknown_area" in ctx.value.detail + assert "storage1" in ctx.value.detail # Test behavior for st_storage not in study - mock_config.areas = {"West": area} - area.st_storages = [STStorageConfig(name="fake_name", group="battery")] - with pytest.raises(STStorageNotFoundError) as ctx: - manager.update_storage(study, area_id="West", storage_id="storage1", form=edit_form) - assert ctx.value.detail == "Short-term storage 'storage1' not found in area 'West'" + file_study.tree.get.return_value = {"storage1": LIST_CFG["storage1"]} + with pytest.raises(STStorageNotFound) as ctx: + manager.update_storage(study, area_id="West", storage_id="unknown_storage", form=edit_form) + assert "West" in ctx.value.detail + assert "unknown_storage" in ctx.value.detail # Test behavior for nominal case - area.st_storages = [STStorageConfig(name="storage1", group="battery")] + file_study.tree.get.return_value = LIST_CFG manager.update_storage(study, area_id="West", storage_id="storage1", form=edit_form) # Assert that the storage fields have been updated @@ -351,7 +346,7 @@ def test_get_st_storage__config_not_found( """ Test the `get_st_storage` method of the `STStorageManager` class when the configuration is not found. - This test verifies that the `get_st_storage` method raises an `STStorageFieldsNotFoundError` + This test verifies that the `get_st_storage` method raises an `STStorageNotFound` exception when the configuration for the provided study, area, and storage ID combination is not found. Args: @@ -375,7 +370,7 @@ def test_get_st_storage__config_not_found( manager = STStorageManager(study_storage_service) # Run the method being tested and expect an exception - with pytest.raises(STStorageFieldsNotFoundError, match="not found") as ctx: + with pytest.raises(STStorageNotFound, match="not found") as ctx: manager.get_storage(study, area_id="West", storage_id="storage1") # ensure the error message contains at least the study ID, area ID and storage ID err_msg = str(ctx.value) @@ -436,7 +431,7 @@ def test_get_matrix__config_not_found( """ Test the `get_matrix` method of the `STStorageManager` class when the time series is not found. - This test verifies that the `get_matrix` method raises an `STStorageFieldsNotFoundError` + This test verifies that the `get_matrix` method raises an `STStorageNotFound` exception when the configuration for the provided study, area, time series, and storage ID combination is not found. @@ -461,7 +456,7 @@ def test_get_matrix__config_not_found( manager = STStorageManager(study_storage_service) # Run the method being tested and expect an exception - with pytest.raises(STStorageMatrixNotFoundError, match="not found") as ctx: + with pytest.raises(STStorageMatrixNotFound, match="not found") as ctx: manager.get_matrix(study, area_id="West", storage_id="storage1", ts_name="inflows") # ensure the error message contains at least the study ID, area ID and storage ID err_msg = str(ctx.value) @@ -477,7 +472,7 @@ def test_get_matrix__invalid_matrix( """ Test the `get_matrix` method of the `STStorageManager` class when the time series is not found. - This test verifies that the `get_matrix` method raises an `STStorageFieldsNotFoundError` + This test verifies that the `get_matrix` method raises an `STStorageNotFound` exception when the configuration for the provided study, area, time series, and storage ID combination is not found. From 51b9a46c652dbc808ddfb8a4e03c1f89910cd2df Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 15 Mar 2024 18:10:43 +0100 Subject: [PATCH 072/248] fix(matrix-service): correct implementation of `ISimpleMatrixService.get_matrix_id` --- antarest/matrixstore/service.py | 7 ++++++- .../model/command/create_binding_constraint.py | 10 ++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/antarest/matrixstore/service.py b/antarest/matrixstore/service.py index 732216c4b3..4b4de557f7 100644 --- a/antarest/matrixstore/service.py +++ b/antarest/matrixstore/service.py @@ -86,8 +86,13 @@ def get_matrix_id(self, matrix: t.Union[t.List[t.List[float]], str]) -> str: Raises: TypeError: If the provided matrix is neither a matrix nor a link to a matrix. """ + # noinspection SpellCheckingInspection if isinstance(matrix, str): - return matrix.lstrip("matrix://") + # str.removeprefix() is not available in Python 3.8 + prefix = "matrix://" + if matrix.startswith(prefix): + return matrix[len(prefix) :] + return matrix elif isinstance(matrix, list): return self.create(matrix) else: 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 75fe563911..4b3885a5f0 100644 --- a/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py +++ b/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py @@ -312,9 +312,11 @@ def _create_diff(self, other: "ICommand") -> t.List["ICommand"]: matrix_service = self.command_context.matrix_service for matrix_name in ["values", "less_term_matrix", "equal_term_matrix", "greater_term_matrix"]: - self_matrix = getattr(self, matrix_name) - other_matrix = getattr(other, matrix_name) - if self_matrix != other_matrix: - args[matrix_name] = matrix_service.get_matrix_id(other_matrix) + self_matrix = getattr(self, matrix_name) # matrix, ID or `None` + other_matrix = getattr(other, matrix_name) # matrix, ID or `None` + self_matrix_id = None if self_matrix is None else matrix_service.get_matrix_id(self_matrix) + other_matrix_id = None if other_matrix is None else matrix_service.get_matrix_id(other_matrix) + if self_matrix_id != other_matrix_id: + args[matrix_name] = other_matrix_id return [UpdateBindingConstraint(**args)] From e757801506c47f390428800dc11cdbec72e8185a Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE <43534797+laurent-laporte-pro@users.noreply.github.com> Date: Tue, 19 Mar 2024 15:30:55 +0100 Subject: [PATCH 073/248] fix(db): correct conversion to `CommandDTO` when the version isn't provided in the database (#1983) --- antarest/study/storage/variantstudy/model/dbmodel.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/antarest/study/storage/variantstudy/model/dbmodel.py b/antarest/study/storage/variantstudy/model/dbmodel.py index f6874a5495..d9a7e5fc55 100644 --- a/antarest/study/storage/variantstudy/model/dbmodel.py +++ b/antarest/study/storage/variantstudy/model/dbmodel.py @@ -55,7 +55,9 @@ class CommandBlock(Base): # type: ignore args: str = Column(String()) def to_dto(self) -> CommandDTO: - return CommandDTO(id=self.id, action=self.command, args=json.loads(self.args), version=self.version) + # Database may lack a version number, defaulting to 1 if so. + version = self.version or 1 + return CommandDTO(id=self.id, action=self.command, args=json.loads(self.args), version=version) def __str__(self) -> str: return ( From 07cf7cac22554b3c6cd63c5eb66766ccb5fbd2ea Mon Sep 17 00:00:00 2001 From: belthlemar Date: Thu, 29 Feb 2024 15:12:48 +0100 Subject: [PATCH 074/248] fix(outputs): build outputs config even when using cache --- .../rawstudy/model/filesystem/config/files.py | 4 +- .../rawstudy/model/filesystem/factory.py | 5 +- .../variant_blueprint/test_variant_manager.py | 54 +++++++++++++++++++ .../filesystem/config/test_config_files.py | 4 +- 4 files changed, 62 insertions(+), 5 deletions(-) diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/files.py b/antarest/study/storage/rawstudy/model/filesystem/config/files.py index 3727f320ec..3248b6560a 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/config/files.py +++ b/antarest/study/storage/rawstudy/model/filesystem/config/files.py @@ -74,7 +74,7 @@ def build(study_path: Path, study_id: str, output_path: t.Optional[Path] = None) version=_parse_version(study_path), areas=_parse_areas(study_path), sets=_parse_sets(study_path), - outputs=_parse_outputs(outputs_dir), + outputs=parse_outputs(outputs_dir), bindings=_parse_bindings(study_path), store_new_set=sns, archive_input_series=asi, @@ -232,7 +232,7 @@ def _parse_areas(root: Path) -> t.Dict[str, Area]: return {transform_name_to_id(a): parse_area(root, a) for a in areas} -def _parse_outputs(output_path: Path) -> t.Dict[str, Simulation]: +def parse_outputs(output_path: Path) -> t.Dict[str, Simulation]: if not output_path.is_dir(): return {} sims = {} diff --git a/antarest/study/storage/rawstudy/model/filesystem/factory.py b/antarest/study/storage/rawstudy/model/filesystem/factory.py index 1899ec1bb4..040e747629 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/factory.py +++ b/antarest/study/storage/rawstudy/model/filesystem/factory.py @@ -10,7 +10,7 @@ from antarest.core.interfaces.cache import CacheConstants, ICache from antarest.matrixstore.service import ISimpleMatrixService from antarest.matrixstore.uri_resolver_service import UriResolverService -from antarest.study.storage.rawstudy.model.filesystem.config.files import build +from antarest.study.storage.rawstudy.model.filesystem.config.files import build, parse_outputs from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfig, FileStudyTreeConfigDTO from antarest.study.storage.rawstudy.model.filesystem.context import ContextServer from antarest.study.storage.rawstudy.model.filesystem.root.filestudytree import FileStudyTree @@ -93,6 +93,9 @@ def _create_from_fs_unsafe( if from_cache is not None: logger.info(f"Study {study_id} read from cache") config = FileStudyTreeConfigDTO.parse_obj(from_cache).to_build_config() + if output_path: + config.output_path = output_path + config.outputs = parse_outputs(output_path) return FileStudy(config, FileStudyTree(self.context, config)) start_time = time.time() config = build(path, study_id, output_path) diff --git a/tests/integration/variant_blueprint/test_variant_manager.py b/tests/integration/variant_blueprint/test_variant_manager.py index 8a300e75da..4b066cdf73 100644 --- a/tests/integration/variant_blueprint/test_variant_manager.py +++ b/tests/integration/variant_blueprint/test_variant_manager.py @@ -1,10 +1,13 @@ +import io import logging +import time import typing as t import pytest from starlette.testclient import TestClient from antarest.core.tasks.model import TaskDTO, TaskStatus +from tests.integration.assets import ASSETS_DIR @pytest.fixture(name="base_study_id") @@ -251,3 +254,54 @@ def test_recursive_variant_tree(client: TestClient, admin_access_token: str): # Asserts that we do not trigger a Recursive Exception res = client.get(f"/v1/studies/{parent_id}/variants", headers=admin_headers) assert res.status_code == 200 + + +def test_outputs(client: TestClient, admin_access_token: str, tmp_path: str) -> None: + # ======================= + # SET UP + # ======================= + + admin_headers = {"Authorization": f"Bearer {admin_access_token}"} + res = client.post(f"/v1/studies?name=foo", headers=admin_headers) + parent_id = res.json() + res = client.post(f"/v1/studies/{parent_id}/variants?name=variant_foo", headers=admin_headers) + variant_id = res.json() + + # Only done to generate the variant folder + res = client.post(f"/v1/launcher/run/{variant_id}", headers=admin_headers) + job_id = res.json()["job_id"] + status = client.get(f"/v1/launcher/jobs/{job_id}", headers=admin_headers).json()["status"] + while status != "failed": + time.sleep(0.2) + status = client.get(f"/v1/launcher/jobs/{job_id}", headers=admin_headers).json()["status"] + + # Import an output to the study folder + output_path_zip = ASSETS_DIR / "output_adq.zip" + client.post( + f"/v1/studies/{variant_id}/output", + headers=admin_headers, + files={"output": io.BytesIO(output_path_zip.read_bytes())}, + ) + + # ======================= + # ASSERTS GENERATING THE VARIANT DOES NOT `HIDE` OUTPUTS FROM THE ENDPOINT + # ======================= + + # Get output + res = client.get(f"/v1/studies/{variant_id}/outputs", headers=admin_headers).json() + assert len(res) == 1 + + # Generates the study + res = client.put(f"/v1/studies/{variant_id}/generate?denormalize=false&from_scratch=true", headers=admin_headers) + task_id = res.json() + # Wait for task completion + res = client.get(f"/v1/tasks/{task_id}", headers=admin_headers, params={"wait_for_completion": True}) + assert res.status_code == 200 + task_result = TaskDTO.parse_obj(res.json()) + assert task_result.status == TaskStatus.COMPLETED + assert task_result.result is not None + assert task_result.result.success + + # Get outputs again + res = client.get(f"/v1/studies/{variant_id}/outputs", headers=admin_headers).json() + assert len(res) == 1 diff --git a/tests/storage/repository/filesystem/config/test_config_files.py b/tests/storage/repository/filesystem/config/test_config_files.py index 7cbab645bc..d8d157f01f 100644 --- a/tests/storage/repository/filesystem/config/test_config_files.py +++ b/tests/storage/repository/filesystem/config/test_config_files.py @@ -11,12 +11,12 @@ ) from antarest.study.storage.rawstudy.model.filesystem.config.files import ( _parse_links, - _parse_outputs, _parse_renewables, _parse_sets, _parse_st_storage, _parse_thermal, build, + parse_outputs, ) from antarest.study.storage.rawstudy.model.filesystem.config.model import ( Area, @@ -227,7 +227,7 @@ def test_parse_outputs__nominal(tmp_path: Path, assets_name: str, expected: Dict with ZipFile(pkg_dir) as zf: zf.extractall(tmp_path) output_path = tmp_path.joinpath("output") - actual = _parse_outputs(output_path) + actual = parse_outputs(output_path) assert actual == expected From 70dc01754a672aecf04df0fa9618c4a694cd196e Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Mon, 25 Mar 2024 20:33:54 +0100 Subject: [PATCH 075/248] test(outputs): improve unit test `test_outputs` --- .../variant_blueprint/test_variant_manager.py | 50 ++++++++++++------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/tests/integration/variant_blueprint/test_variant_manager.py b/tests/integration/variant_blueprint/test_variant_manager.py index 4b066cdf73..a0e4a68108 100644 --- a/tests/integration/variant_blueprint/test_variant_manager.py +++ b/tests/integration/variant_blueprint/test_variant_manager.py @@ -243,33 +243,34 @@ def test_comments(client: TestClient, admin_access_token: str, variant_id: str) assert res.json() == comment -def test_recursive_variant_tree(client: TestClient, admin_access_token: str): +def test_recursive_variant_tree(client: TestClient, admin_access_token: str, base_study_id: str) -> None: admin_headers = {"Authorization": f"Bearer {admin_access_token}"} - base_study_res = client.post("/v1/studies?name=foo", headers=admin_headers) - base_study_id = base_study_res.json() - parent_id = base_study_res.json() - for k in range(150): - res = client.post(f"/v1/studies/{base_study_id}/variants?name=variant_{k}", headers=admin_headers) + parent_id = base_study_id + for k in range(200): + res = client.post( + f"/v1/studies/{base_study_id}/variants", + headers=admin_headers, + params={"name": f"variant_{k}"}, + ) base_study_id = res.json() + # Asserts that we do not trigger a Recursive Exception res = client.get(f"/v1/studies/{parent_id}/variants", headers=admin_headers) - assert res.status_code == 200 + assert res.status_code == 200, res.json() -def test_outputs(client: TestClient, admin_access_token: str, tmp_path: str) -> None: +def test_outputs(client: TestClient, admin_access_token: str, variant_id: str, tmp_path: str) -> None: # ======================= # SET UP # ======================= admin_headers = {"Authorization": f"Bearer {admin_access_token}"} - res = client.post(f"/v1/studies?name=foo", headers=admin_headers) - parent_id = res.json() - res = client.post(f"/v1/studies/{parent_id}/variants?name=variant_foo", headers=admin_headers) - variant_id = res.json() # Only done to generate the variant folder res = client.post(f"/v1/launcher/run/{variant_id}", headers=admin_headers) + res.raise_for_status() job_id = res.json()["job_id"] + status = client.get(f"/v1/launcher/jobs/{job_id}", headers=admin_headers).json()["status"] while status != "failed": time.sleep(0.2) @@ -277,31 +278,42 @@ def test_outputs(client: TestClient, admin_access_token: str, tmp_path: str) -> # Import an output to the study folder output_path_zip = ASSETS_DIR / "output_adq.zip" - client.post( + res = client.post( f"/v1/studies/{variant_id}/output", headers=admin_headers, files={"output": io.BytesIO(output_path_zip.read_bytes())}, ) + res.raise_for_status() # ======================= # ASSERTS GENERATING THE VARIANT DOES NOT `HIDE` OUTPUTS FROM THE ENDPOINT # ======================= # Get output - res = client.get(f"/v1/studies/{variant_id}/outputs", headers=admin_headers).json() - assert len(res) == 1 + res = client.get(f"/v1/studies/{variant_id}/outputs", headers=admin_headers) + assert res.status_code == 200, res.json() + outputs = res.json() + assert len(outputs) == 1 # Generates the study - res = client.put(f"/v1/studies/{variant_id}/generate?denormalize=false&from_scratch=true", headers=admin_headers) + res = client.put( + f"/v1/studies/{variant_id}/generate", + headers=admin_headers, + params={"denormalize": False, "from_scratch": True}, + ) + res.raise_for_status() task_id = res.json() + # Wait for task completion res = client.get(f"/v1/tasks/{task_id}", headers=admin_headers, params={"wait_for_completion": True}) - assert res.status_code == 200 + res.raise_for_status() task_result = TaskDTO.parse_obj(res.json()) assert task_result.status == TaskStatus.COMPLETED assert task_result.result is not None assert task_result.result.success # Get outputs again - res = client.get(f"/v1/studies/{variant_id}/outputs", headers=admin_headers).json() - assert len(res) == 1 + res = client.get(f"/v1/studies/{variant_id}/outputs", headers=admin_headers) + assert res.status_code == 200, res.json() + outputs = res.json() + assert len(outputs) == 1 From dc0e93fb3fb50b5eb5edd3c4a838d8fab9cbc3d1 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Mon, 25 Mar 2024 21:11:05 +0100 Subject: [PATCH 076/248] test: correct implementation of the `get_matrix_id` mock --- tests/variantstudy/conftest.py | 6 +++++- tests/variantstudy/test_command_factory.py | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/variantstudy/conftest.py b/tests/variantstudy/conftest.py index 1bcbb13cb1..b08851b07b 100644 --- a/tests/variantstudy/conftest.py +++ b/tests/variantstudy/conftest.py @@ -81,7 +81,11 @@ def get_matrix_id(matrix: t.Union[t.List[t.List[float]], str]) -> str: Get the matrix ID from a matrix or a matrix link. """ if isinstance(matrix, str): - return matrix.lstrip("matrix://") + # str.removeprefix() is not available in Python 3.8 + prefix = "matrix://" + if matrix.startswith(prefix): + return matrix[len(prefix) :] + return matrix elif isinstance(matrix, list): return create(matrix) else: diff --git a/tests/variantstudy/test_command_factory.py b/tests/variantstudy/test_command_factory.py index 64ec3b799f..a0324a2722 100644 --- a/tests/variantstudy/test_command_factory.py +++ b/tests/variantstudy/test_command_factory.py @@ -403,7 +403,11 @@ def setup_class(self): @pytest.mark.unit_test def test_command_factory(self, command_dto: CommandDTO): def get_matrix_id(matrix: str) -> str: - return matrix.lstrip("matrix://") + # str.removeprefix() is not available in Python 3.8 + prefix = "matrix://" + if matrix.startswith(prefix): + return matrix[len(prefix) :] + return matrix command_factory = CommandFactory( generator_matrix_constants=Mock(spec=GeneratorMatrixConstants), From fba45a0a0f6ccc1b2bb38581fd1172154a9d3ef0 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Tue, 26 Mar 2024 11:03:32 +0100 Subject: [PATCH 077/248] chore(eslint): add new rules (#1985) --- webapp/.eslintrc.cjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/webapp/.eslintrc.cjs b/webapp/.eslintrc.cjs index 74a900fee9..c39a0165bc 100644 --- a/webapp/.eslintrc.cjs +++ b/webapp/.eslintrc.cjs @@ -44,9 +44,11 @@ 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", "no-param-reassign": [ "error", { @@ -74,5 +76,6 @@ module.exports = { "react/hook-use-state": "error", "react/prop-types": "off", "react/self-closing-comp": "error", + "require-await": "warn", // TODO: switch to "error" when the quantity of warning will be low }, }; From ebb600e7c975c8e841e9e2b52ef6b44eb7ad0d83 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Tue, 26 Mar 2024 12:36:06 +0100 Subject: [PATCH 078/248] chore(commitlint): add config (#1987) --- .gitignore | 1 + commitlint.config.js | 10 + package-lock.json | 1449 ++++++++++++++++++++++++++++++++++++++++++ package.json | 7 + 4 files changed, 1467 insertions(+) create mode 100644 commitlint.config.js create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.gitignore b/.gitignore index c1e83b8272..ee039fcb85 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ share/python-wheels/ .installed.cfg *.egg MANIFEST +node_modules # PyInstaller # Usually these files are written by a python script from a template diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000000..080384a0a6 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,10 @@ +import { RuleConfigSeverity } from "@commitlint/types"; + +// Config used by 'commitlint' GitHub action. +export default { + extends: ["@commitlint/config-conventional"], + // Rules: https://commitlint.js.org/reference/rules.html + rules: { + "header-max-length": [RuleConfigSeverity.Error, "always", 150], + }, +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..e21a23c548 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1449 @@ +{ + "name": "AntaREST", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@commitlint/cli": "^19.2.1", + "@commitlint/config-conventional": "^19.1.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", + "integrity": "sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.24.2", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.2.tgz", + "integrity": "sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/@commitlint/cli": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-19.2.1.tgz", + "integrity": "sha512-cbkYUJsLqRomccNxvoJTyv5yn0bSy05BBizVyIcLACkRbVUqYorC351Diw/XFSWC/GtpwiwT2eOvQgFZa374bg==", + "dev": true, + "dependencies": { + "@commitlint/format": "^19.0.3", + "@commitlint/lint": "^19.1.0", + "@commitlint/load": "^19.2.0", + "@commitlint/read": "^19.2.1", + "@commitlint/types": "^19.0.3", + "execa": "^8.0.1", + "yargs": "^17.0.0" + }, + "bin": { + "commitlint": "cli.js" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-conventional": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-19.1.0.tgz", + "integrity": "sha512-KIKD2xrp6Uuk+dcZVj3++MlzIr/Su6zLE8crEDQCZNvWHNQSeeGbzOlNtsR32TUy6H3JbP7nWgduAHCaiGQ6EA==", + "dev": true, + "dependencies": { + "@commitlint/types": "^19.0.3", + "conventional-changelog-conventionalcommits": "^7.0.2" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-validator": { + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-19.0.3.tgz", + "integrity": "sha512-2D3r4PKjoo59zBc2auodrSCaUnCSALCx54yveOFwwP/i2kfEAQrygwOleFWswLqK0UL/F9r07MFi5ev2ohyM4Q==", + "dev": true, + "dependencies": { + "@commitlint/types": "^19.0.3", + "ajv": "^8.11.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/ensure": { + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-19.0.3.tgz", + "integrity": "sha512-SZEpa/VvBLoT+EFZVb91YWbmaZ/9rPH3ESrINOl0HD2kMYsjvl0tF7nMHh0EpTcv4+gTtZBAe1y/SS6/OhfZzQ==", + "dev": true, + "dependencies": { + "@commitlint/types": "^19.0.3", + "lodash.camelcase": "^4.3.0", + "lodash.kebabcase": "^4.1.1", + "lodash.snakecase": "^4.1.1", + "lodash.startcase": "^4.4.0", + "lodash.upperfirst": "^4.3.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/execute-rule": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-19.0.0.tgz", + "integrity": "sha512-mtsdpY1qyWgAO/iOK0L6gSGeR7GFcdW7tIjcNFxcWkfLDF5qVbPHKuGATFqRMsxcO8OUKNj0+3WOHB7EHm4Jdw==", + "dev": true, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/format": { + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-19.0.3.tgz", + "integrity": "sha512-QjjyGyoiVWzx1f5xOteKHNLFyhyweVifMgopozSgx1fGNrGV8+wp7k6n1t6StHdJ6maQJ+UUtO2TcEiBFRyR6Q==", + "dev": true, + "dependencies": { + "@commitlint/types": "^19.0.3", + "chalk": "^5.3.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/is-ignored": { + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-19.0.3.tgz", + "integrity": "sha512-MqDrxJaRSVSzCbPsV6iOKG/Lt52Y+PVwFVexqImmYYFhe51iVJjK2hRhOG2jUAGiUHk4jpdFr0cZPzcBkSzXDQ==", + "dev": true, + "dependencies": { + "@commitlint/types": "^19.0.3", + "semver": "^7.6.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/lint": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-19.1.0.tgz", + "integrity": "sha512-ESjaBmL/9cxm+eePyEr6SFlBUIYlYpI80n+Ltm7IA3MAcrmiP05UMhJdAD66sO8jvo8O4xdGn/1Mt2G5VzfZKw==", + "dev": true, + "dependencies": { + "@commitlint/is-ignored": "^19.0.3", + "@commitlint/parse": "^19.0.3", + "@commitlint/rules": "^19.0.3", + "@commitlint/types": "^19.0.3" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/load": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-19.2.0.tgz", + "integrity": "sha512-XvxxLJTKqZojCxaBQ7u92qQLFMMZc4+p9qrIq/9kJDy8DOrEa7P1yx7Tjdc2u2JxIalqT4KOGraVgCE7eCYJyQ==", + "dev": true, + "dependencies": { + "@commitlint/config-validator": "^19.0.3", + "@commitlint/execute-rule": "^19.0.0", + "@commitlint/resolve-extends": "^19.1.0", + "@commitlint/types": "^19.0.3", + "chalk": "^5.3.0", + "cosmiconfig": "^9.0.0", + "cosmiconfig-typescript-loader": "^5.0.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "lodash.uniq": "^4.5.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/message": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-19.0.0.tgz", + "integrity": "sha512-c9czf6lU+9oF9gVVa2lmKaOARJvt4soRsVmbR7Njwp9FpbBgste5i7l/2l5o8MmbwGh4yE1snfnsy2qyA2r/Fw==", + "dev": true, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/parse": { + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-19.0.3.tgz", + "integrity": "sha512-Il+tNyOb8VDxN3P6XoBBwWJtKKGzHlitEuXA5BP6ir/3loWlsSqDr5aecl6hZcC/spjq4pHqNh0qPlfeWu38QA==", + "dev": true, + "dependencies": { + "@commitlint/types": "^19.0.3", + "conventional-changelog-angular": "^7.0.0", + "conventional-commits-parser": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/read": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-19.2.1.tgz", + "integrity": "sha512-qETc4+PL0EUv7Q36lJbPG+NJiBOGg7SSC7B5BsPWOmei+Dyif80ErfWQ0qXoW9oCh7GTpTNRoaVhiI8RbhuaNw==", + "dev": true, + "dependencies": { + "@commitlint/top-level": "^19.0.0", + "@commitlint/types": "^19.0.3", + "execa": "^8.0.1", + "git-raw-commits": "^4.0.0", + "minimist": "^1.2.8" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/resolve-extends": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-19.1.0.tgz", + "integrity": "sha512-z2riI+8G3CET5CPgXJPlzftH+RiWYLMYv4C9tSLdLXdr6pBNimSKukYP9MS27ejmscqCTVA4almdLh0ODD2KYg==", + "dev": true, + "dependencies": { + "@commitlint/config-validator": "^19.0.3", + "@commitlint/types": "^19.0.3", + "global-directory": "^4.0.1", + "import-meta-resolve": "^4.0.0", + "lodash.mergewith": "^4.6.2", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/rules": { + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-19.0.3.tgz", + "integrity": "sha512-TspKb9VB6svklxNCKKwxhELn7qhtY1rFF8ls58DcFd0F97XoG07xugPjjbVnLqmMkRjZDbDIwBKt9bddOfLaPw==", + "dev": true, + "dependencies": { + "@commitlint/ensure": "^19.0.3", + "@commitlint/message": "^19.0.0", + "@commitlint/to-lines": "^19.0.0", + "@commitlint/types": "^19.0.3", + "execa": "^8.0.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/to-lines": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-19.0.0.tgz", + "integrity": "sha512-vkxWo+VQU5wFhiP9Ub9Sre0FYe019JxFikrALVoD5UGa8/t3yOJEpEhxC5xKiENKKhUkTpEItMTRAjHw2SCpZw==", + "dev": true, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/top-level": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-19.0.0.tgz", + "integrity": "sha512-KKjShd6u1aMGNkCkaX4aG1jOGdn7f8ZI8TR1VEuNqUOjWTOdcDSsmglinglJ18JTjuBX5I1PtjrhQCRcixRVFQ==", + "dev": true, + "dependencies": { + "find-up": "^7.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/types": { + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-19.0.3.tgz", + "integrity": "sha512-tpyc+7i6bPG9mvaBbtKUeghfyZSDgWquIDfMgqYtTbmZ9Y9VzEm2je9EYcQ0aoz5o7NvGS+rcDec93yO08MHYA==", + "dev": true, + "dependencies": { + "@types/conventional-commits-parser": "^5.0.0", + "chalk": "^5.3.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@types/conventional-commits-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", + "integrity": "sha512-loB369iXNmAZglwWATL+WRe+CRMmmBPtpolYzIebFaX4YA3x+BEfLqhUAV9WanycKI3TG1IMr5bMJDajDKLlUQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "20.11.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz", + "integrity": "sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "dev": true + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "dev": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "dev": true, + "dependencies": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" + } + }, + "node_modules/conventional-changelog-angular": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", + "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", + "dev": true, + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/conventional-changelog-conventionalcommits": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz", + "integrity": "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==", + "dev": true, + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/conventional-commits-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", + "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", + "dev": true, + "dependencies": { + "is-text-path": "^2.0.0", + "JSONStream": "^1.3.5", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "conventional-commits-parser": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig-typescript-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-5.0.0.tgz", + "integrity": "sha512-+8cK7jRAReYkMwMiG+bxhcNKiHJDM6bR9FD/nGBXOWdMLuYawjF5cGrtLilJ+LGd3ZjCXnJjR5DkfWPoIVlqJA==", + "dev": true, + "dependencies": { + "jiti": "^1.19.1" + }, + "engines": { + "node": ">=v16" + }, + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=8.2", + "typescript": ">=4" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dargs": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/dargs/-/dargs-8.1.0.tgz", + "integrity": "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/find-up": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", + "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", + "dev": true, + "dependencies": { + "locate-path": "^7.2.0", + "path-exists": "^5.0.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/git-raw-commits": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz", + "integrity": "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==", + "dev": true, + "dependencies": { + "dargs": "^8.0.0", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "git-raw-commits": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.0.0.tgz", + "integrity": "sha512-okYUR7ZQPH+efeuMJGlq4f8ubUgO50kByRPyt/Cy1Io4PSRsPjxME+YlVaCOx+NIToW7hCsZNFJyTPFFKepRSA==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-text-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz", + "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==", + "dev": true, + "dependencies": { + "text-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jiti": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", + "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ] + }, + "node_modules/JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "dev": true, + "dependencies": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + }, + "bin": { + "JSONStream": "bin.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true + }, + "node_modules/lodash.kebabcase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", + "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", + "dev": true + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "dev": true + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "dev": true + }, + "node_modules/lodash.startcase": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", + "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "dev": true + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true + }, + "node_modules/lodash.upperfirst": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", + "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", + "dev": true + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/meow": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", + "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", + "dev": true, + "engines": { + "node": ">=16.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/text-extensions": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz", + "integrity": "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true + }, + "node_modules/typescript": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz", + "integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==", + "dev": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", + "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000..affe100a23 --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "type": "module", + "devDependencies": { + "@commitlint/cli": "^19.2.1", + "@commitlint/config-conventional": "^19.1.0" + } +} From 9e971f499177a9b2b2707cb26e1e27b4c86d8fee Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE <43534797+laurent-laporte-pro@users.noreply.github.com> Date: Tue, 26 Mar 2024 15:23:21 +0100 Subject: [PATCH 079/248] fix(study-search): correct the SQL query used for pagination (#1986) Fix Pagination Issue in `_search_studies` Method In this PR, I've fixed a bug in the `_search_studies` method in the `repository.py` file. The bug was causing issues with pagination because `joinedload` was being used unnecessarily when we already had a classic `join` in our query. By avoiding the use of `joinedload`, the pagination issue has been resolved. Now, the SQL query is correctly generated by SqlAlchemy in the case of pagination (using LIMIT and OFFSET). I've also updated the unit tests in the `test_repository.py` file to ensure that the pagination works as expected. --- antarest/study/repository.py | 11 +++- tests/study/test_repository.py | 112 +++++++++++++++++++++------------ 2 files changed, 79 insertions(+), 44 deletions(-) diff --git a/antarest/study/repository.py b/antarest/study/repository.py index 6e89d33d5a..93237ff850 100644 --- a/antarest/study/repository.py +++ b/antarest/study/repository.py @@ -304,10 +304,15 @@ def _search_studies( q = q.filter(RawStudy.missing.is_(None)) else: q = q.filter(not_(RawStudy.missing.is_(None))) - q = q.options(joinedload(entity.owner)) - q = q.options(joinedload(entity.groups)) + + if study_filter.users is not None: + q = q.options(joinedload(entity.owner)) + if study_filter.groups is not None: + q = q.options(joinedload(entity.groups)) + if study_filter.tags is not None: + q = q.options(joinedload(entity.tags)) q = q.options(joinedload(entity.additional_data)) - q = q.options(joinedload(entity.tags)) + if study_filter.managed is not None: if study_filter.managed: q = q.filter(or_(entity.type == "variantstudy", RawStudy.workspace == DEFAULT_WORKSPACE_NAME)) diff --git a/tests/study/test_repository.py b/tests/study/test_repository.py index cd8b6c790c..e6becac349 100644 --- a/tests/study/test_repository.py +++ b/tests/study/test_repository.py @@ -9,68 +9,83 @@ from antarest.core.model import PublicMode from antarest.login.model import Group, User from antarest.study.model import DEFAULT_WORKSPACE_NAME, RawStudy, Tag -from antarest.study.repository import AccessPermissions, StudyFilter, StudyMetadataRepository, StudyPagination +from antarest.study.repository import ( + AccessPermissions, + StudyFilter, + StudyMetadataRepository, + StudyPagination, + StudySortBy, +) from antarest.study.storage.variantstudy.model.dbmodel import VariantStudy from tests.db_statement_recorder import DBStatementRecorder @pytest.mark.parametrize( - "managed, study_ids, exists, expected_ids", + "managed, study_names, exists, expected_names", [ - (None, [], False, {"5", "6"}), - (None, [], True, {"1", "2", "3", "4", "7", "8"}), - (None, [], None, {"1", "2", "3", "4", "5", "6", "7", "8"}), - (None, [1, 3, 5, 7], False, {"5"}), - (None, [1, 3, 5, 7], True, {"1", "3", "7"}), - (None, [1, 3, 5, 7], None, {"1", "3", "5", "7"}), - (True, [], False, {"5"}), - (True, [], True, {"1", "2", "3", "4", "8"}), - (True, [], None, {"1", "2", "3", "4", "5", "8"}), - (True, [1, 3, 5, 7], False, {"5"}), - (True, [1, 3, 5, 7], True, {"1", "3"}), - (True, [1, 3, 5, 7], None, {"1", "3", "5"}), - (True, [2, 4, 6, 8], True, {"2", "4", "8"}), - (True, [2, 4, 6, 8], None, {"2", "4", "8"}), - (False, [], False, {"6"}), - (False, [], True, {"7"}), - (False, [], None, {"6", "7"}), - (False, [1, 3, 5, 7], False, set()), - (False, [1, 3, 5, 7], True, {"7"}), - (False, [1, 3, 5, 7], None, {"7"}), + (None, [], False, ["s5", "s6"]), + (None, [], True, ["s1", "s2", "s3", "s4", "s7", "s8"]), + (None, [], None, ["s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8"]), + (None, ["s1", "s3", "s5", "s7"], False, ["s5"]), + (None, ["s1", "s3", "s5", "s7"], True, ["s1", "s3", "s7"]), + (None, ["s1", "s3", "s5", "s7"], None, ["s1", "s3", "s5", "s7"]), + (True, [], False, ["s5"]), + (True, [], True, ["s1", "s2", "s3", "s4", "s8"]), + (True, [], None, ["s1", "s2", "s3", "s4", "s5", "s8"]), + (True, ["s1", "s3", "s5", "s7"], False, ["s5"]), + (True, ["s1", "s3", "s5", "s7"], True, ["s1", "s3"]), + (True, ["s1", "s3", "s5", "s7"], None, ["s1", "s3", "s5"]), + (True, ["s2", "s4", "s6", "s8"], True, ["s2", "s4", "s8"]), + (True, ["s2", "s4", "s6", "s8"], None, ["s2", "s4", "s8"]), + (False, [], False, ["s6"]), + (False, [], True, ["s7"]), + (False, [], None, ["s6", "s7"]), + (False, ["s1", "s3", "s5", "s7"], False, []), + (False, ["s1", "s3", "s5", "s7"], True, ["s7"]), + (False, ["s1", "s3", "s5", "s7"], None, ["s7"]), ], ) def test_get_all__general_case( db_session: Session, managed: t.Union[bool, None], - study_ids: t.Sequence[str], + study_names: t.Sequence[str], exists: t.Union[bool, None], - expected_ids: t.Set[str], + expected_names: t.Sequence[str], ) -> None: test_workspace = "test-repository" icache: Mock = Mock(spec=ICache) repository = StudyMetadataRepository(cache_service=icache, session=db_session) - study_1 = VariantStudy(id=1) - study_2 = VariantStudy(id=2) - study_3 = VariantStudy(id=3) - study_4 = VariantStudy(id=4) - study_5 = RawStudy(id=5, missing=datetime.datetime.now(), workspace=DEFAULT_WORKSPACE_NAME) - study_6 = RawStudy(id=6, missing=datetime.datetime.now(), workspace=test_workspace) - study_7 = RawStudy(id=7, missing=None, workspace=test_workspace) - study_8 = RawStudy(id=8, missing=None, workspace=DEFAULT_WORKSPACE_NAME) - - db_session.add_all([study_1, study_2, study_3, study_4, study_5, study_6, study_7, study_8]) + study_1 = VariantStudy(name="s1") + study_2 = VariantStudy(name="s2") + study_3 = VariantStudy(name="s3") + study_4 = VariantStudy(name="s4") + study_5 = RawStudy(name="s5", missing=datetime.datetime.now(), workspace=DEFAULT_WORKSPACE_NAME) + study_6 = RawStudy(name="s6", missing=datetime.datetime.now(), workspace=test_workspace) + study_7 = RawStudy(name="s7", missing=None, workspace=test_workspace) + study_8 = RawStudy(name="s8", missing=None, workspace=DEFAULT_WORKSPACE_NAME) + + my_studies = [study_1, study_2, study_3, study_4, study_5, study_6, study_7, study_8] + db_session.add_all(my_studies) db_session.commit() + ids_by_names = {s.name: s.id for s in my_studies} + # use the db recorder to check that: # 1- retrieving all studies requires only 1 query # 2- accessing studies attributes does not require additional queries to db # 3- having an exact total of queries equals to 1 study_filter = StudyFilter( - managed=managed, study_ids=study_ids, exists=exists, access_permissions=AccessPermissions(is_admin=True) + managed=managed, + study_ids=[ids_by_names[name] for name in study_names], + exists=exists, + access_permissions=AccessPermissions(is_admin=True), ) with DBStatementRecorder(db_session.bind) as db_recorder: - all_studies = repository.get_all(study_filter=study_filter) + all_studies = repository.get_all( + study_filter=study_filter, + sort_by=StudySortBy.NAME_ASC, + ) _ = [s.owner for s in all_studies] _ = [s.groups for s in all_studies] _ = [s.additional_data for s in all_studies] @@ -78,17 +93,32 @@ def test_get_all__general_case( assert len(db_recorder.sql_statements) == 1, str(db_recorder) # test that the expected studies are returned - if expected_ids is not None: - assert {s.id for s in all_studies} == expected_ids + assert [s.name for s in all_studies] == expected_names - # test pagination + # -- test pagination + page_nb = 1 + page_size = 2 + page_slice = slice(page_nb * page_size, (page_nb + 1) * page_size) + + # test pagination in normal order with DBStatementRecorder(db_session.bind) as db_recorder: all_studies = repository.get_all( study_filter=study_filter, - pagination=StudyPagination(page_nb=1, page_size=2), + sort_by=StudySortBy.NAME_ASC, + pagination=StudyPagination(page_nb=page_nb, page_size=page_size), + ) + assert len(db_recorder.sql_statements) == 1, str(db_recorder) + assert [s.name for s in all_studies] == expected_names[page_slice] + + # test pagination in reverse order + with DBStatementRecorder(db_session.bind) as db_recorder: + all_studies = repository.get_all( + study_filter=study_filter, + sort_by=StudySortBy.NAME_DESC, + pagination=StudyPagination(page_nb=page_nb, page_size=page_size), ) - assert len(all_studies) == max(0, min(len(expected_ids) - 2, 2)) assert len(db_recorder.sql_statements) == 1, str(db_recorder) + assert [s.name for s in all_studies] == expected_names[::-1][page_slice] def test_get_all__incompatible_case( From 7a5e55c47f01e82e3f17e74d9a069badd060abf0 Mon Sep 17 00:00:00 2001 From: belthlemar Date: Mon, 18 Mar 2024 16:09:52 +0100 Subject: [PATCH 080/248] feat(bc): add bc validation endpoint --- .../business/binding_constraint_management.py | 207 +++++++----------- antarest/study/web/study_data_blueprint.py | 33 ++- .../test_binding_constraints.py | 107 ++++++--- 3 files changed, 192 insertions(+), 155 deletions(-) diff --git a/antarest/study/business/binding_constraint_management.py b/antarest/study/business/binding_constraint_management.py index 910c30dc51..49b784f9bc 100644 --- a/antarest/study/business/binding_constraint_management.py +++ b/antarest/study/business/binding_constraint_management.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Optional, Union, cast +from typing import Any, Dict, List, Optional, Union from pydantic import BaseModel, validator @@ -32,6 +32,7 @@ from antarest.study.storage.variantstudy.business.matrix_constants.binding_constraint.series_before_v87 import ( default_bc_weekly_daily as default_bc_weekly_daily_86, ) +from antarest.study.storage.variantstudy.model.command.common import BindingConstraintOperator from antarest.study.storage.variantstudy.model.command.create_binding_constraint import ( BindingConstraintMatrices, BindingConstraintProperties, @@ -111,9 +112,15 @@ def generate_id(self) -> str: return self.data.generate_id() -class UpdateBindingConstProps(BaseModel): - key: str - value: Any +class BindingConstraintEdition(BindingConstraintMatrices): + group: Optional[str] = None + enabled: Optional[bool] = None + time_step: Optional[BindingConstraintFrequency] = None + operator: Optional[BindingConstraintOperator] = None + filter_year_by_year: Optional[str] = None + filter_synthesis: Optional[str] = None + comments: Optional[str] = None + coeffs: Optional[Dict[str, List[float]]] = None class BindingConstraintCreation(BindingConstraintMatrices, BindingConstraintProperties870): @@ -239,6 +246,21 @@ def get_binding_constraint( binding_constraint.append(new_config) return binding_constraint + def validate_binding_constraint(self, study: Study, constraint_id: str) -> None: + if int(study.version) < 870: + return # There's nothing to check for constraints before v8.7 + file_study = self.storage_service.get_storage(study).get_raw(study) + config = file_study.tree.get(["input", "bindingconstraints", "bindingconstraints"]) + group = next((value["group"] for value in config.values() if value["id"] == constraint_id), None) + if not group: + raise BindingConstraintNotFoundError(study.id) + matrix_terms = { + "eq": get_matrix_data(file_study, constraint_id, "eq"), + "lt": get_matrix_data(file_study, constraint_id, "lt"), + "gt": get_matrix_data(file_study, constraint_id, "gt"), + } + check_matrices_coherence(file_study, group, constraint_id, matrix_terms) + def create_binding_constraint( self, study: Study, @@ -253,22 +275,17 @@ def create_binding_constraint( if bc_id in {bc.id for bc in self.get_binding_constraint(study, None)}: # type: ignore raise DuplicateConstraintName(f"A binding constraint with the same name already exists: {bc_id}.") - if data.group and version < 870: - raise InvalidFieldForVersionError( - f"You cannot specify a group as your study version is older than v8.7: {data.group}" - ) - - if version >= 870 and not data.group: - data.group = "default" + check_attributes_coherence(data, version) - matrix_terms_list = {"eq": data.equal_term_matrix, "lt": data.less_term_matrix, "gt": data.greater_term_matrix} file_study = self.storage_service.get_storage(study).get_raw(study) if version >= 870: - if data.values is not None: - raise InvalidFieldForVersionError("You cannot fill 'values' as it refers to the matrix before v8.7") - check_matrices_coherence(file_study, data.group or "default", bc_id, matrix_terms_list, {}) - elif any(matrix_terms_list.values()): - raise InvalidFieldForVersionError("You cannot fill a 'matrix_term' as these values refer to v8.7+ studies") + data.group = data.group or "default" + matrix_terms_list = { + "eq": data.equal_term_matrix, + "lt": data.less_term_matrix, + "gt": data.greater_term_matrix, + } + check_matrices_coherence(file_study, data.group, bc_id, matrix_terms_list) command = CreateBindingConstraint( name=data.name, @@ -297,7 +314,7 @@ def update_binding_constraint( self, study: Study, binding_constraint_id: str, - data: UpdateBindingConstProps, + data: BindingConstraintEdition, ) -> None: file_study = self.storage_service.get_storage(study).get_raw(study) constraint = self.get_binding_constraint(study, binding_constraint_id) @@ -307,35 +324,42 @@ def update_binding_constraint( ): raise BindingConstraintNotFoundError(study.id) - if study_version >= 870: - validates_matrices_coherence(file_study, binding_constraint_id, constraint.group or "default", data) # type: ignore + check_attributes_coherence(data, study_version) + # Because the update_binding_constraint command requires every attribute we have to fill them all. + # This creates a `big` command even though we only updated one field. + # fixme : Change the architecture to avoid this type of misconception args = { "id": binding_constraint_id, - "enabled": data.value if data.key == "enabled" else constraint.enabled, - "time_step": data.value if data.key == "time_step" else constraint.time_step, - "operator": data.value if data.key == "operator" else constraint.operator, - "coeffs": BindingConstraintManager.constraints_to_coeffs(constraint), - "filter_year_by_year": data.value if data.key == "filterByYear" else constraint.filter_year_by_year, - "filter_synthesis": data.value if data.key == "filterSynthesis" else constraint.filter_synthesis, - "comments": data.value if data.key == "comments" else constraint.comments, + "enabled": data.enabled or constraint.enabled, + "time_step": data.time_step or constraint.time_step, + "operator": data.operator or constraint.operator, + "coeffs": data.coeffs or BindingConstraintManager.constraints_to_coeffs(constraint), + "filter_year_by_year": data.filter_year_by_year or constraint.filter_year_by_year, + "filter_synthesis": data.filter_synthesis or constraint.filter_synthesis, + "comments": data.comments or constraint.comments, "command_context": self.storage_service.variant_study_service.command_factory.command_context, } + for term in ["values", "less_term_matrix", "equal_term_matrix", "greater_term_matrix"]: + if matrices_to_update := getattr(data, term): + args[term] = matrices_to_update - args = _fill_group_value(data, constraint, study_version, args) - args = _fill_matrices_according_to_version(data, study_version, args) + if study_version >= 870: + args["group"] = data.group or constraint.group # type: ignore - if data.key == "time_step" and data.value != constraint.time_step: + if data.time_step is not None and data.time_step != constraint.time_step: # The user changed the time step, we need to update the matrix accordingly - args = _replace_matrices_according_to_frequency_and_version(data, study_version, args) + args = _replace_matrices_according_to_frequency_and_version(data.time_step, study_version, args) command = UpdateBindingConstraint(**args) # Validates the matrices. Needed when the study is a variant because we only append the command to the list if isinstance(study, VariantStudy): - updated_matrix = None - if data.key in ["less_term_matrix", "equal_term_matrix", "greater_term_matrix"]: - updated_matrix = [data.key] - command.validates_and_fills_matrices(specific_matrices=updated_matrix, version=study_version, create=False) + updated_matrices = [ + term for term in ["less_term_matrix", "equal_term_matrix", "greater_term_matrix"] if getattr(data, term) + ] + command.validates_and_fills_matrices( + specific_matrices=updated_matrices, version=study_version, create=False + ) execute_or_add_commands(study, file_study, [command], self.storage_service) @@ -460,56 +484,22 @@ def remove_constraint_term( return self.update_constraint_term(study, binding_constraint_id, term_id) -def _fill_group_value( - data: UpdateBindingConstProps, constraint: BindingConstraintConfigType, version: int, args: Dict[str, Any] -) -> Dict[str, Any]: - if version < 870: - if data.key == "group": - raise InvalidFieldForVersionError( - f"You cannot specify a group as your study version is older than v8.7: {data.value}" - ) - else: - # cast to 870 to use the attribute group - constraint = cast(BindingConstraintConfig870, constraint) - args["group"] = data.value if data.key == "group" else constraint.group - return args - - -def _fill_matrices_according_to_version( - data: UpdateBindingConstProps, version: int, args: Dict[str, Any] -) -> Dict[str, Any]: - if data.key == "values": - if version >= 870: - raise InvalidFieldForVersionError("You cannot fill 'values' as it refers to the matrix before v8.7") - args["values"] = data.value - return args - for matrix in ["less_term_matrix", "equal_term_matrix", "greater_term_matrix"]: - if data.key == matrix: - if version < 870: - raise InvalidFieldForVersionError( - "You cannot fill a 'matrix_term' as these values refer to v8.7+ studies" - ) - args[matrix] = data.value - return args - return args - - def _replace_matrices_according_to_frequency_and_version( - data: UpdateBindingConstProps, version: int, args: Dict[str, Any] + frequency: BindingConstraintFrequency, version: int, args: Dict[str, Any] ) -> Dict[str, Any]: if version < 870: matrix = { BindingConstraintFrequency.HOURLY.value: default_bc_hourly_86, BindingConstraintFrequency.DAILY.value: default_bc_weekly_daily_86, BindingConstraintFrequency.WEEKLY.value: default_bc_weekly_daily_86, - }[data.value].tolist() + }[frequency].tolist() args["values"] = matrix else: matrix = { BindingConstraintFrequency.HOURLY.value: default_bc_hourly_87, BindingConstraintFrequency.DAILY.value: default_bc_weekly_daily_87, BindingConstraintFrequency.WEEKLY.value: default_bc_weekly_daily_87, - }[data.value].tolist() + }[frequency].tolist() args["less_term_matrix"] = matrix args["equal_term_matrix"] = matrix args["greater_term_matrix"] = matrix @@ -531,11 +521,7 @@ def get_binding_constraint_of_a_given_group(file_study: FileStudy, group_id: str def check_matrices_coherence( - file_study: FileStudy, - group_id: str, - binding_constraint_id: str, - matrix_terms: Dict[str, Any], - matrix_to_avoid: Dict[str, str], + file_study: FileStudy, group_id: str, binding_constraint_id: str, matrix_terms: Dict[str, Any] ) -> None: given_number_of_cols = set() for term_str, term_data in matrix_terms.items(): @@ -551,57 +537,26 @@ def check_matrices_coherence( given_size = list(given_number_of_cols)[0] for bd_id in get_binding_constraint_of_a_given_group(file_study, group_id): for term in list(matrix_terms.keys()): - if ( - bd_id not in matrix_to_avoid or matrix_to_avoid[bd_id] != term - ): # avoids to check the matrix that will be replaced - matrix_file = file_study.tree.get(url=["input", "bindingconstraints", f"{bd_id}_{term}"]) - column_size = len(matrix_file["data"][0]) - if column_size > 1 and column_size != given_size: - raise IncoherenceBetweenMatricesLength( - f"The matrices of the group {group_id} do not have the same number of columns" - ) - - -def validates_matrices_coherence( - file_study: FileStudy, binding_constraint_id: str, group: str, data: UpdateBindingConstProps -) -> None: - if data.key == "group": - matrix_terms = { - "eq": get_matrix_data(file_study, binding_constraint_id, "eq"), - "lt": get_matrix_data(file_study, binding_constraint_id, "lt"), - "gt": get_matrix_data(file_study, binding_constraint_id, "gt"), - } - check_matrices_coherence(file_study, data.value, binding_constraint_id, matrix_terms, {}) + matrix_file = file_study.tree.get(url=["input", "bindingconstraints", f"{bd_id}_{term}"]) + column_size = len(matrix_file["data"][0]) + if column_size > 1 and column_size != given_size: + raise IncoherenceBetweenMatricesLength( + f"The matrices of the group {group_id} do not have the same number of columns" + ) + - if data.key in ["less_term_matrix", "equal_term_matrix", "greater_term_matrix"]: - if isinstance(data.value, str): - raise NotImplementedError( - f"We do not currently handle binding constraint update for {data.key} with a string value. Please provide a matrix" +def check_attributes_coherence( + data: Union[BindingConstraintCreation, BindingConstraintEdition], study_version: int +) -> None: + if study_version < 870: + if data.group: + raise InvalidFieldForVersionError( + f"You cannot specify a group as your study version is older than v8.7: {data.group}" ) - if data.key == "less_term_matrix": - term_to_avoid = "lt" - matrix_terms = { - "lt": data.value, - "eq": get_matrix_data(file_study, binding_constraint_id, "eq"), - "gt": get_matrix_data(file_study, binding_constraint_id, "gt"), - } - elif data.key == "greater_term_matrix": - term_to_avoid = "gt" - matrix_terms = { - "gt": data.value, - "eq": get_matrix_data(file_study, binding_constraint_id, "eq"), - "lt": get_matrix_data(file_study, binding_constraint_id, "lt"), - } - else: - term_to_avoid = "eq" - matrix_terms = { - "eq": data.value, - "gt": get_matrix_data(file_study, binding_constraint_id, "gt"), - "lt": get_matrix_data(file_study, binding_constraint_id, "lt"), - } - check_matrices_coherence( - file_study, group, binding_constraint_id, matrix_terms, {binding_constraint_id: term_to_avoid} - ) + if any([data.less_term_matrix, data.equal_term_matrix, data.greater_term_matrix]): + raise InvalidFieldForVersionError("You cannot fill a 'matrix_term' as these values refer to v8.7+ studies") + elif data.values: + raise InvalidFieldForVersionError("You cannot fill 'values' as it refers to the matrix before v8.7") def get_matrix_data(file_study: FileStudy, binding_constraint_id: str, keyword: str) -> List[Any]: diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index 34067cefcb..e6f39465a0 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -43,8 +43,8 @@ ) from antarest.study.business.binding_constraint_management import ( BindingConstraintCreation, + BindingConstraintEdition, ConstraintTermDTO, - UpdateBindingConstProps, ) from antarest.study.business.correlation_management import CorrelationFormFields, CorrelationManager, CorrelationMatrix from antarest.study.business.district_manager import DistrictCreationDTO, DistrictInfoDTO, DistrictUpdateDTO @@ -926,7 +926,7 @@ def get_binding_constraint( def update_binding_constraint( uuid: str, binding_constraint_id: str, - data: UpdateBindingConstProps, + data: BindingConstraintEdition, current_user: JWTUser = Depends(auth.get_current_user), ) -> Any: logger.info( @@ -937,6 +937,35 @@ def update_binding_constraint( study = study_service.check_study_access(uuid, StudyPermissionType.WRITE, params) return study_service.binding_constraint_manager.update_binding_constraint(study, binding_constraint_id, data) + @bp.get( + "/studies/{uuid}/bindingconstraints/{binding_constraint_id}/validate", + tags=[APITag.study_data], + summary="Validate binding constraint configuration", + response_model=None, + ) + def validate_binding_constraint( + uuid: str, + binding_constraint_id: str, + current_user: JWTUser = Depends(auth.get_current_user), + ) -> Any: + """ + Validates the binding constraint configuration. + + Parameters: + - `uuid`: The study UUID. + - `binding_constraint_id`: The binding constraint id to validate + + For studies version prior to v8.7, does nothing. + Else, it checks the coherence between the group and the size of its columns. + """ + logger.info( + f"Validating binding constraint {binding_constraint_id} for study {uuid}", + extra={"user": current_user.id}, + ) + params = RequestParameters(user=current_user) + study = study_service.check_study_access(uuid, StudyPermissionType.READ, params) + return study_service.binding_constraint_manager.validate_binding_constraint(study, binding_constraint_id) + @bp.post( "/studies/{uuid}/bindingconstraints", tags=[APITag.study_data], diff --git a/tests/integration/study_data_blueprint/test_binding_constraints.py b/tests/integration/study_data_blueprint/test_binding_constraints.py index 1597eb3c7d..ce0637226c 100644 --- a/tests/integration/study_data_blueprint/test_binding_constraints.py +++ b/tests/integration/study_data_blueprint/test_binding_constraints.py @@ -256,12 +256,16 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st ] assert binding_constraints_list == expected + bc_id = binding_constraints_list[0]["id"] + + # Asserts binding constraint configuration is always valid. + res = client.get(f"/v1/studies/{study_id}/bindingconstraints/{bc_id}/validate", headers=user_headers) + assert res.status_code == 200, res.json() + # ============================= # CONSTRAINT TERM MANAGEMENT # ============================= - bc_id = binding_constraints_list[0]["id"] - # Add binding constraint link term res = client.post( f"/v1/studies/{study_id}/bindingconstraints/{bc_id}/term", @@ -395,7 +399,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st new_comment = "We made it !" res = client.put( f"v1/studies/{study_id}/bindingconstraints/{bc_id}", - json={"key": "comments", "value": new_comment}, + json={"comments": new_comment}, headers=user_headers, ) assert res.status_code == 200, res.json() @@ -414,7 +418,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st # We must check that the matrix is a daily/weekly matrix. res = client.put( f"/v1/studies/{study_id}/bindingconstraints/{bc_id}", - json={"key": "time_step", "value": "daily"}, + json={"time_step": "daily"}, headers=user_headers, ) assert res.status_code == 200, res.json() @@ -567,7 +571,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st grp_name = "random_grp" res = client.put( f"/v1/studies/{study_id}/bindingconstraints/binding_constraint_2", - json={"key": "group", "value": grp_name}, + json={"group": grp_name}, headers=user_headers, ) assert res.status_code == 422 @@ -580,7 +584,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st # Update with a matrix from v8.7 res = client.put( f"/v1/studies/{study_id}/bindingconstraints/binding_constraint_2", - json={"key": "less_term_matrix", "value": [[]]}, + json={"less_term_matrix": [[]]}, headers=user_headers, ) assert res.status_code == 422 @@ -683,7 +687,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud grp_name = "random_grp" res = client.put( f"/v1/studies/{study_id}/bindingconstraints/{bc_id_w_matrix}", - json={"key": "group", "value": grp_name}, + json={"group": grp_name}, headers=admin_headers, ) assert res.status_code == 200, res.json() @@ -695,14 +699,14 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud # Update matrix_term res = client.put( f"/v1/studies/{study_id}/bindingconstraints/{bc_id_w_matrix}", - json={"key": "greater_term_matrix", "value": matrix_lt_to_list}, + json={"greater_term_matrix": matrix_lt_to_list}, headers=admin_headers, ) assert res.status_code == 200, res.json() res = client.get( f"/v1/studies/{study_id}/raw", - params={"path": f"input/bindingconstraints/{bc_id_w_matrix}_gt", "depth": 1, "formatted": True}, + params={"path": f"input/bindingconstraints/{bc_id_w_matrix}_gt"}, headers=admin_headers, ) assert res.status_code == 200 @@ -712,7 +716,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud # We must check that the matrices have been updated. res = client.put( f"/v1/studies/{study_id}/bindingconstraints/{bc_id_w_matrix}", - json={"key": "time_step", "value": "daily"}, + json={"time_step": "daily"}, headers=admin_headers, ) assert res.status_code == 200, res.json() @@ -781,7 +785,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud # Update with old matrices res = client.put( f"/v1/studies/{study_id}/bindingconstraints/{bc_id_w_matrix}", - json={"key": "values", "value": [[]]}, + json={"values": [[]]}, headers=admin_headers, ) assert res.status_code == 422 @@ -811,13 +815,20 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud == "The matrices of binding_constraint_with_wrong_matrix must have the same number of columns, currently {2, 3}" ) + # fixme: a changer + + # # Creation of 2 bc inside the same group with different columns size - bc_id = "binding_constraint_validation" + # first_bc: 3 cols, group1 + # second_bc: 4 cols, group1 -> Should fail + # + + first_bc = "binding_constraint_validation" matrix_lt = np.ones((8784, 3)) matrix_lt_to_list = matrix_lt.tolist() res = client.post( f"/v1/studies/{study_id}/bindingconstraints", - json={"name": bc_id, "less_term_matrix": matrix_lt_to_list, "group": "group1", **args}, + json={"name": first_bc, "less_term_matrix": matrix_lt_to_list, "group": "group1", **args}, headers=admin_headers, ) assert res.status_code in {200, 201}, res.json() @@ -833,34 +844,61 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" assert res.json()["description"] == "The matrices of the group group1 do not have the same number of columns" - # Updating thr group of a bc creates different columns size inside the same group - bc_id = "binding_constraint_validation_2" + # + # Updating the group of a bc creates different columns size inside the same group + # first_bc: 3 cols, group 1 + # second_bc: 4 cols, group2 -> OK + # second_bc group changes to group1 -> Fails validation + # + + second_bc = "binding_constraint_validation_2" matrix_lt = np.ones((8784, 4)) matrix_lt_to_list = matrix_lt.tolist() res = client.post( f"/v1/studies/{study_id}/bindingconstraints", - json={"name": bc_id, "less_term_matrix": matrix_lt_to_list, "group": "group2", **args}, + json={"name": second_bc, "less_term_matrix": matrix_lt_to_list, "group": "group2", **args}, headers=admin_headers, ) assert res.status_code in {200, 201}, res.json() res = client.put( - f"v1/studies/{study_id}/bindingconstraints/{bc_id}", - json={"key": "group", "value": "group1"}, + f"v1/studies/{study_id}/bindingconstraints/{second_bc}", + json={"group": "group1"}, headers=admin_headers, ) + # This should succeed but cause the validation endpoint to fail. + assert res.status_code in {200, 201}, res.json() + + res = client.get(f"/v1/studies/{study_id}/bindingconstraints/{second_bc}/validate", headers=admin_headers) assert res.status_code == 422 assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" assert res.json()["description"] == "The matrices of the group group1 do not have the same number of columns" + # # Update causes different matrices size inside the same bc + # second_bc: 1st matrix has 4 cols and others 1 -> OK + # second_bc: 1st matrix has 4 cols and 2nd matrix has 3 cols -> Fails validation + # + + res = client.put( + f"v1/studies/{study_id}/bindingconstraints/{second_bc}", json={"group": "group2"}, headers=admin_headers + ) + assert res.status_code in {200, 201}, res.json() + # For the moment the bc is valid + res = client.get(f"/v1/studies/{study_id}/bindingconstraints/{second_bc}/validate", headers=admin_headers) + assert res.status_code in {200, 201}, res.json() + matrix_lt_3 = np.ones((8784, 3)) matrix_lt_3_to_list = matrix_lt_3.tolist() res = client.put( - f"v1/studies/{study_id}/bindingconstraints/{bc_id}", - json={"key": "greater_term_matrix", "value": matrix_lt_3_to_list}, + f"v1/studies/{study_id}/bindingconstraints/{second_bc}", + json={"greater_term_matrix": matrix_lt_3_to_list}, headers=admin_headers, ) + # This should succeed but cause the validation endpoint to fail. + assert res.status_code in {200, 201}, res.json() + + res = client.get(f"/v1/studies/{study_id}/bindingconstraints/{second_bc}/validate", headers=admin_headers) assert res.status_code == 422 assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" assert ( @@ -868,24 +906,39 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud == "The matrices of binding_constraint_validation_2 must have the same number of columns, currently {3, 4}" ) - # Update causes different matrices size inside the same group + # + # Updating a matrix causes different matrices size inside the same group + # first_bc: 3 cols, group1 + # second_bc: 3 cols, group1 -> OK + # second_bc: update 2 matrices with 4 cols, group1 -> Fails validation + # + res = client.put( - f"v1/studies/{study_id}/bindingconstraints/{bc_id}", - json={"key": "less_term_matrix", "value": matrix_lt_3_to_list}, + f"v1/studies/{study_id}/bindingconstraints/{second_bc}", + json={"less_term_matrix": matrix_lt_3_to_list}, headers=admin_headers, ) assert res.status_code in {200, 201}, res.json() + + # For the moment the bc is valid + res = client.get(f"/v1/studies/{study_id}/bindingconstraints/{second_bc}/validate", headers=admin_headers) + assert res.status_code in {200, 201}, res.json() + res = client.put( - f"v1/studies/{study_id}/bindingconstraints/{bc_id}", - json={"key": "group", "value": "group1"}, + f"v1/studies/{study_id}/bindingconstraints/{second_bc}", + json={"group": "group1"}, headers=admin_headers, ) assert res.status_code in {200, 201}, res.json() res = client.put( - f"v1/studies/{study_id}/bindingconstraints/{bc_id}", - json={"key": "less_term_matrix", "value": matrix_lt_to_list}, + f"v1/studies/{study_id}/bindingconstraints/{second_bc}", + json={"less_term_matrix": matrix_lt_to_list, "greater_term_matrix": matrix_lt_to_list}, headers=admin_headers, ) + # This should succeed but cause the validation endpoint to fail. + assert res.status_code in {200, 201}, res.json() + + res = client.get(f"/v1/studies/{study_id}/bindingconstraints/{second_bc}/validate", headers=admin_headers) assert res.status_code == 422 assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" assert res.json()["description"] == "The matrices of the group group1 do not have the same number of columns" From 79f11fee63a5cfe0a48215d6fb8a7da1e819f71b Mon Sep 17 00:00:00 2001 From: belthlemar Date: Mon, 18 Mar 2024 19:02:41 +0100 Subject: [PATCH 081/248] feat(bc): add response model to post and put endpoints --- .../business/binding_constraint_management.py | 83 ++++++++++++------- antarest/study/web/study_data_blueprint.py | 13 +-- .../test_binding_constraints.py | 31 ++----- 3 files changed, 62 insertions(+), 65 deletions(-) diff --git a/antarest/study/business/binding_constraint_management.py b/antarest/study/business/binding_constraint_management.py index 49b784f9bc..fb8a41d861 100644 --- a/antarest/study/business/binding_constraint_management.py +++ b/antarest/study/business/binding_constraint_management.py @@ -14,7 +14,7 @@ MissingDataError, NoConstraintError, ) -from antarest.study.business.utils import execute_or_add_commands +from antarest.study.business.utils import AllOptionalMetaclass, execute_or_add_commands from antarest.study.model import Study from antarest.study.storage.rawstudy.model.filesystem.config.binding_constraint import BindingConstraintFrequency from antarest.study.storage.rawstudy.model.filesystem.config.model import transform_name_to_id @@ -112,15 +112,19 @@ def generate_id(self) -> str: return self.data.generate_id() -class BindingConstraintEdition(BindingConstraintMatrices): - group: Optional[str] = None - enabled: Optional[bool] = None - time_step: Optional[BindingConstraintFrequency] = None - operator: Optional[BindingConstraintOperator] = None - filter_year_by_year: Optional[str] = None - filter_synthesis: Optional[str] = None - comments: Optional[str] = None - coeffs: Optional[Dict[str, List[float]]] = None +class BindingConstraintForm(BaseModel, metaclass=AllOptionalMetaclass): + group: str + enabled: bool + time_step: BindingConstraintFrequency + operator: BindingConstraintOperator + filter_year_by_year: str + filter_synthesis: str + comments: str + coeffs: Dict[str, List[float]] + + +class BindingConstraintEdition(BindingConstraintMatrices, BindingConstraintForm): + pass class BindingConstraintCreation(BindingConstraintMatrices, BindingConstraintProperties870): @@ -265,7 +269,7 @@ def create_binding_constraint( self, study: Study, data: BindingConstraintCreation, - ) -> None: + ) -> BindingConstraintConfigType: bc_id = transform_name_to_id(data.name) version = int(study.version) @@ -287,35 +291,43 @@ def create_binding_constraint( } check_matrices_coherence(file_study, data.group, bc_id, matrix_terms_list) + args = { + "name": data.name, + "enabled": data.enabled, + "time_step": data.time_step, + "operator": data.operator, + "coeffs": data.coeffs, + "values": data.values, + "less_term_matrix": data.less_term_matrix, + "equal_term_matrix": data.equal_term_matrix, + "greater_term_matrix": data.greater_term_matrix, + "filter_year_by_year": data.filter_year_by_year, + "filter_synthesis": data.filter_synthesis, + "comments": data.comments or "", + } + if version >= 870: + args["group"] = data.group + command = CreateBindingConstraint( - name=data.name, - enabled=data.enabled, - time_step=data.time_step, - operator=data.operator, - coeffs=data.coeffs, - values=data.values, - less_term_matrix=data.less_term_matrix, - equal_term_matrix=data.equal_term_matrix, - greater_term_matrix=data.greater_term_matrix, - filter_year_by_year=data.filter_year_by_year, - filter_synthesis=data.filter_synthesis, - comments=data.comments or "", - group=data.group, - command_context=self.storage_service.variant_study_service.command_factory.command_context, + **args, command_context=self.storage_service.variant_study_service.command_factory.command_context ) # Validates the matrices. Needed when the study is a variant because we only append the command to the list if isinstance(study, VariantStudy): command.validates_and_fills_matrices(specific_matrices=None, version=version, create=True) - execute_or_add_commands(study, file_study, [command], self.storage_service) + # Processes the constraints to add them inside the endpoint response. + args["id"] = bc_id + args["type"] = data.time_step + return BindingConstraintManager.process_constraint(args, version) + def update_binding_constraint( self, study: Study, binding_constraint_id: str, data: BindingConstraintEdition, - ) -> None: + ) -> BindingConstraintConfigType: file_study = self.storage_service.get_storage(study).get_raw(study) constraint = self.get_binding_constraint(study, binding_constraint_id) study_version = int(study.version) @@ -329,7 +341,7 @@ def update_binding_constraint( # Because the update_binding_constraint command requires every attribute we have to fill them all. # This creates a `big` command even though we only updated one field. # fixme : Change the architecture to avoid this type of misconception - args = { + binding_constraint_output = { "id": binding_constraint_id, "enabled": data.enabled or constraint.enabled, "time_step": data.time_step or constraint.time_step, @@ -338,15 +350,18 @@ def update_binding_constraint( "filter_year_by_year": data.filter_year_by_year or constraint.filter_year_by_year, "filter_synthesis": data.filter_synthesis or constraint.filter_synthesis, "comments": data.comments or constraint.comments, + } + if study_version >= 870: + binding_constraint_output["group"] = data.group or constraint.group # type: ignore + + args = { + **binding_constraint_output, "command_context": self.storage_service.variant_study_service.command_factory.command_context, } for term in ["values", "less_term_matrix", "equal_term_matrix", "greater_term_matrix"]: if matrices_to_update := getattr(data, term): args[term] = matrices_to_update - if study_version >= 870: - args["group"] = data.group or constraint.group # type: ignore - if data.time_step is not None and data.time_step != constraint.time_step: # The user changed the time step, we need to update the matrix accordingly args = _replace_matrices_according_to_frequency_and_version(data.time_step, study_version, args) @@ -360,9 +375,13 @@ def update_binding_constraint( command.validates_and_fills_matrices( specific_matrices=updated_matrices, version=study_version, create=False ) - execute_or_add_commands(study, file_study, [command], self.storage_service) + # Processes the constraints to add them inside the endpoint response. + binding_constraint_output["name"] = constraint.name + binding_constraint_output["type"] = binding_constraint_output["time_step"] + return BindingConstraintManager.process_constraint(binding_constraint_output, study_version) + def remove_binding_constraint(self, study: Study, binding_constraint_id: str) -> None: command = RemoveBindingConstraint( id=binding_constraint_id, diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index e6f39465a0..c3eb2a98fc 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -42,6 +42,7 @@ ThermalManager, ) from antarest.study.business.binding_constraint_management import ( + BindingConstraintConfigType, BindingConstraintCreation, BindingConstraintEdition, ConstraintTermDTO, @@ -921,14 +922,13 @@ def get_binding_constraint( "/studies/{uuid}/bindingconstraints/{binding_constraint_id}", tags=[APITag.study_data], summary="Update binding constraint", - response_model=None, # Dict[str, bool], ) def update_binding_constraint( uuid: str, binding_constraint_id: str, data: BindingConstraintEdition, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> BindingConstraintConfigType: logger.info( f"Update binding constraint {binding_constraint_id} for study {uuid}", extra={"user": current_user.id}, @@ -966,15 +966,10 @@ def validate_binding_constraint( study = study_service.check_study_access(uuid, StudyPermissionType.READ, params) return study_service.binding_constraint_manager.validate_binding_constraint(study, binding_constraint_id) - @bp.post( - "/studies/{uuid}/bindingconstraints", - tags=[APITag.study_data], - summary="Create a binding constraint", - response_model=None, - ) + @bp.post("/studies/{uuid}/bindingconstraints", tags=[APITag.study_data], summary="Create a binding constraint") def create_binding_constraint( uuid: str, data: BindingConstraintCreation, current_user: JWTUser = Depends(auth.get_current_user) - ) -> None: + ) -> BindingConstraintConfigType: logger.info( f"Creating a new binding constraint for study {uuid}", extra={"user": current_user.id}, diff --git a/tests/integration/study_data_blueprint/test_binding_constraints.py b/tests/integration/study_data_blueprint/test_binding_constraints.py index ce0637226c..d9899e6b85 100644 --- a/tests/integration/study_data_blueprint/test_binding_constraints.py +++ b/tests/integration/study_data_blueprint/test_binding_constraints.py @@ -402,17 +402,8 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st json={"comments": new_comment}, headers=user_headers, ) - assert res.status_code == 200, res.json() - - # Get Binding Constraint - res = client.get( - f"/v1/studies/{study_id}/bindingconstraints/{bc_id}", - headers=user_headers, - ) - binding_constraint = res.json() - comments = binding_constraint["comments"] - assert res.status_code == 200, res.json() - assert comments == new_comment + assert res.status_code == 200 + assert res.json()["comments"] == new_comment # The user change the time_step to daily instead of hourly. # We must check that the matrix is a daily/weekly matrix. @@ -421,7 +412,8 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st json={"time_step": "daily"}, headers=user_headers, ) - assert res.status_code == 200, res.json() + assert res.status_code == 200 + assert res.json()["time_step"] == "daily" # Check the last command is a change time_step if study_type == "variant": @@ -629,9 +621,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud json={"name": bc_id_wo_group, **args}, headers=admin_headers, ) - assert res.status_code in {200, 201}, res.json() - - res = client.get(f"/v1/studies/{study_id}/bindingconstraints/{bc_id_wo_group}", headers=admin_headers) + assert res.status_code in {200, 201} assert res.json()["group"] == "default" # Creation of bc with a group @@ -641,9 +631,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud json={"name": bc_id_w_group, "group": "specific_grp", **args}, headers=admin_headers, ) - assert res.status_code in {200, 201}, res.json() - - res = client.get(f"/v1/studies/{study_id}/bindingconstraints/{bc_id_w_group}", headers=admin_headers) + assert res.status_code in {200, 201} assert res.json()["group"] == "specific_grp" # Creation of bc with a matrix @@ -690,10 +678,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud json={"group": grp_name}, headers=admin_headers, ) - assert res.status_code == 200, res.json() - - # Asserts the groupe is created - res = client.get(f"/v1/studies/{study_id}/bindingconstraints/{bc_id_w_matrix}", headers=admin_headers) + assert res.status_code == 200 assert res.json()["group"] == grp_name # Update matrix_term @@ -815,8 +800,6 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud == "The matrices of binding_constraint_with_wrong_matrix must have the same number of columns, currently {2, 3}" ) - # fixme: a changer - # # Creation of 2 bc inside the same group with different columns size # first_bc: 3 cols, group1 From f40f723cd0663fce1e61d5c7281871c8104df507 Mon Sep 17 00:00:00 2001 From: belthlemar Date: Tue, 19 Mar 2024 09:28:28 +0100 Subject: [PATCH 082/248] fix(bc): only replace outdated matrices when updating frequency --- .../business/binding_constraint_management.py | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/antarest/study/business/binding_constraint_management.py b/antarest/study/business/binding_constraint_management.py index fb8a41d861..3ced8a49f0 100644 --- a/antarest/study/business/binding_constraint_management.py +++ b/antarest/study/business/binding_constraint_management.py @@ -112,7 +112,7 @@ def generate_id(self) -> str: return self.data.generate_id() -class BindingConstraintForm(BaseModel, metaclass=AllOptionalMetaclass): +class BindingConstraintEditionModel(BaseModel, metaclass=AllOptionalMetaclass): group: str enabled: bool time_step: BindingConstraintFrequency @@ -123,7 +123,7 @@ class BindingConstraintForm(BaseModel, metaclass=AllOptionalMetaclass): coeffs: Dict[str, List[float]] -class BindingConstraintEdition(BindingConstraintMatrices, BindingConstraintForm): +class BindingConstraintEdition(BindingConstraintMatrices, BindingConstraintEditionModel): pass @@ -364,7 +364,7 @@ def update_binding_constraint( if data.time_step is not None and data.time_step != constraint.time_step: # The user changed the time step, we need to update the matrix accordingly - args = _replace_matrices_according_to_frequency_and_version(data.time_step, study_version, args) + args = _replace_matrices_according_to_frequency_and_version(data, study_version, args) command = UpdateBindingConstraint(**args) # Validates the matrices. Needed when the study is a variant because we only append the command to the list @@ -504,24 +504,25 @@ def remove_constraint_term( def _replace_matrices_according_to_frequency_and_version( - frequency: BindingConstraintFrequency, version: int, args: Dict[str, Any] + data: BindingConstraintEdition, version: int, args: Dict[str, Any] ) -> Dict[str, Any]: if version < 870: - matrix = { - BindingConstraintFrequency.HOURLY.value: default_bc_hourly_86, - BindingConstraintFrequency.DAILY.value: default_bc_weekly_daily_86, - BindingConstraintFrequency.WEEKLY.value: default_bc_weekly_daily_86, - }[frequency].tolist() - args["values"] = matrix + if "values" not in args: + matrix = { + BindingConstraintFrequency.HOURLY.value: default_bc_hourly_86, + BindingConstraintFrequency.DAILY.value: default_bc_weekly_daily_86, + BindingConstraintFrequency.WEEKLY.value: default_bc_weekly_daily_86, + }[data.time_step].tolist() + args["values"] = matrix else: matrix = { BindingConstraintFrequency.HOURLY.value: default_bc_hourly_87, BindingConstraintFrequency.DAILY.value: default_bc_weekly_daily_87, BindingConstraintFrequency.WEEKLY.value: default_bc_weekly_daily_87, - }[frequency].tolist() - args["less_term_matrix"] = matrix - args["equal_term_matrix"] = matrix - args["greater_term_matrix"] = matrix + }[data.time_step].tolist() + for term in ["less_term_matrix", "equal_term_matrix", "greater_term_matrix"]: + if term not in args: + args[term] = matrix return args From 7d91d446f89e0bcdb70938bea089917c8a608261 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Thu, 21 Mar 2024 22:10:22 +0100 Subject: [PATCH 083/248] chore: remove useless comment in blueprint --- antarest/study/web/studies_blueprint.py | 1 - 1 file changed, 1 deletion(-) diff --git a/antarest/study/web/studies_blueprint.py b/antarest/study/web/studies_blueprint.py index 6565538281..1945f71ab4 100644 --- a/antarest/study/web/studies_blueprint.py +++ b/antarest/study/web/studies_blueprint.py @@ -86,7 +86,6 @@ def get_studies( exists: t.Optional[bool] = Query(None, description="Filter studies based on their existence on disk."), workspace: str = Query("", description="Filter studies based on their workspace."), folder: str = Query("", description="Filter studies based on their folder."), - # It is advisable to use an optional Query parameter for enumerated types, like booleans. sort_by: t.Optional[StudySortBy] = Query( None, description="Sort studies based on their name (case-insensitive) or creation date.", From d09d67bf6b939a4b6c40c8d121a279c24a42f900 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Thu, 21 Mar 2024 22:56:18 +0100 Subject: [PATCH 084/248] feat(bc): add the ability to filter BC on various properties --- .../business/binding_constraint_management.py | 140 +++++++++++++++--- antarest/study/web/study_data_blueprint.py | 64 +++++++- 2 files changed, 176 insertions(+), 28 deletions(-) diff --git a/antarest/study/business/binding_constraint_management.py b/antarest/study/business/binding_constraint_management.py index 3ced8a49f0..0434c0e738 100644 --- a/antarest/study/business/binding_constraint_management.py +++ b/antarest/study/business/binding_constraint_management.py @@ -112,6 +112,103 @@ def generate_id(self) -> str: return self.data.generate_id() +class BindingConstraintFilter(BaseModel, frozen=True, extra="forbid"): + """ + Binding Constraint Filter gathering the main filtering parameters. + + Attributes: + bc_id: binding constraint ID (exact match) + enabled: enabled status + operator: operator + comments: comments (word match, case-insensitive) + group: on group name (exact match, case-insensitive) + time_step: time step + area_name: area name (word match, case-insensitive) + cluster_name: cluster name (word match, case-insensitive) + link_id: link ID ('area1%area2') in at least one term. + cluster_id: cluster ID ('area.cluster') in at least one term. + """ + + bc_id: str = "" + enabled: Optional[bool] = None + operator: Optional[BindingConstraintOperator] = None + comments: str = "" + group: str = "" + time_step: Optional[BindingConstraintFrequency] = None + area_name: str = "" + cluster_name: str = "" + link_id: str = "" + cluster_id: str = "" + + def accept(self, constraint: "BindingConstraintConfigType") -> bool: + """ + Check if the constraint matches the filter. + + Args: + constraint: the constraint to check + + Returns: + True if the constraint matches the filter, False otherwise + """ + if self.bc_id and self.bc_id != constraint.id: + return False + if self.enabled is not None and self.enabled != constraint.enabled: + return False + if self.operator is not None and self.operator != constraint.operator: + return False + if self.comments: + comments = constraint.comments or "" + if self.comments.upper() not in comments.upper(): + return False + if self.group: + group = getattr(constraint, "group") or "" + if self.group.upper() != group.upper(): + return False + if self.time_step is not None and self.time_step != constraint.time_step: + return False + + # Filter on terms + terms = constraint.constraints or [] + + if self.area_name: + all_areas = [] + for term in terms: + if term.data is None: + continue + if isinstance(term.data, AreaLinkDTO): + all_areas.extend([term.data.area1, term.data.area2]) + elif isinstance(term.data, AreaClusterDTO): + all_areas.append(term.data.area) + else: # pragma: no cover + raise NotImplementedError(f"Unknown term data type: {type(term.data)}") + upper_area_name = self.area_name.upper() + if all_areas and not any(upper_area_name in area.upper() for area in all_areas): + return False + + if self.cluster_name: + all_clusters = [] + for term in terms: + if term.data is None: + continue + if isinstance(term.data, AreaClusterDTO): + all_clusters.append(term.data.cluster) + upper_cluster_name = self.cluster_name.upper() + if all_clusters and not any(upper_cluster_name in cluster.upper() for cluster in all_clusters): + return False + + if self.link_id: + all_link_ids = [term.data.generate_id() for term in terms if isinstance(term.data, AreaLinkDTO)] + if not any(self.link_id.lower() == link_id.lower() for link_id in all_link_ids): + return False + + if self.cluster_id: + all_cluster_ids = [term.data.generate_id() for term in terms if isinstance(term.data, AreaClusterDTO)] + if not any(self.cluster_id.lower() == cluster_id.lower() for cluster_id in all_cluster_ids): + return False + + return True + + class BindingConstraintEditionModel(BaseModel, metaclass=AllOptionalMetaclass): group: str enabled: bool @@ -229,26 +326,27 @@ def constraints_to_coeffs( return coeffs def get_binding_constraint( - self, study: Study, constraint_id: Optional[str] + self, + study: Study, + bc_filter: BindingConstraintFilter = BindingConstraintFilter(), ) -> Union[BindingConstraintConfigType, List[BindingConstraintConfigType], None]: storage_service = self.storage_service.get_storage(study) file_study = storage_service.get_raw(study) config = file_study.tree.get(["input", "bindingconstraints", "bindingconstraints"]) - config_values = list(config.values()) - study_version = int(study.version) - if constraint_id: - try: - index = [value["id"] for value in config_values].index(constraint_id) - config_value = config_values[index] - return BindingConstraintManager.process_constraint(config_value, study_version) - except ValueError: - return None - binding_constraint = [] - for config_value in config_values: - new_config = BindingConstraintManager.process_constraint(config_value, study_version) - binding_constraint.append(new_config) - return binding_constraint + bc_by_ids: Dict[str, BindingConstraintConfigType] = {} + for value in config.values(): + new_config = BindingConstraintManager.process_constraint(value, int(study.version)) + bc_by_ids[new_config.id] = new_config + + result = {bc_id: bc for bc_id, bc in bc_by_ids.items() if bc_filter.accept(bc)} + + # If a specific bc_id is provided, we return a single element + if bc_filter.bc_id: + return result.get(bc_filter.bc_id) + + # Else we return all the matching elements + return list(result.values()) def validate_binding_constraint(self, study: Study, constraint_id: str) -> None: if int(study.version) < 870: @@ -276,7 +374,7 @@ def create_binding_constraint( if not bc_id: raise InvalidConstraintName(f"Invalid binding constraint name: {data.name}.") - if bc_id in {bc.id for bc in self.get_binding_constraint(study, None)}: # type: ignore + if bc_id in {bc.id for bc in self.get_binding_constraint(study)}: # type: ignore raise DuplicateConstraintName(f"A binding constraint with the same name already exists: {bc_id}.") check_attributes_coherence(data, version) @@ -329,7 +427,7 @@ def update_binding_constraint( data: BindingConstraintEdition, ) -> BindingConstraintConfigType: file_study = self.storage_service.get_storage(study).get_raw(study) - constraint = self.get_binding_constraint(study, binding_constraint_id) + constraint = self.get_binding_constraint(study, BindingConstraintFilter(bc_id=binding_constraint_id)) study_version = int(study.version) if not isinstance(constraint, BindingConstraintConfig) and not isinstance( constraint, BindingConstraintConfig870 @@ -390,7 +488,9 @@ def remove_binding_constraint(self, study: Study, binding_constraint_id: str) -> file_study = self.storage_service.get_storage(study).get_raw(study) # Needed when the study is a variant because we only append the command to the list - if isinstance(study, VariantStudy) and not self.get_binding_constraint(study, binding_constraint_id): + if isinstance(study, VariantStudy) and not self.get_binding_constraint( + study, BindingConstraintFilter(bc_id=binding_constraint_id) + ): raise CommandApplicationError("Binding constraint not found") execute_or_add_commands(study, file_study, [command], self.storage_service) @@ -402,7 +502,7 @@ def update_constraint_term( term: Union[ConstraintTermDTO, str], ) -> None: file_study = self.storage_service.get_storage(study).get_raw(study) - constraint = self.get_binding_constraint(study, binding_constraint_id) + constraint = self.get_binding_constraint(study, BindingConstraintFilter(bc_id=binding_constraint_id)) if not isinstance(constraint, BindingConstraintConfig) and not isinstance(constraint, BindingConstraintConfig): raise BindingConstraintNotFoundError(study.id) @@ -454,7 +554,7 @@ def add_new_constraint_term( constraint_term: ConstraintTermDTO, ) -> None: file_study = self.storage_service.get_storage(study).get_raw(study) - constraint = self.get_binding_constraint(study, binding_constraint_id) + constraint = self.get_binding_constraint(study, BindingConstraintFilter(bc_id=binding_constraint_id)) if not isinstance(constraint, BindingConstraintConfig) and not isinstance(constraint, BindingConstraintConfig): raise BindingConstraintNotFoundError(study.id) diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index c3eb2a98fc..a47ffa2c40 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -3,8 +3,7 @@ from http import HTTPStatus from typing import Any, Dict, List, Optional, Sequence, Union, cast -from fastapi import APIRouter, Body, Depends -from fastapi.params import Query +from fastapi import APIRouter, Body, Depends, Query from starlette.responses import RedirectResponse from antarest.core.config import Config @@ -45,6 +44,7 @@ BindingConstraintConfigType, BindingConstraintCreation, BindingConstraintEdition, + BindingConstraintFilter, ConstraintTermDTO, ) from antarest.study.business.correlation_management import CorrelationFormFields, CorrelationManager, CorrelationMatrix @@ -53,11 +53,16 @@ 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.table_mode_management import ColumnsModelTypes, TableTemplateType +from antarest.study.business.table_mode_management import ( + BindingConstraintOperator, + ColumnsModelTypes, + TableTemplateType, +) from antarest.study.business.thematic_trimming_management import ThematicTrimmingFormFields from antarest.study.business.timeseries_config_management import TSFormFields from antarest.study.model import PatchArea, PatchCluster from antarest.study.service import StudyService +from antarest.study.storage.rawstudy.model.filesystem.config.binding_constraint import BindingConstraintFrequency from antarest.study.storage.rawstudy.model.filesystem.config.model import transform_name_to_id logger = logging.getLogger(__name__) @@ -889,6 +894,35 @@ def update_version( ) def get_binding_constraint_list( uuid: str, + enabled: Optional[bool] = Query(None, description="Filter results based on enabled status"), + operator: Optional[BindingConstraintOperator] = Query(None, description="Filter results based on operator"), + comments: str = Query("", description="Filter results based on comments (word match)"), + group: str = Query("", description="filter binding constraints based on group name (exact match)"), + time_step: Optional[BindingConstraintFrequency] = Query( + None, + description="Filter results based on time step", + alias="timeStep", + ), + area_name: str = Query( + "", + description="Filter results based on area name (word match)", + alias="areaName", + ), + cluster_name: str = Query( + "", + description="Filter results based on cluster name (word match)", + alias="clusterName", + ), + link_id: str = Query( + "", + description="Filter results based on link ID ('area1%area2')", + alias="linkId", + ), + cluster_id: str = Query( + "", + description="Filter results based on cluster ID ('area.cluster')", + alias="clusterId", + ), current_user: JWTUser = Depends(auth.get_current_user), ) -> Any: logger.info( @@ -897,7 +931,18 @@ def get_binding_constraint_list( ) params = RequestParameters(user=current_user) study = study_service.check_study_access(uuid, StudyPermissionType.READ, params) - return study_service.binding_constraint_manager.get_binding_constraint(study, None) + bc_filter = BindingConstraintFilter( + enabled=enabled, + operator=operator, + comments=comments, + group=group, + time_step=time_step, + area_name=area_name, + cluster_name=cluster_name, + link_id=link_id, + cluster_id=cluster_id, + ) + return study_service.binding_constraint_manager.get_binding_constraint(study, bc_filter) @bp.get( "/studies/{uuid}/bindingconstraints/{binding_constraint_id}", @@ -916,7 +961,8 @@ def get_binding_constraint( ) params = RequestParameters(user=current_user) study = study_service.check_study_access(uuid, StudyPermissionType.READ, params) - return study_service.binding_constraint_manager.get_binding_constraint(study, binding_constraint_id) + bc_filter = BindingConstraintFilter(bc_id=binding_constraint_id) + return study_service.binding_constraint_manager.get_binding_constraint(study, bc_filter) @bp.put( "/studies/{uuid}/bindingconstraints/{binding_constraint_id}", @@ -968,7 +1014,9 @@ def validate_binding_constraint( @bp.post("/studies/{uuid}/bindingconstraints", tags=[APITag.study_data], summary="Create a binding constraint") def create_binding_constraint( - uuid: str, data: BindingConstraintCreation, current_user: JWTUser = Depends(auth.get_current_user) + uuid: str, + data: BindingConstraintCreation, + current_user: JWTUser = Depends(auth.get_current_user), ) -> BindingConstraintConfigType: logger.info( f"Creating a new binding constraint for study {uuid}", @@ -1171,7 +1219,7 @@ def get_correlation_matrix( "value": "north,east", }, }, - ), # type: ignore + ), current_user: JWTUser = Depends(auth.get_current_user), ) -> CorrelationMatrix: """ @@ -2088,7 +2136,7 @@ def duplicate_cluster( area_id: str, cluster_type: ClusterType, source_cluster_id: str, - new_cluster_name: str = Query(..., alias="newName", title="New Cluster Name"), # type: ignore + new_cluster_name: str = Query(..., alias="newName", title="New Cluster Name"), current_user: JWTUser = Depends(auth.get_current_user), ) -> Union[STStorageOutput, ThermalClusterOutput, RenewableClusterOutput]: logger.info( From 1311d1575b5e28ba68b6162851cc10768d101370 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Thu, 21 Mar 2024 22:59:08 +0100 Subject: [PATCH 085/248] docs(bc): rephrase docstring in `validate_binding_constraint` --- antarest/study/web/study_data_blueprint.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index a47ffa2c40..cbd70f58b2 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -1001,8 +1001,10 @@ def validate_binding_constraint( - `uuid`: The study UUID. - `binding_constraint_id`: The binding constraint id to validate - For studies version prior to v8.7, does nothing. - Else, it checks the coherence between the group and the size of its columns. + For studies with versions prior to v8.7, no validation is performed. + For studies with version 8.7 or later, the endpoint checks if the dimensions + of the right-hand side matrices are consistent with the dimensions of the + binding constraint matrices within the same group. """ logger.info( f"Validating binding constraint {binding_constraint_id} for study {uuid}", From 212bdfb7cf1e818697724d05187eae40ef25d332 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 22 Mar 2024 10:00:45 +0100 Subject: [PATCH 086/248] fix(bc): remove group validation from BC creation --- .../business/binding_constraint_management.py | 68 ++++-- .../test_binding_constraints.py | 196 +++++++++--------- 2 files changed, 153 insertions(+), 111 deletions(-) diff --git a/antarest/study/business/binding_constraint_management.py b/antarest/study/business/binding_constraint_management.py index 0434c0e738..241d0cf98a 100644 --- a/antarest/study/business/binding_constraint_management.py +++ b/antarest/study/business/binding_constraint_management.py @@ -1,6 +1,7 @@ from typing import Any, Dict, List, Optional, Union -from pydantic import BaseModel, validator +import numpy as np +from pydantic import BaseModel, validator, root_validator from antarest.core.exceptions import ( BindingConstraintNotFoundError, @@ -228,6 +229,57 @@ class BindingConstraintCreation(BindingConstraintMatrices, BindingConstraintProp name: str coeffs: Dict[str, List[float]] + # Ajout d'un root validator pour valider les dimensions des matrices + @root_validator(pre=True) + def check_matrices_dimensions(cls, values: Dict[str, Any]) -> Dict[str, Any]: + # The dimensions of the matrices depend on the frequency and the version of the study. + if values.get("time_step") is None: + return values + _time_step = BindingConstraintFrequency(values["time_step"]) + + # Matrix shapes for binding constraints are different from usual shapes, + # because we need to take leap years into account, which contains 366 days and 8784 hours. + # Also, we use the same matrices for "weekly" and "daily" frequencies, + # because the solver calculates the weekly matrix from the daily matrix. + # See https://github.com/AntaresSimulatorTeam/AntaREST/issues/1843 + expected_rows = { + BindingConstraintFrequency.HOURLY: 8784, + BindingConstraintFrequency.DAILY: 366, + BindingConstraintFrequency.WEEKLY: 366, + }[_time_step] + + # Collect the matrix shapes + matrix_shapes = {} + for _field_name in ["values", "less_term_matrix", "equal_term_matrix", "greater_term_matrix"]: + if _matrix := values.get(_field_name): + _array = np.array(_matrix) + # We only store the shape if the array is not empty + if _array.size != 0: + matrix_shapes[_field_name] = _array.shape + + # We don't know the exact version of the study here, but we can rely on the matrix field names. + if not matrix_shapes: + return values + elif "values" in matrix_shapes: + expected_cols = 3 + else: + # pick the first matrix column as the expected column + expected_cols = next(iter(matrix_shapes.values()))[1] + + if all(shape == (expected_rows, expected_cols) for shape in matrix_shapes.values()): + return values + + # Prepare a clear error message + _field_names = ", ".join(f"'{n}'" for n in matrix_shapes) + if len(matrix_shapes) == 1: + err_msg = f"Matrix {_field_names} must have the shape ({expected_rows}, {expected_cols})" + else: + _shapes = list({(expected_rows, s[1]) for s in matrix_shapes.values()}) + _shapes_msg = ", ".join(f"{s}" for s in _shapes[:-1]) + " or " + f"{_shapes[-1]}" + err_msg = f"Matrices {_field_names} must have the same shape: {_shapes_msg}" + + raise ValueError(err_msg) + class BindingConstraintConfig(BindingConstraintProperties): id: str @@ -379,16 +431,6 @@ def create_binding_constraint( check_attributes_coherence(data, version) - file_study = self.storage_service.get_storage(study).get_raw(study) - if version >= 870: - data.group = data.group or "default" - matrix_terms_list = { - "eq": data.equal_term_matrix, - "lt": data.less_term_matrix, - "gt": data.greater_term_matrix, - } - check_matrices_coherence(file_study, data.group, bc_id, matrix_terms_list) - args = { "name": data.name, "enabled": data.enabled, @@ -404,7 +446,7 @@ def create_binding_constraint( "comments": data.comments or "", } if version >= 870: - args["group"] = data.group + args["group"] = data.group or "default" command = CreateBindingConstraint( **args, command_context=self.storage_service.variant_study_service.command_factory.command_context @@ -413,6 +455,8 @@ def create_binding_constraint( # Validates the matrices. Needed when the study is a variant because we only append the command to the list if isinstance(study, VariantStudy): command.validates_and_fills_matrices(specific_matrices=None, version=version, create=True) + + file_study = self.storage_service.get_storage(study).get_raw(study) execute_or_add_commands(study, file_study, [command], self.storage_service) # Processes the constraints to add them inside the endpoint response. diff --git a/tests/integration/study_data_blueprint/test_binding_constraints.py b/tests/integration/study_data_blueprint/test_binding_constraints.py index d9899e6b85..f1c9b80ad1 100644 --- a/tests/integration/study_data_blueprint/test_binding_constraints.py +++ b/tests/integration/study_data_blueprint/test_binding_constraints.py @@ -547,11 +547,12 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st json=wrong_request_args, headers=user_headers, ) - assert res.status_code == 500 + assert res.status_code == 422, res.json() exception = res.json()["exception"] description = res.json()["description"] - assert exception == "ValueError" if study_type == "variant" else "CommandApplicationError" - assert f"Invalid matrix shape {wrong_matrix.shape}, expected (366, 3)" in description + assert exception == "RequestValidationError" + assert "'values'" in description + assert "(366, 3)" in description # Delete a fake binding constraint res = client.delete(f"/v1/studies/{study_id}/bindingconstraints/fake_bc", headers=user_headers) @@ -636,11 +637,10 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud # Creation of bc with a matrix bc_id_w_matrix = "binding_constraint_3" - matrix_lt = np.ones((8784, 3)) - matrix_lt_to_list = matrix_lt.tolist() + 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_lt_to_list, **args}, + json={"name": bc_id_w_matrix, "less_term_matrix": matrix_lt3.tolist(), **args}, headers=admin_headers, ) assert res.status_code in {200, 201}, res.json() @@ -663,9 +663,9 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud assert res.status_code == 200 data = res.json()["data"] if term == "lt": - assert data == matrix_lt_to_list + assert data == matrix_lt3.tolist() else: - assert data == np.zeros((matrix_lt.shape[0], 1)).tolist() + assert data == np.zeros((matrix_lt3.shape[0], 1)).tolist() # ============================= # UPDATE @@ -684,7 +684,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud # Update matrix_term res = client.put( f"/v1/studies/{study_id}/bindingconstraints/{bc_id_w_matrix}", - json={"greater_term_matrix": matrix_lt_to_list}, + json={"greater_term_matrix": matrix_lt3.tolist()}, headers=admin_headers, ) assert res.status_code == 200, res.json() @@ -695,7 +695,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud headers=admin_headers, ) assert res.status_code == 200 - assert res.json()["data"] == matrix_lt_to_list + assert res.json()["data"] == matrix_lt3.tolist() # The user changed the time_step to daily instead of hourly. # We must check that the matrices have been updated. @@ -707,11 +707,12 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud assert res.status_code == 200, res.json() if study_type == "variant": - # Check the last command is a change time_step + # Check the last command is a change on `time_step` field only res = client.get(f"/v1/studies/{study_id}/commands", headers=admin_headers) commands = res.json() command_args = commands[-1]["args"] assert command_args["time_step"] == "daily" + assert "values" not in command_args assert ( command_args["less_term_matrix"] == command_args["greater_term_matrix"] @@ -720,7 +721,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud ) # Check that the matrices are daily/weekly matrices - expected_matrix = np.zeros((366, 1)).tolist() + expected_matrix = np.zeros((366, 1)) for term_alias in ["lt", "gt", "eq"]: res = client.get( f"/v1/studies/{study_id}/raw", @@ -732,7 +733,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud headers=admin_headers, ) assert res.status_code == 200 - assert res.json()["data"] == expected_matrix + assert res.json()["data"] == expected_matrix.tolist() # ============================= # DELETE @@ -779,149 +780,146 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud # Creation with 2 matrices with different columns size bc_id_with_wrong_matrix = "binding_constraint_with_wrong_matrix" - matrix_lt = np.ones((8784, 3)) - matrix_gt = np.ones((8784, 2)) - matrix_gt_to_list = matrix_gt.tolist() - matrix_lt_to_list = matrix_lt.tolist() + matrix_lt3 = np.ones((8784, 3)) + matrix_gt2 = np.ones((8784, 2)) # Wrong number of columns res = client.post( f"/v1/studies/{study_id}/bindingconstraints", json={ "name": bc_id_with_wrong_matrix, - "less_term_matrix": matrix_lt_to_list, - "greater_term_matrix": matrix_gt_to_list, + "less_term_matrix": matrix_lt3.tolist(), + "greater_term_matrix": matrix_gt2.tolist(), **args, }, headers=admin_headers, ) - assert res.status_code == 422 - assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" - assert ( - res.json()["description"] - == "The matrices of binding_constraint_with_wrong_matrix must have the same number of columns, currently {2, 3}" - ) + assert res.status_code == 422, res.json() + exception = res.json()["exception"] + description = res.json()["description"] + assert exception == "RequestValidationError" + assert "'less_term_matrix'" in description + assert "'greater_term_matrix'" in description + assert "(8784, 3)" in description + assert "(8784, 2)" in description # - # Creation of 2 bc inside the same group with different columns size - # first_bc: 3 cols, group1 - # second_bc: 4 cols, group1 -> Should fail + # Creation of 2 BC inside the same group with different columns size + # "First BC": 3 cols, "Group 1" -> OK + # "Second BC": 4 cols, "Group 1" -> OK, but should fail in group validation # - first_bc = "binding_constraint_validation" - matrix_lt = np.ones((8784, 3)) - matrix_lt_to_list = matrix_lt.tolist() + matrix_lt3 = np.ones((8784, 3)) res = client.post( f"/v1/studies/{study_id}/bindingconstraints", - json={"name": first_bc, "less_term_matrix": matrix_lt_to_list, "group": "group1", **args}, + json={ + "name": "First BC", + "less_term_matrix": matrix_lt3.tolist(), + "group": "Group 1", + **args, + }, headers=admin_headers, ) assert res.status_code in {200, 201}, res.json() - matrix_gt = np.ones((8784, 4)) - matrix_gt_to_list = matrix_gt.tolist() + matrix_gt4 = np.ones((8784, 4)) # Wrong number of columns res = client.post( f"/v1/studies/{study_id}/bindingconstraints", - json={"name": "other_bc", "greater_term_matrix": matrix_gt_to_list, "group": "group1", **args}, + json={ + "name": "Second BC", + "greater_term_matrix": matrix_gt4.tolist(), + "group": "group 1", # Same group, but different case + **args, + }, headers=admin_headers, ) - assert res.status_code == 422 - assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" - assert res.json()["description"] == "The matrices of the group group1 do not have the same number of columns" + assert res.status_code in {200, 201}, res.json() + second_bc_id = res.json()["id"] + + # todo: validate the BC group "Group 1" + # res = client.get(f"/v1/studies/{study_id}/bindingconstraints/Group 1/validate", headers=admin_headers) + # assert res.status_code == 422 + # assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" + # assert res.json()["description"] == "Mismatched column count in 'Group 1'". + + # So, we correct the shape of the matrix of the Second BC + res = client.put( + f"/v1/studies/{study_id}/bindingconstraints/{second_bc_id}", + json={"greater_term_matrix": matrix_lt3.tolist()}, + headers=admin_headers, + ) + assert res.status_code in {200, 201}, res.json() # # Updating the group of a bc creates different columns size inside the same group - # first_bc: 3 cols, group 1 - # second_bc: 4 cols, group2 -> OK - # second_bc group changes to group1 -> Fails validation + # first_bc: 3 cols, "Group 1" -> OK + # third_bd: 4 cols, "Group 2" -> OK + # third_bd group changes to group1 -> Fails validation # - second_bc = "binding_constraint_validation_2" - matrix_lt = np.ones((8784, 4)) - matrix_lt_to_list = matrix_lt.tolist() + matrix_lt4 = np.ones((8784, 4)) res = client.post( f"/v1/studies/{study_id}/bindingconstraints", - json={"name": second_bc, "less_term_matrix": matrix_lt_to_list, "group": "group2", **args}, + json={ + "name": "Third BC", + "less_term_matrix": matrix_lt4.tolist(), + "group": "Group 2", + **args, + }, headers=admin_headers, ) assert res.status_code in {200, 201}, res.json() + third_bd_id = res.json()["id"] res = client.put( - f"v1/studies/{study_id}/bindingconstraints/{second_bc}", - json={"group": "group1"}, + f"v1/studies/{study_id}/bindingconstraints/{third_bd_id}", + json={"group": "Group 1"}, headers=admin_headers, ) # This should succeed but cause the validation endpoint to fail. assert res.status_code in {200, 201}, res.json() - res = client.get(f"/v1/studies/{study_id}/bindingconstraints/{second_bc}/validate", headers=admin_headers) - assert res.status_code == 422 - assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" - assert res.json()["description"] == "The matrices of the group group1 do not have the same number of columns" - - # - # Update causes different matrices size inside the same bc - # second_bc: 1st matrix has 4 cols and others 1 -> OK - # second_bc: 1st matrix has 4 cols and 2nd matrix has 3 cols -> Fails validation - # + # todo: validate the BC group "Group 1" + # res = client.get(f"/v1/studies/{study_id}/bindingconstraints/Group 1/validate", headers=admin_headers) + # assert res.status_code == 422 + # assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" + # assert res.json()["description"] == "Mismatched column count in 'Group 1'". + # So, we correct the shape of the matrix of the Second BC res = client.put( - f"v1/studies/{study_id}/bindingconstraints/{second_bc}", json={"group": "group2"}, headers=admin_headers - ) - assert res.status_code in {200, 201}, res.json() - # For the moment the bc is valid - res = client.get(f"/v1/studies/{study_id}/bindingconstraints/{second_bc}/validate", headers=admin_headers) - assert res.status_code in {200, 201}, res.json() - - matrix_lt_3 = np.ones((8784, 3)) - matrix_lt_3_to_list = matrix_lt_3.tolist() - res = client.put( - f"v1/studies/{study_id}/bindingconstraints/{second_bc}", - json={"greater_term_matrix": matrix_lt_3_to_list}, + f"/v1/studies/{study_id}/bindingconstraints/{third_bd_id}", + json={"greater_term_matrix": matrix_lt3.tolist()}, headers=admin_headers, ) - # This should succeed but cause the validation endpoint to fail. assert res.status_code in {200, 201}, res.json() - res = client.get(f"/v1/studies/{study_id}/bindingconstraints/{second_bc}/validate", headers=admin_headers) - assert res.status_code == 422 - assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" - assert ( - res.json()["description"] - == "The matrices of binding_constraint_validation_2 must have the same number of columns, currently {3, 4}" - ) - # - # Updating a matrix causes different matrices size inside the same group - # first_bc: 3 cols, group1 - # second_bc: 3 cols, group1 -> OK - # second_bc: update 2 matrices with 4 cols, group1 -> Fails validation + # Update causes different matrices size inside the same bc + # second_bc: 1st matrix has 4 cols and others 1 -> OK + # second_bc: 1st matrix has 4 cols and 2nd matrix has 3 cols -> Fails validation # res = client.put( - f"v1/studies/{study_id}/bindingconstraints/{second_bc}", - json={"less_term_matrix": matrix_lt_3_to_list}, + f"v1/studies/{study_id}/bindingconstraints/{second_bc_id}", + json={"group": "Group 2"}, headers=admin_headers, ) assert res.status_code in {200, 201}, res.json() - # For the moment the bc is valid - res = client.get(f"/v1/studies/{study_id}/bindingconstraints/{second_bc}/validate", headers=admin_headers) - assert res.status_code in {200, 201}, res.json() + # todo: validate the "Group 2" + # # For the moment the bc is valid + # res = client.get(f"/v1/studies/{study_id}/bindingconstraints/Group 2/validate", headers=admin_headers) + # assert res.status_code in {200, 201}, res.json() res = client.put( - f"v1/studies/{study_id}/bindingconstraints/{second_bc}", - json={"group": "group1"}, - headers=admin_headers, - ) - assert res.status_code in {200, 201}, res.json() - res = client.put( - f"v1/studies/{study_id}/bindingconstraints/{second_bc}", - json={"less_term_matrix": matrix_lt_to_list, "greater_term_matrix": matrix_lt_to_list}, + f"v1/studies/{study_id}/bindingconstraints/{second_bc_id}", + json={"greater_term_matrix": matrix_lt3.tolist()}, headers=admin_headers, ) # This should succeed but cause the validation endpoint to fail. assert res.status_code in {200, 201}, res.json() - res = client.get(f"/v1/studies/{study_id}/bindingconstraints/{second_bc}/validate", headers=admin_headers) - assert res.status_code == 422 - assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" - assert res.json()["description"] == "The matrices of the group group1 do not have the same number of columns" + # For the moment the "Group 2" is valid + # todo: validate the "Group 2" + # res = client.get(f"/v1/studies/{study_id}/bindingconstraints/Group 2/validate", headers=admin_headers) + # assert res.status_code == 422 + # assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" + # assert res.json()["description"] == "Mismatched column count in 'Group 2'". From 0694f2b34d5af0c16390bd4328408b46d0fb0dd4 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 22 Mar 2024 16:06:16 +0100 Subject: [PATCH 087/248] feat(api-bc): add endpoints to manage and validate BC groups --- antarest/core/exceptions.py | 6 +- .../business/binding_constraint_management.py | 158 ++++++++++++------ antarest/study/web/study_data_blueprint.py | 138 ++++++++++++--- .../test_binding_constraints.py | 79 ++++++--- 4 files changed, 281 insertions(+), 100 deletions(-) diff --git a/antarest/core/exceptions.py b/antarest/core/exceptions.py index 079263ae91..9094d322be 100644 --- a/antarest/core/exceptions.py +++ b/antarest/core/exceptions.py @@ -1,6 +1,6 @@ import re from http import HTTPStatus -from typing import Optional +from typing import Any, Optional from fastapi.exceptions import HTTPException @@ -386,8 +386,8 @@ def __init__(self, message: str) -> None: class IncoherenceBetweenMatricesLength(HTTPException): - def __init__(self, message: str) -> None: - super().__init__(HTTPStatus.UNPROCESSABLE_ENTITY, message) + def __init__(self, detail: Any) -> None: + super().__init__(HTTPStatus.UNPROCESSABLE_ENTITY, detail) class MissingDataError(HTTPException): diff --git a/antarest/study/business/binding_constraint_management.py b/antarest/study/business/binding_constraint_management.py index 241d0cf98a..e29d4cad5e 100644 --- a/antarest/study/business/binding_constraint_management.py +++ b/antarest/study/business/binding_constraint_management.py @@ -1,7 +1,11 @@ -from typing import Any, Dict, List, Optional, Union +import collections +import itertools +import logging +from typing import Any, Dict, List, Mapping, Optional, Sequence, Union import numpy as np -from pydantic import BaseModel, validator, root_validator +from pydantic import BaseModel, root_validator, validator +from requests.utils import CaseInsensitiveDict from antarest.core.exceptions import ( BindingConstraintNotFoundError, @@ -44,6 +48,11 @@ from antarest.study.storage.variantstudy.model.command.update_binding_constraint import UpdateBindingConstraint from antarest.study.storage.variantstudy.model.dbmodel import VariantStudy +logger = logging.getLogger(__name__) + +DEFAULT_GROUP = "default" +"""Default group name for binding constraints if missing or empty.""" + class AreaLinkDTO(BaseModel): """ @@ -162,7 +171,7 @@ def accept(self, constraint: "BindingConstraintConfigType") -> bool: if self.comments.upper() not in comments.upper(): return False if self.group: - group = getattr(constraint, "group") or "" + group = getattr(constraint, "group", DEFAULT_GROUP) if self.group.upper() != group.upper(): return False if self.time_step is not None and self.time_step != constraint.time_step: @@ -294,6 +303,42 @@ class BindingConstraintConfig870(BindingConstraintConfig): BindingConstraintConfigType = Union[BindingConstraintConfig870, BindingConstraintConfig] +def _validate_binding_constraints(file_study: FileStudy, bcs: Sequence[BindingConstraintConfigType]) -> bool: + if int(file_study.config.version) < 870: + matrix_id_fmts = {"{bc_id}"} + else: + matrix_id_fmts = {"{bc_id}_eq", "{bc_id}_lt", "{bc_id}_gt"} + + references_by_shapes = collections.defaultdict(list) + _total = len(bcs) * len(matrix_id_fmts) + for _index, (bc, fmt) in enumerate(itertools.product(bcs, matrix_id_fmts), 1): + matrix_id = fmt.format(bc_id=bc.id) + logger.info(f"⏲ Validating BC '{bc.id}': {matrix_id=} [{_index}/{_total}]") + _obj = file_study.tree.get(url=["input", "bindingconstraints", matrix_id]) + _array = np.array(_obj["data"], dtype=float) + if _array.size == 0 or _array.shape[1] == 1: + continue + references_by_shapes[_array.shape].append((bc.id, matrix_id)) + del _obj + del _array + + if len(references_by_shapes) > 1: + most_common = collections.Counter(references_by_shapes.keys()).most_common() + invalid_constraints = collections.defaultdict(list) + for shape, _ in most_common[1:]: + references = references_by_shapes[shape] + for bc_id, matrix_id in references: + invalid_constraints[bc_id].append(f"'{matrix_id}' {shape}") + expected_shape = most_common[0][0] + detail = { + "msg": f"Matrix shapes mismatch in binding constraints group. Expected shape: {expected_shape}", + "invalid_constraints": dict(invalid_constraints), + } + raise IncoherenceBetweenMatricesLength(detail) + + return True + + class BindingConstraintManager: def __init__( self, @@ -400,20 +445,64 @@ def get_binding_constraint( # Else we return all the matching elements return list(result.values()) - def validate_binding_constraint(self, study: Study, constraint_id: str) -> None: - if int(study.version) < 870: - return # There's nothing to check for constraints before v8.7 - file_study = self.storage_service.get_storage(study).get_raw(study) + def get_binding_constraint_groups(self, study: Study) -> Mapping[str, Sequence[BindingConstraintConfigType]]: + """ + Get all binding constraints grouped by group name. + + Args: + study: the study + + Returns: + A dictionary with group names as keys and lists of binding constraints as values. + """ + storage_service = self.storage_service.get_storage(study) + file_study = storage_service.get_raw(study) config = file_study.tree.get(["input", "bindingconstraints", "bindingconstraints"]) - group = next((value["group"] for value in config.values() if value["id"] == constraint_id), None) - if not group: - raise BindingConstraintNotFoundError(study.id) - matrix_terms = { - "eq": get_matrix_data(file_study, constraint_id, "eq"), - "lt": get_matrix_data(file_study, constraint_id, "lt"), - "gt": get_matrix_data(file_study, constraint_id, "gt"), - } - check_matrices_coherence(file_study, group, constraint_id, matrix_terms) + bcs_by_group = CaseInsensitiveDict() # type: ignore + for value in config.values(): + _bc_config = BindingConstraintManager.process_constraint(value, int(study.version)) + _group = getattr(_bc_config, "group", DEFAULT_GROUP) + bcs_by_group.setdefault(_group, []).append(_bc_config) + return bcs_by_group + + def get_binding_constraint_group(self, study: Study, group_name: str) -> Sequence[BindingConstraintConfigType]: + """ + Get all binding constraints from a given group. + + Args: + study: the study. + group_name: the group name (case-insensitive). + + Returns: + A list of binding constraints from the group. + """ + groups = self.get_binding_constraint_groups(study) + if group_name not in groups: + raise BindingConstraintNotFoundError(f"Group '{group_name}' not found") + return groups[group_name] + + def validate_binding_constraint_group(self, study: Study, group_name: str) -> bool: + storage_service = self.storage_service.get_storage(study) + file_study = storage_service.get_raw(study) + bcs_by_group = self.get_binding_constraint_groups(study) + if group_name not in bcs_by_group: + raise BindingConstraintNotFoundError(f"Group '{group_name}' not found") + bcs = bcs_by_group[group_name] + return _validate_binding_constraints(file_study, bcs) + + def validate_binding_constraint_groups(self, study: Study) -> bool: + storage_service = self.storage_service.get_storage(study) + file_study = storage_service.get_raw(study) + bcs_by_group = self.get_binding_constraint_groups(study) + invalid_groups = {} + for group_name, bcs in bcs_by_group.items(): + try: + _validate_binding_constraints(file_study, bcs) + except IncoherenceBetweenMatricesLength as e: + invalid_groups[group_name] = e.detail + if invalid_groups: + raise IncoherenceBetweenMatricesLength(invalid_groups) + return True def create_binding_constraint( self, @@ -446,7 +535,7 @@ def create_binding_constraint( "comments": data.comments or "", } if version >= 870: - args["group"] = data.group or "default" + args["group"] = data.group or DEFAULT_GROUP command = CreateBindingConstraint( **args, command_context=self.storage_service.variant_study_service.command_factory.command_context @@ -678,37 +767,6 @@ def find_constraint_term_id(constraints_term: List[ConstraintTermDTO], constrain return -1 -def get_binding_constraint_of_a_given_group(file_study: FileStudy, group_id: str) -> List[str]: - config = file_study.tree.get(["input", "bindingconstraints", "bindingconstraints"]) - config_values = list(config.values()) - return [bd["id"] for bd in config_values if bd["group"] == group_id] - - -def check_matrices_coherence( - file_study: FileStudy, group_id: str, binding_constraint_id: str, matrix_terms: Dict[str, Any] -) -> None: - given_number_of_cols = set() - for term_str, term_data in matrix_terms.items(): - if term_data: - nb_cols = len(term_data[0]) - if nb_cols > 1: - given_number_of_cols.add(nb_cols) - if len(given_number_of_cols) > 1: - raise IncoherenceBetweenMatricesLength( - f"The matrices of {binding_constraint_id} must have the same number of columns, currently {given_number_of_cols}" - ) - if len(given_number_of_cols) == 1: - given_size = list(given_number_of_cols)[0] - for bd_id in get_binding_constraint_of_a_given_group(file_study, group_id): - for term in list(matrix_terms.keys()): - matrix_file = file_study.tree.get(url=["input", "bindingconstraints", f"{bd_id}_{term}"]) - column_size = len(matrix_file["data"][0]) - if column_size > 1 and column_size != given_size: - raise IncoherenceBetweenMatricesLength( - f"The matrices of the group {group_id} do not have the same number of columns" - ) - - def check_attributes_coherence( data: Union[BindingConstraintCreation, BindingConstraintEdition], study_version: int ) -> None: @@ -721,7 +779,3 @@ def check_attributes_coherence( raise InvalidFieldForVersionError("You cannot fill a 'matrix_term' as these values refer to v8.7+ studies") elif data.values: raise InvalidFieldForVersionError("You cannot fill 'values' as it refers to the matrix before v8.7") - - -def get_matrix_data(file_study: FileStudy, binding_constraint_id: str, keyword: str) -> List[Any]: - return file_study.tree.get(url=["input", "bindingconstraints", f"{binding_constraint_id}_{keyword}"])["data"] # type: ignore diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index cbd70f58b2..2bcb56100f 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -1,7 +1,7 @@ import enum import logging from http import HTTPStatus -from typing import Any, Dict, List, Optional, Sequence, Union, cast +from typing import Any, Dict, List, Mapping, Optional, Sequence, Union, cast from fastapi import APIRouter, Body, Depends, Query from starlette.responses import RedirectResponse @@ -984,35 +984,129 @@ def update_binding_constraint( return study_service.binding_constraint_manager.update_binding_constraint(study, binding_constraint_id, data) @bp.get( - "/studies/{uuid}/bindingconstraints/{binding_constraint_id}/validate", + "/studies/{uuid}/constraint-groups", tags=[APITag.study_data], - summary="Validate binding constraint configuration", + summary="Get the list of binding constraint groups", + ) + def get_binding_constraint_groups( + uuid: str, + current_user: JWTUser = Depends(auth.get_current_user), + ) -> Mapping[str, Sequence[BindingConstraintConfigType]]: + """ + Get the list of binding constraint groups for the study. + + Args: + - `uuid`: The UUID of the study. + + Returns: + - The list of binding constraints for each group. + """ + logger.info( + f"Fetching binding constraint groups for study {uuid}", + extra={"user": current_user.id}, + ) + params = RequestParameters(user=current_user) + study = study_service.check_study_access(uuid, StudyPermissionType.READ, params) + result = study_service.binding_constraint_manager.get_binding_constraint_groups(study) + return result + + @bp.get( + # We use "validate-all" because it is unlikely to conflict with a group name. + "/studies/{uuid}/constraint-groups/validate-all", + tags=[APITag.study_data], + summary="Validate all binding constraint groups", response_model=None, ) - def validate_binding_constraint( + def validate_binding_constraint_groups( uuid: str, - binding_constraint_id: str, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> bool: """ - Validates the binding constraint configuration. + Checks if the dimensions of the right-hand side matrices are consistent with + the dimensions of the binding constraint matrices within the same group. - Parameters: + Args: - `uuid`: The study UUID. - - `binding_constraint_id`: The binding constraint id to validate - For studies with versions prior to v8.7, no validation is performed. - For studies with version 8.7 or later, the endpoint checks if the dimensions - of the right-hand side matrices are consistent with the dimensions of the - binding constraint matrices within the same group. + Returns: + - `true` if all groups are valid. + + Raises: + - HTTPException(422) if any group is invalid. """ logger.info( - f"Validating binding constraint {binding_constraint_id} for study {uuid}", + f"Validating all binding constraint groups for study {uuid}", extra={"user": current_user.id}, ) params = RequestParameters(user=current_user) study = study_service.check_study_access(uuid, StudyPermissionType.READ, params) - return study_service.binding_constraint_manager.validate_binding_constraint(study, binding_constraint_id) + return study_service.binding_constraint_manager.validate_binding_constraint_groups(study) + + @bp.get( + "/studies/{uuid}/constraint-groups/{group}", + tags=[APITag.study_data], + summary="Get the binding constraint group", + ) + def get_binding_constraint_group( + uuid: str, + group: str, + current_user: JWTUser = Depends(auth.get_current_user), + ) -> Sequence[BindingConstraintConfigType]: + """ + Get the binding constraint group for the study. + + Args: + - `uuid`: The UUID of the study. + - `group`: The name of the binding constraint group (case-insensitive). + + Returns: + - The list of binding constraints in the group. + + Raises: + - HTTPException(404) if the group does not exist. + """ + logger.info( + f"Fetching binding constraint group '{group}' for study {uuid}", + extra={"user": current_user.id}, + ) + params = RequestParameters(user=current_user) + study = study_service.check_study_access(uuid, StudyPermissionType.READ, params) + result = study_service.binding_constraint_manager.get_binding_constraint_group(study, group) + return result + + @bp.get( + "/studies/{uuid}/constraint-groups/{group}/validate", + tags=[APITag.study_data], + summary="Validate the binding constraint group", + response_model=None, + ) + def validate_binding_constraint_group( + uuid: str, + group: str, + current_user: JWTUser = Depends(auth.get_current_user), + ) -> bool: + """ + Checks if the dimensions of the right-hand side matrices are consistent with + the dimensions of the binding constraint matrices within the same group. + + Args: + - `uuid`: The study UUID. + - `group`: The name of the binding constraint group (case-insensitive). + + Returns: + - `true` if the group is valid. + + Raises: + - HTTPException(404) if the group does not exist. + - HTTPException(422) if the group is invalid. + """ + logger.info( + f"Validating binding constraint group '{group}' for study {uuid}", + extra={"user": current_user.id}, + ) + params = RequestParameters(user=current_user) + study = study_service.check_study_access(uuid, StudyPermissionType.READ, params) + return study_service.binding_constraint_manager.validate_binding_constraint_group(study, group) @bp.post("/studies/{uuid}/bindingconstraints", tags=[APITag.study_data], summary="Create a binding constraint") def create_binding_constraint( @@ -1115,7 +1209,7 @@ def get_allocation_matrix( """ Get the hydraulic allocation matrix for all areas. - Parameters: + Args: - `uuid`: The study UUID. Returns the data frame matrix, where: @@ -1145,7 +1239,7 @@ def get_allocation_form_fields( """ Get the form fields used for the allocation form. - Parameters: + Args: - `uuid`: The study UUID, - `area_id`: the area ID. @@ -1183,7 +1277,7 @@ def set_allocation_form_fields( """ Update the hydraulic allocation of a given area. - Parameters: + Args: - `uuid`: The study UUID, - `area_id`: the area ID. @@ -1227,7 +1321,7 @@ def get_correlation_matrix( """ Get the hydraulic/load/solar/wind correlation matrix of a study. - Parameters: + Args: - `uuid`: The UUID of the study. - `columns`: a filter on the area identifiers: - Use no parameter to select all areas. @@ -1279,7 +1373,7 @@ def set_correlation_matrix( """ Set the hydraulic/load/solar/wind correlation matrix of a study. - Parameters: + Args: - `uuid`: The UUID of the study. - `index`: a list of all study areas. - `columns`: a list of selected production areas. @@ -1310,7 +1404,7 @@ def get_correlation_form_fields( """ Get the form fields used for the correlation form. - Parameters: + Args: - `uuid`: The UUID of the study. - `area_id`: the area ID. @@ -1349,7 +1443,7 @@ def set_correlation_form_fields( """ Update the hydraulic/load/solar/wind correlation of a given area. - Parameters: + Args: - `uuid`: The UUID of the study. - `area_id`: the area ID. diff --git a/tests/integration/study_data_blueprint/test_binding_constraints.py b/tests/integration/study_data_blueprint/test_binding_constraints.py index f1c9b80ad1..5950a8b121 100644 --- a/tests/integration/study_data_blueprint/test_binding_constraints.py +++ b/tests/integration/study_data_blueprint/test_binding_constraints.py @@ -258,8 +258,8 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st bc_id = binding_constraints_list[0]["id"] - # Asserts binding constraint configuration is always valid. - res = client.get(f"/v1/studies/{study_id}/bindingconstraints/{bc_id}/validate", headers=user_headers) + # Asserts binding constraint configuration is valid. + res = client.get(f"/v1/studies/{study_id}/constraint-groups", headers=user_headers) assert res.status_code == 200, res.json() # ============================= @@ -834,11 +834,15 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud assert res.status_code in {200, 201}, res.json() second_bc_id = res.json()["id"] - # todo: validate the BC group "Group 1" - # res = client.get(f"/v1/studies/{study_id}/bindingconstraints/Group 1/validate", headers=admin_headers) - # assert res.status_code == 422 - # assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" - # assert res.json()["description"] == "Mismatched column count in 'Group 1'". + # validate the BC group "Group 1" + res = client.get(f"/v1/studies/{study_id}/constraint-groups/Group 1/validate", headers=admin_headers) + assert res.status_code == 422 + assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" + description = res.json()["description"] + assert description == { + "invalid_constraints": {"second bc": ["'second bc_gt' (8784, 4)"]}, + "msg": "Matrix shapes mismatch in binding constraints group. Expected shape: (8784, 3)", + } # So, we correct the shape of the matrix of the Second BC res = client.put( @@ -877,11 +881,15 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud # This should succeed but cause the validation endpoint to fail. assert res.status_code in {200, 201}, res.json() - # todo: validate the BC group "Group 1" - # res = client.get(f"/v1/studies/{study_id}/bindingconstraints/Group 1/validate", headers=admin_headers) - # assert res.status_code == 422 - # assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" - # assert res.json()["description"] == "Mismatched column count in 'Group 1'". + # validate the BC group "Group 1" + res = client.get(f"/v1/studies/{study_id}/constraint-groups/Group 1/validate", headers=admin_headers) + assert res.status_code == 422 + assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" + description = res.json()["description"] + assert description == { + "invalid_constraints": {"third bc": ["'third bc_lt' (8784, 4)"]}, + "msg": "Matrix shapes mismatch in binding constraints group. Expected shape: (8784, 3)", + } # So, we correct the shape of the matrix of the Second BC res = client.put( @@ -904,22 +912,47 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud ) assert res.status_code in {200, 201}, res.json() - # todo: validate the "Group 2" - # # For the moment the bc is valid - # res = client.get(f"/v1/studies/{study_id}/bindingconstraints/Group 2/validate", headers=admin_headers) - # assert res.status_code in {200, 201}, res.json() + # validate the "Group 2": for the moment the BC is valid + res = client.get(f"/v1/studies/{study_id}/constraint-groups/Group 2/validate", headers=admin_headers) + assert res.status_code in {200, 201}, res.json() res = client.put( f"v1/studies/{study_id}/bindingconstraints/{second_bc_id}", - json={"greater_term_matrix": matrix_lt3.tolist()}, + json={"greater_term_matrix": matrix_gt4.tolist()}, headers=admin_headers, ) # This should succeed but cause the validation endpoint to fail. assert res.status_code in {200, 201}, res.json() - # For the moment the "Group 2" is valid - # todo: validate the "Group 2" - # res = client.get(f"/v1/studies/{study_id}/bindingconstraints/Group 2/validate", headers=admin_headers) - # assert res.status_code == 422 - # assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" - # assert res.json()["description"] == "Mismatched column count in 'Group 2'". + # Collect all the binding constraints groups + res = client.get(f"/v1/studies/{study_id}/constraint-groups", headers=admin_headers) + assert res.status_code in {200, 201}, res.json() + groups = res.json() + assert set(groups) == {"default", "random_grp", "Group 1", "Group 2"} + assert groups["Group 2"] == [ + { + "comments": "New API", + "constraints": None, + "enabled": True, + "filter_synthesis": "", + "filter_year_by_year": "", + "group": "Group 2", + "id": "second bc", + "name": "Second BC", + "operator": "less", + "time_step": "hourly", + } + ] + + # Validate all binding constraints groups + res = client.get(f"/v1/studies/{study_id}/constraint-groups/validate-all", headers=admin_headers) + assert res.status_code == 422, res.json() + exception = res.json()["exception"] + description = res.json()["description"] + assert exception == "IncoherenceBetweenMatricesLength" + assert description == { + "Group 1": { + "msg": "Matrix shapes mismatch in binding constraints group. Expected shape: (8784, 3)", + "invalid_constraints": {"third bc": ["'third bc_lt' (8784, 4)"]}, + } + } From b2424476c44a36fddf6217ef7686d92f6cb43a38 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Sun, 24 Mar 2024 19:06:54 +0100 Subject: [PATCH 088/248] feat(bc): used camelCase field names in binding constraints API --- .../business/binding_constraint_management.py | 70 +++++++++++-------- .../command/create_binding_constraint.py | 12 ++-- antarest/study/web/study_data_blueprint.py | 23 +++++- .../test_binding_constraints.py | 68 +++++++++--------- tests/integration/test_integration.py | 2 +- 5 files changed, 102 insertions(+), 73 deletions(-) diff --git a/antarest/study/business/binding_constraint_management.py b/antarest/study/business/binding_constraint_management.py index e29d4cad5e..265ee35828 100644 --- a/antarest/study/business/binding_constraint_management.py +++ b/antarest/study/business/binding_constraint_management.py @@ -1,10 +1,10 @@ import collections import itertools import logging -from typing import Any, Dict, List, Mapping, Optional, Sequence, Union +from typing import Any, Dict, List, Mapping, MutableSequence, Optional, Sequence, Union import numpy as np -from pydantic import BaseModel, root_validator, validator +from pydantic import BaseModel, Field, root_validator, validator from requests.utils import CaseInsensitiveDict from antarest.core.exceptions import ( @@ -19,7 +19,8 @@ MissingDataError, NoConstraintError, ) -from antarest.study.business.utils import AllOptionalMetaclass, execute_or_add_commands +from antarest.core.utils.string import to_camel_case +from antarest.study.business.utils import AllOptionalMetaclass, camel_case_model, execute_or_add_commands from antarest.study.model import Study from antarest.study.storage.rawstudy.model.filesystem.config.binding_constraint import BindingConstraintFrequency from antarest.study.storage.rawstudy.model.filesystem.config.model import transform_name_to_id @@ -178,7 +179,7 @@ def accept(self, constraint: "BindingConstraintConfigType") -> bool: return False # Filter on terms - terms = constraint.constraints or [] + terms = constraint.terms or [] if self.area_name: all_areas = [] @@ -219,21 +220,17 @@ def accept(self, constraint: "BindingConstraintConfigType") -> bool: return True -class BindingConstraintEditionModel(BaseModel, metaclass=AllOptionalMetaclass): - group: str - enabled: bool - time_step: BindingConstraintFrequency - operator: BindingConstraintOperator - filter_year_by_year: str - filter_synthesis: str - comments: str +@camel_case_model +class BindingConstraintEditionModel(BindingConstraintProperties870, metaclass=AllOptionalMetaclass, use_none=True): coeffs: Dict[str, List[float]] +@camel_case_model class BindingConstraintEdition(BindingConstraintMatrices, BindingConstraintEditionModel): pass +@camel_case_model class BindingConstraintCreation(BindingConstraintMatrices, BindingConstraintProperties870): name: str coeffs: Dict[str, List[float]] @@ -241,6 +238,10 @@ class BindingConstraintCreation(BindingConstraintMatrices, BindingConstraintProp # Ajout d'un root validator pour valider les dimensions des matrices @root_validator(pre=True) def check_matrices_dimensions(cls, values: Dict[str, Any]) -> Dict[str, Any]: + for _key in ["time_step", "less_term_matrix", "equal_term_matrix", "greater_term_matrix"]: + _camel = to_camel_case(_key) + values[_key] = values.pop(_camel, values.get(_key)) + # The dimensions of the matrices depend on the frequency and the version of the study. if values.get("time_step") is None: return values @@ -290,10 +291,18 @@ def check_matrices_dimensions(cls, values: Dict[str, Any]) -> Dict[str, Any]: raise ValueError(err_msg) -class BindingConstraintConfig(BindingConstraintProperties): +@camel_case_model +class _BindingConstraintConfig(BindingConstraintProperties): id: str name: str - constraints: Optional[List[ConstraintTermDTO]] + + +class BindingConstraintConfig(_BindingConstraintConfig): + terms: MutableSequence[ConstraintTermDTO] = Field( + default_factory=lambda: [], + alias="constraints", # only for backport compatibility + title="Constraint terms", + ) class BindingConstraintConfig870(BindingConstraintConfig): @@ -361,9 +370,9 @@ def parse_constraint(key: str, value: str, char: str, new_config: BindingConstra if len(weight_and_offset) == 2: weight = float(weight_and_offset[0]) offset = float(weight_and_offset[1]) - if new_config.constraints is None: - new_config.constraints = [] - new_config.constraints.append( + if new_config.terms is None: + new_config.terms = [] + new_config.terms.append( ConstraintTermDTO( id=key, weight=weight, @@ -387,18 +396,17 @@ def process_constraint(constraint_value: Dict[str, Any], version: int) -> Bindin args = { "id": constraint_value["id"], "name": constraint_value["name"], - "enabled": constraint_value["enabled"], - "time_step": constraint_value["type"], - "operator": constraint_value["operator"], - "comments": constraint_value.get("comments", None), + "enabled": constraint_value.get("enabled", True), + "time_step": constraint_value.get("type", BindingConstraintFrequency.HOURLY), + "operator": constraint_value.get("operator", BindingConstraintOperator.EQUAL), + "comments": constraint_value.get("comments", ""), "filter_year_by_year": constraint_value.get("filter-year-by-year", ""), "filter_synthesis": constraint_value.get("filter-synthesis", ""), - "constraints": None, } if version < 870: new_config: BindingConstraintConfigType = BindingConstraintConfig(**args) else: - args["group"] = constraint_value.get("group") + args["group"] = constraint_value.get("group", DEFAULT_GROUP) new_config = BindingConstraintConfig870(**args) for key, value in constraint_value.items(): @@ -413,8 +421,8 @@ def constraints_to_coeffs( constraint: BindingConstraintConfigType, ) -> Dict[str, List[float]]: coeffs: Dict[str, List[float]] = {} - if constraint.constraints is not None: - for term in constraint.constraints: + if constraint.terms is not None: + for term in constraint.terms: if term.id is not None and term.weight is not None: coeffs[term.id] = [term.weight] if term.offset is not None: @@ -640,7 +648,7 @@ def update_constraint_term( if not isinstance(constraint, BindingConstraintConfig) and not isinstance(constraint, BindingConstraintConfig): raise BindingConstraintNotFoundError(study.id) - constraint_terms = constraint.constraints # existing constraint terms + constraint_terms = constraint.terms # existing constraint terms if constraint_terms is None: raise NoConstraintError(study.id) @@ -695,11 +703,11 @@ def add_new_constraint_term( raise MissingDataError("Add new constraint term : data is missing") constraint_id = constraint_term.data.generate_id() - constraints_term = constraint.constraints or [] - if find_constraint_term_id(constraints_term, constraint_id) >= 0: + constraint_terms = constraint.terms or [] + if find_constraint_term_id(constraint_terms, constraint_id) >= 0: raise ConstraintAlreadyExistError(study.id) - constraints_term.append( + constraint_terms.append( ConstraintTermDTO( id=constraint_id, weight=constraint_term.weight if constraint_term.weight is not None else 0.0, @@ -708,7 +716,7 @@ def add_new_constraint_term( ) ) coeffs = {} - for term in constraints_term: + for term in constraint_terms: coeffs[term.id] = [term.weight] if term.offset is not None: coeffs[term.id].append(term.offset) @@ -759,7 +767,7 @@ def _replace_matrices_according_to_frequency_and_version( return args -def find_constraint_term_id(constraints_term: List[ConstraintTermDTO], constraint_term_id: str) -> int: +def find_constraint_term_id(constraints_term: Sequence[ConstraintTermDTO], constraint_term_id: str) -> int: try: index = [elm.id for elm in constraints_term].index(constraint_term_id) return index 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 4b3885a5f0..a8c04d657f 100644 --- a/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py +++ b/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py @@ -71,13 +71,13 @@ def check_matrix_values(time_step: BindingConstraintFrequency, values: MatrixTyp raise ValueError("Matrix values cannot contain NaN") -class BindingConstraintProperties(BaseModel, extra=Extra.forbid): +class BindingConstraintProperties(BaseModel, extra=Extra.forbid, allow_population_by_field_name=True): enabled: bool = True - time_step: BindingConstraintFrequency - operator: BindingConstraintOperator - filter_year_by_year: t.Optional[str] = None - filter_synthesis: t.Optional[str] = None - comments: t.Optional[str] = None + time_step: BindingConstraintFrequency = BindingConstraintFrequency.HOURLY + operator: BindingConstraintOperator = BindingConstraintOperator.EQUAL + comments: str = "" + filter_year_by_year: str = "" + filter_synthesis: str = "" class BindingConstraintProperties870(BindingConstraintProperties): diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index 2bcb56100f..c6aa7fe6e6 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -1,8 +1,10 @@ import enum import logging +import warnings from http import HTTPStatus from typing import Any, Dict, List, Mapping, Optional, Sequence, Union, cast +import typing_extensions as te from fastapi import APIRouter, Body, Depends, Query from starlette.responses import RedirectResponse @@ -68,6 +70,13 @@ logger = logging.getLogger(__name__) +class BCKeyValueType(te.TypedDict): + """Deprecated type for binding constraint key-value pair (used for update)""" + + key: str + value: Union[str, int, float, bool] + + class ClusterType(str, enum.Enum): """ Cluster type: @@ -972,7 +981,7 @@ def get_binding_constraint( def update_binding_constraint( uuid: str, binding_constraint_id: str, - data: BindingConstraintEdition, + data: Union[BCKeyValueType, BindingConstraintEdition], current_user: JWTUser = Depends(auth.get_current_user), ) -> BindingConstraintConfigType: logger.info( @@ -981,6 +990,18 @@ def update_binding_constraint( ) params = RequestParameters(user=current_user) study = study_service.check_study_access(uuid, StudyPermissionType.WRITE, params) + + if isinstance(data, dict): + warnings.warn( + "Using key / value format for binding constraint data is deprecated." + " Please use the BindingConstraintEdition format instead.", + DeprecationWarning, + ) + _obj = {data["key"]: data["value"]} + if "filterByYear" in _obj: + _obj["filterYearByYear"] = _obj.pop("filterByYear") + data = BindingConstraintEdition(**_obj) + return study_service.binding_constraint_manager.update_binding_constraint(study, binding_constraint_id, data) @bp.get( diff --git a/tests/integration/study_data_blueprint/test_binding_constraints.py b/tests/integration/study_data_blueprint/test_binding_constraints.py index 5950a8b121..3cf4a921cd 100644 --- a/tests/integration/study_data_blueprint/test_binding_constraints.py +++ b/tests/integration/study_data_blueprint/test_binding_constraints.py @@ -202,7 +202,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st json={ "name": "binding_constraint_3", "enabled": True, - "time_step": "hourly", + "timeStep": "hourly", "operator": "less", "coeffs": {}, "comments": "New API", @@ -222,36 +222,36 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st expected = [ { "comments": "", - "constraints": None, + "constraints": [], # should be renamed to `terms` in the future. "enabled": True, - "filter_synthesis": "", - "filter_year_by_year": "", + "filterSynthesis": "", + "filterYearByYear": "", "id": "binding_constraint_1", "name": "binding_constraint_1", "operator": "less", - "time_step": "hourly", + "timeStep": "hourly", }, { "comments": "", - "constraints": None, + "constraints": [], # should be renamed to `terms` in the future. "enabled": True, - "filter_synthesis": "", - "filter_year_by_year": "", + "filterSynthesis": "", + "filterYearByYear": "", "id": "binding_constraint_2", "name": "binding_constraint_2", "operator": "less", - "time_step": "hourly", + "timeStep": "hourly", }, { "comments": "New API", - "constraints": None, + "constraints": [], # should be renamed to `terms` in the future. "enabled": True, - "filter_synthesis": "", - "filter_year_by_year": "", + "filterSynthesis": "", + "filterYearByYear": "", "id": "binding_constraint_3", "name": "binding_constraint_3", "operator": "less", - "time_step": "hourly", + "timeStep": "hourly", }, ] assert binding_constraints_list == expected @@ -301,7 +301,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st ) assert res.status_code == 200, res.json() binding_constraint = res.json() - constraint_terms = binding_constraint["constraints"] + constraint_terms = binding_constraint["constraints"] # should be renamed to `terms` in the future. expected = [ { "data": {"area1": area1_id, "area2": area2_id}, @@ -336,7 +336,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st ) assert res.status_code == 200, res.json() binding_constraint = res.json() - constraint_terms = binding_constraint["constraints"] + constraint_terms = binding_constraint["constraints"] # should be renamed to `terms` in the future. expected = [ { "data": {"area1": area1_id, "area2": area2_id}, @@ -405,17 +405,17 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st assert res.status_code == 200 assert res.json()["comments"] == new_comment - # The user change the time_step to daily instead of hourly. + # The user change the timeStep to daily instead of hourly. # We must check that the matrix is a daily/weekly matrix. res = client.put( f"/v1/studies/{study_id}/bindingconstraints/{bc_id}", - json={"time_step": "daily"}, + json={"timeStep": "daily"}, headers=user_headers, ) - assert res.status_code == 200 - assert res.json()["time_step"] == "daily" + assert res.status_code == 200, res.json() + assert res.json()["timeStep"] == "daily" - # Check the last command is a change time_step + # Check that the command corresponds to a change in `time_step` if study_type == "variant": res = client.get(f"/v1/studies/{study_id}/commands", headers=user_headers) commands = res.json() @@ -444,7 +444,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st json={ "name": " ", "enabled": True, - "time_step": "hourly", + "timeStep": "hourly", "operator": "less", "coeffs": {}, "comments": "New API", @@ -463,7 +463,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st json={ "name": "%%**", "enabled": True, - "time_step": "hourly", + "timeStep": "hourly", "operator": "less", "coeffs": {}, "comments": "New API", @@ -482,7 +482,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st json={ "name": bc_id, "enabled": True, - "time_step": "hourly", + "timeStep": "hourly", "operator": "less", "coeffs": {}, "comments": "", @@ -497,7 +497,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st json={ "name": "binding_constraint_x", "enabled": True, - "time_step": "hourly", + "timeStep": "hourly", "operator": "less", "coeffs": {}, "comments": "2 types of matrices", @@ -519,7 +519,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st json={ "name": "binding_constraint_x", "enabled": True, - "time_step": "hourly", + "timeStep": "hourly", "operator": "less", "coeffs": {}, "comments": "Incoherent matrix with version", @@ -536,7 +536,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st wrong_request_args = { "name": "binding_constraint_5", "enabled": True, - "time_step": "daily", + "timeStep": "daily", "operator": "less", "coeffs": {}, "comments": "Creation with matrix", @@ -616,7 +616,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud # Creation of a bc without group bc_id_wo_group = "binding_constraint_1" - args = {"enabled": True, "time_step": "hourly", "operator": "less", "coeffs": {}, "comments": "New API"} + args = {"enabled": True, "timeStep": "hourly", "operator": "less", "coeffs": {}, "comments": "New API"} res = client.post( f"/v1/studies/{study_id}/bindingconstraints", json={"name": bc_id_wo_group, **args}, @@ -697,11 +697,11 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud assert res.status_code == 200 assert res.json()["data"] == matrix_lt3.tolist() - # The user changed the time_step to daily instead of hourly. + # The user changed the timeStep to daily instead of hourly. # We must check that the matrices have been updated. res = client.put( f"/v1/studies/{study_id}/bindingconstraints/{bc_id_w_matrix}", - json={"time_step": "daily"}, + json={"timeStep": "daily"}, headers=admin_headers, ) assert res.status_code == 200, res.json() @@ -757,7 +757,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud json={ "name": "binding_constraint_700", "enabled": True, - "time_step": "hourly", + "timeStep": "hourly", "operator": "less", "coeffs": {}, "comments": "New API", @@ -932,15 +932,15 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud assert groups["Group 2"] == [ { "comments": "New API", - "constraints": None, + "constraints": [], "enabled": True, - "filter_synthesis": "", - "filter_year_by_year": "", + "filterSynthesis": "", + "filterYearByYear": "", "group": "Group 2", "id": "second bc", "name": "Second BC", "operator": "less", - "time_step": "hourly", + "timeStep": "hourly", } ] diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index 33b059ccae..c99b8c9e0e 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -1706,7 +1706,7 @@ def test_area_management(client: TestClient, admin_access_token: str, study_id: binding_constraint_1 = res.json() assert res.status_code == 200 - constraint = binding_constraint_1["constraints"][0] + constraint = binding_constraint_1["constraints"][0] # should be renamed to `terms` in the future. assert constraint["id"] == "area 1.cluster 1" assert constraint["weight"] == 2.0 assert constraint["offset"] == 4.0 From 004abcb39a69bbd512bb6e38f6531e5ebf62a6c9 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Mon, 25 Mar 2024 07:53:39 +0100 Subject: [PATCH 089/248] feat(bc): make `comments` `filter_year_by_year` `filter_synthesis` optional --- .../model/command/create_binding_constraint.py | 6 +++--- tests/variantstudy/test_command_factory.py | 14 +++++++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) 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 a8c04d657f..5a832b7d29 100644 --- a/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py +++ b/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py @@ -75,9 +75,9 @@ class BindingConstraintProperties(BaseModel, extra=Extra.forbid, allow_populatio enabled: bool = True time_step: BindingConstraintFrequency = BindingConstraintFrequency.HOURLY operator: BindingConstraintOperator = BindingConstraintOperator.EQUAL - comments: str = "" - filter_year_by_year: str = "" - filter_synthesis: str = "" + comments: t.Optional[str] = None + filter_year_by_year: t.Optional[str] = None + filter_synthesis: t.Optional[str] = None class BindingConstraintProperties870(BindingConstraintProperties): diff --git a/tests/variantstudy/test_command_factory.py b/tests/variantstudy/test_command_factory.py index a0324a2722..09f45d30f3 100644 --- a/tests/variantstudy/test_command_factory.py +++ b/tests/variantstudy/test_command_factory.py @@ -132,6 +132,9 @@ def setup_class(self): "operator": "equal", "coeffs": {}, "values": "values", + "comments": "", + "filter_synthesis": "", + "filter_year_by_year": "", }, ), CommandDTO( @@ -144,7 +147,10 @@ def setup_class(self): "operator": "equal", "coeffs": {}, "values": "values", - } + "comments": "", + "filter_synthesis": "", + "filter_year_by_year": "", + }, ], ), CommandDTO( @@ -156,6 +162,9 @@ def setup_class(self): "operator": "equal", "coeffs": {}, "values": "values", + "comments": "", + "filter_synthesis": "", + "filter_year_by_year": "", }, ), CommandDTO( @@ -167,6 +176,9 @@ def setup_class(self): "time_step": "hourly", "operator": "equal", "coeffs": {}, + "comments": "", + "filter_synthesis": "", + "filter_year_by_year": "", } ], ), From 51d60924e4d7d46330550f7e276c316b7cf2046c Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Tue, 26 Mar 2024 17:01:04 +0100 Subject: [PATCH 090/248] refactor(bc)!: update binding constraints service and address minor issues BREAKING CHANGE: must now use `terms` instead of `coeffs` for constraints endpoints. --- .../business/binding_constraint_management.py | 399 +++++++++++------- .../study/business/table_mode_management.py | 2 +- antarest/study/web/study_data_blueprint.py | 64 +-- .../test_binding_constraints.py | 22 +- 4 files changed, 290 insertions(+), 197 deletions(-) diff --git a/antarest/study/business/binding_constraint_management.py b/antarest/study/business/binding_constraint_management.py index 265ee35828..181fbb5c07 100644 --- a/antarest/study/business/binding_constraint_management.py +++ b/antarest/study/business/binding_constraint_management.py @@ -55,7 +55,7 @@ """Default group name for binding constraints if missing or empty.""" -class AreaLinkDTO(BaseModel): +class LinkTerm(BaseModel): """ DTO for a constraint term on a link between two areas. @@ -74,7 +74,7 @@ def generate_id(self) -> str: return "%".join(ids) -class AreaClusterDTO(BaseModel): +class ClusterTerm(BaseModel): """ DTO for a constraint term on a cluster in an area. @@ -93,7 +93,7 @@ def generate_id(self) -> str: return ".".join(ids) -class ConstraintTermDTO(BaseModel): +class ConstraintTerm(BaseModel): """ DTO for a constraint term. @@ -107,7 +107,7 @@ class ConstraintTermDTO(BaseModel): id: Optional[str] weight: Optional[float] offset: Optional[float] - data: Optional[Union[AreaLinkDTO, AreaClusterDTO]] + data: Optional[Union[LinkTerm, ClusterTerm]] @validator("id") def id_to_lower(cls, v: Optional[str]) -> Optional[str]: @@ -123,9 +123,9 @@ def generate_id(self) -> str: return self.data.generate_id() -class BindingConstraintFilter(BaseModel, frozen=True, extra="forbid"): +class ConstraintFilters(BaseModel, frozen=True, extra="forbid"): """ - Binding Constraint Filter gathering the main filtering parameters. + Binding Constraint Filters gathering the main filtering parameters. Attributes: bc_id: binding constraint ID (exact match) @@ -151,15 +151,15 @@ class BindingConstraintFilter(BaseModel, frozen=True, extra="forbid"): link_id: str = "" cluster_id: str = "" - def accept(self, constraint: "BindingConstraintConfigType") -> bool: + def match_filters(self, constraint: "ConstraintOutput") -> bool: """ - Check if the constraint matches the filter. + Check if the constraint matches the filters. Args: constraint: the constraint to check Returns: - True if the constraint matches the filter, False otherwise + True if the constraint matches the filters, False otherwise """ if self.bc_id and self.bc_id != constraint.id: return False @@ -182,18 +182,21 @@ def accept(self, constraint: "BindingConstraintConfigType") -> bool: terms = constraint.terms or [] if self.area_name: - all_areas = [] + matching_terms = [] + for term in terms: - if term.data is None: - continue - if isinstance(term.data, AreaLinkDTO): - all_areas.extend([term.data.area1, term.data.area2]) - elif isinstance(term.data, AreaClusterDTO): - all_areas.append(term.data.area) - else: # pragma: no cover - raise NotImplementedError(f"Unknown term data type: {type(term.data)}") - upper_area_name = self.area_name.upper() - if all_areas and not any(upper_area_name in area.upper() for area in all_areas): + if term.data: + if isinstance(term.data, LinkTerm): + # Check if either area in the link matches the specified area_name + if self.area_name.upper() in (term.data.area1.upper(), term.data.area2.upper()): + matching_terms.append(term) + elif isinstance(term.data, ClusterTerm): + # Check if the cluster's area matches the specified area_name + if self.area_name.upper() == term.data.area.upper(): + matching_terms.append(term) + + # If no terms match, the constraint should not pass + if not matching_terms: return False if self.cluster_name: @@ -201,19 +204,19 @@ def accept(self, constraint: "BindingConstraintConfigType") -> bool: for term in terms: if term.data is None: continue - if isinstance(term.data, AreaClusterDTO): + if isinstance(term.data, ClusterTerm): all_clusters.append(term.data.cluster) upper_cluster_name = self.cluster_name.upper() if all_clusters and not any(upper_cluster_name in cluster.upper() for cluster in all_clusters): return False if self.link_id: - all_link_ids = [term.data.generate_id() for term in terms if isinstance(term.data, AreaLinkDTO)] + all_link_ids = [term.data.generate_id() for term in terms if isinstance(term.data, LinkTerm)] if not any(self.link_id.lower() == link_id.lower() for link_id in all_link_ids): return False if self.cluster_id: - all_cluster_ids = [term.data.generate_id() for term in terms if isinstance(term.data, AreaClusterDTO)] + all_cluster_ids = [term.data.generate_id() for term in terms if isinstance(term.data, ClusterTerm)] if not any(self.cluster_id.lower() == cluster_id.lower() for cluster_id in all_cluster_ids): return False @@ -221,21 +224,21 @@ def accept(self, constraint: "BindingConstraintConfigType") -> bool: @camel_case_model -class BindingConstraintEditionModel(BindingConstraintProperties870, metaclass=AllOptionalMetaclass, use_none=True): - coeffs: Dict[str, List[float]] +class ConstraintInput870(BindingConstraintProperties870, metaclass=AllOptionalMetaclass, use_none=True): + pass @camel_case_model -class BindingConstraintEdition(BindingConstraintMatrices, BindingConstraintEditionModel): - pass +class ConstraintInput(BindingConstraintMatrices, ConstraintInput870): + terms: MutableSequence[ConstraintTerm] = Field( + default_factory=lambda: [], + ) @camel_case_model -class BindingConstraintCreation(BindingConstraintMatrices, BindingConstraintProperties870): +class ConstraintCreation(ConstraintInput): name: str - coeffs: Dict[str, List[float]] - # Ajout d'un root validator pour valider les dimensions des matrices @root_validator(pre=True) def check_matrices_dimensions(cls, values: Dict[str, Any]) -> Dict[str, Any]: for _key in ["time_step", "less_term_matrix", "equal_term_matrix", "greater_term_matrix"]: @@ -292,27 +295,23 @@ def check_matrices_dimensions(cls, values: Dict[str, Any]) -> Dict[str, Any]: @camel_case_model -class _BindingConstraintConfig(BindingConstraintProperties): +class ConstraintOutputBase(BindingConstraintProperties): id: str name: str - - -class BindingConstraintConfig(_BindingConstraintConfig): - terms: MutableSequence[ConstraintTermDTO] = Field( + terms: MutableSequence[ConstraintTerm] = Field( default_factory=lambda: [], - alias="constraints", # only for backport compatibility - title="Constraint terms", ) -class BindingConstraintConfig870(BindingConstraintConfig): - group: Optional[str] = None +@camel_case_model +class ConstraintOutput870(ConstraintOutputBase): + group: str -BindingConstraintConfigType = Union[BindingConstraintConfig870, BindingConstraintConfig] +ConstraintOutput = Union[ConstraintOutputBase, ConstraintOutput870] -def _validate_binding_constraints(file_study: FileStudy, bcs: Sequence[BindingConstraintConfigType]) -> bool: +def _validate_binding_constraints(file_study: FileStudy, bcs: Sequence[ConstraintOutput]) -> bool: if int(file_study.config.version) < 870: matrix_id_fmts = {"{bc_id}"} else: @@ -356,7 +355,7 @@ def __init__( self.storage_service = storage_service @staticmethod - def parse_constraint(key: str, value: str, char: str, new_config: BindingConstraintConfigType) -> bool: + def parse_constraint(key: str, value: str, char: str, new_config: ConstraintOutput) -> bool: split = key.split(char) if len(split) == 2: value1 = split[0] @@ -373,16 +372,16 @@ def parse_constraint(key: str, value: str, char: str, new_config: BindingConstra if new_config.terms is None: new_config.terms = [] new_config.terms.append( - ConstraintTermDTO( + ConstraintTerm( id=key, weight=weight, offset=offset if offset is not None else None, - data=AreaLinkDTO( + data=LinkTerm( area1=value1, area2=value2, ) if char == "%" - else AreaClusterDTO( + else ClusterTerm( area=value1, cluster=value2, ), @@ -392,131 +391,216 @@ def parse_constraint(key: str, value: str, char: str, new_config: BindingConstra return False @staticmethod - def process_constraint(constraint_value: Dict[str, Any], version: int) -> BindingConstraintConfigType: - args = { - "id": constraint_value["id"], - "name": constraint_value["name"], - "enabled": constraint_value.get("enabled", True), - "time_step": constraint_value.get("type", BindingConstraintFrequency.HOURLY), - "operator": constraint_value.get("operator", BindingConstraintOperator.EQUAL), - "comments": constraint_value.get("comments", ""), - "filter_year_by_year": constraint_value.get("filter-year-by-year", ""), - "filter_synthesis": constraint_value.get("filter-synthesis", ""), + def constraint_model_adapter(constraint: ConstraintInput, version: int) -> ConstraintOutput: + """ + Adapts a constraint configuration to the appropriate version-specific format. + + Parameters: + - constraint: A dictionary or model representing the constraint to be adapted. + This can either be a dictionary coming from client input or an existing + model that needs reformatting. + - version: An integer indicating the target version of the study configuration. This is used to + determine which model class to instantiate and which default values to apply. + + Returns: + - A new instance of either `ConstraintOutputBase` or `ConstraintOutput870`, + populated with the adapted values from the input constraint, and conforming to the + structure expected by the specified version. + + Note: + This method is crucial for ensuring backward compatibility and future-proofing the application + as it evolves. It allows client-side data to be accurately represented within the config and + ensures data integrity when storing or retrieving constraint configurations from the database. + """ + + constraint_output = { + "id": constraint["id"], + "name": constraint["name"], + "enabled": constraint.get("enabled", True), + "time_step": constraint.get("type", BindingConstraintFrequency.HOURLY), + "operator": constraint.get("operator", BindingConstraintOperator.EQUAL), + "comments": constraint.get("comments", ""), + "filter_year_by_year": constraint.get("filter_year_by_year", ""), + "filter_synthesis": constraint.get("filter_synthesis", ""), + "terms": constraint.get("terms", []), } + if version < 870: - new_config: BindingConstraintConfigType = BindingConstraintConfig(**args) + adapted_constraint = ConstraintOutputBase(**constraint_output) else: - args["group"] = constraint_value.get("group", DEFAULT_GROUP) - new_config = BindingConstraintConfig870(**args) + constraint_output["group"] = constraint.get("group", DEFAULT_GROUP) + adapted_constraint = ConstraintOutput870(**constraint_output) - for key, value in constraint_value.items(): - if BindingConstraintManager.parse_constraint(key, value, "%", new_config): + for key, value in constraint.items(): + if BindingConstraintManager.parse_constraint(key, value, "%", adapted_constraint): continue - if BindingConstraintManager.parse_constraint(key, value, ".", new_config): + if BindingConstraintManager.parse_constraint(key, value, ".", adapted_constraint): continue - return new_config + return adapted_constraint @staticmethod - def constraints_to_coeffs( - constraint: BindingConstraintConfigType, - ) -> Dict[str, List[float]]: - coeffs: Dict[str, List[float]] = {} - if constraint.terms is not None: - for term in constraint.terms: - if term.id is not None and term.weight is not None: + def terms_to_coeffs(terms: Sequence[ConstraintTerm]) -> Dict[str, List[float]]: + """ + Converts a sequence of terms into a dictionary mapping each term's ID to its coefficients, + including the weight and, optionally, the offset. + + :param terms: A sequence of terms to be converted. + :return: A dictionary of term IDs mapped to a list of their coefficients. + """ + coeffs = {} + + if terms is not None: + for term in terms: + if term.id and term.weight is not None: coeffs[term.id] = [term.weight] if term.offset is not None: coeffs[term.id].append(term.offset) - return coeffs + return coeffs def get_binding_constraint( self, study: Study, - bc_filter: BindingConstraintFilter = BindingConstraintFilter(), - ) -> Union[BindingConstraintConfigType, List[BindingConstraintConfigType], None]: + filters: ConstraintFilters = ConstraintFilters(), + ) -> Union[ConstraintOutput, List[ConstraintOutput]]: storage_service = self.storage_service.get_storage(study) file_study = storage_service.get_raw(study) config = file_study.tree.get(["input", "bindingconstraints", "bindingconstraints"]) - bc_by_ids: Dict[str, BindingConstraintConfigType] = {} - for value in config.values(): - new_config = BindingConstraintManager.process_constraint(value, int(study.version)) - bc_by_ids[new_config.id] = new_config + # TODO: if a single constraint ID is passed, and don't exist in the config raise an execption - result = {bc_id: bc for bc_id, bc in bc_by_ids.items() if bc_filter.accept(bc)} + constraints_by_id: Dict[str, ConstraintOutput] = {} - # If a specific bc_id is provided, we return a single element - if bc_filter.bc_id: - return result.get(bc_filter.bc_id) + for constraint in config.values(): + constraint_config = self.constraint_model_adapter(constraint, int(study.version)) + constraints_by_id[constraint_config.id] = constraint_config - # Else we return all the matching elements - return list(result.values()) + filtered_constraints = {bc_id: bc for bc_id, bc in constraints_by_id.items() if filters.match_filters(bc)} - def get_binding_constraint_groups(self, study: Study) -> Mapping[str, Sequence[BindingConstraintConfigType]]: + # If a specific constraint ID is provided, we return that constraint + if filters.bc_id: + return filtered_constraints.get(filters.bc_id) + + # Else we return all the matching constraints, based on the given filters + return list(filtered_constraints.values()) + + def get_grouped_constraints(self, study: Study) -> Mapping[str, Sequence[ConstraintOutput]]: """ - Get all binding constraints grouped by group name. + Retrieves and groups all binding constraints by their group names within a given study. + + This method organizes binding constraints into a dictionary where each key corresponds to a group name, + and the value is a list of ConstraintOutput objects associated with that group. Args: study: the study Returns: - A dictionary with group names as keys and lists of binding constraints as values. + A dictionary mapping group names to lists of binding constraints associated with each group. + + Notes: + The grouping considers the exact group name, implying case sensitivity. If case-insensitive grouping + is required, normalization of group names to a uniform case (e.g., all lower or upper) should be performed. """ storage_service = self.storage_service.get_storage(study) file_study = storage_service.get_raw(study) config = file_study.tree.get(["input", "bindingconstraints", "bindingconstraints"]) - bcs_by_group = CaseInsensitiveDict() # type: ignore - for value in config.values(): - _bc_config = BindingConstraintManager.process_constraint(value, int(study.version)) - _group = getattr(_bc_config, "group", DEFAULT_GROUP) - bcs_by_group.setdefault(_group, []).append(_bc_config) - return bcs_by_group - - def get_binding_constraint_group(self, study: Study, group_name: str) -> Sequence[BindingConstraintConfigType]: + grouped_constraints = CaseInsensitiveDict() # type: ignore + + for constraint in config.values(): + constraint_config = self.constraint_model_adapter(constraint, int(study.version)) + constraint_group = getattr(constraint_config, "group", DEFAULT_GROUP) + grouped_constraints.setdefault(constraint_group, []).append(constraint_config) + + return grouped_constraints + + def get_constraints_by_group(self, study: Study, group_name: str) -> Sequence[ConstraintOutput]: """ - Get all binding constraints from a given group. + Retrieve all binding constraints belonging to a specified group within a study. Args: - study: the study. - group_name: the group name (case-insensitive). + study: The study from which to retrieve the constraints. + group_name: The name of the group (case-insensitive). Returns: - A list of binding constraints from the group. + A list of ConstraintOutput objects that belong to the specified group. + + Raises: + BindingConstraintNotFoundError: If the specified group name is not found among the constraint groups. """ - groups = self.get_binding_constraint_groups(study) - if group_name not in groups: + grouped_constraints = self.get_grouped_constraints(study) + + if group_name not in grouped_constraints: raise BindingConstraintNotFoundError(f"Group '{group_name}' not found") - return groups[group_name] - def validate_binding_constraint_group(self, study: Study, group_name: str) -> bool: + return grouped_constraints[group_name] + + def validate_constraint_group(self, study: Study, group_name: str) -> bool: + """ + Validates if the specified group name exists within the study's binding constraints + and checks the validity of the constraints within that group. + + This method performs a case-insensitive search to match the specified group name against + existing groups of binding constraints. It ensures that the group exists and then + validates the constraints within that found group. + + Args: + study: The study object containing binding constraints. + group_name: The name of the group (case-insensitive). + + Returns: + True if the group exists and the constraints within the group are valid; False otherwise. + + Raises: + BindingConstraintNotFoundError: If no matching group name is found in a case-insensitive manner. + """ storage_service = self.storage_service.get_storage(study) file_study = storage_service.get_raw(study) - bcs_by_group = self.get_binding_constraint_groups(study) - if group_name not in bcs_by_group: + grouped_constraints = self.get_grouped_constraints(study) + + if group_name not in grouped_constraints: raise BindingConstraintNotFoundError(f"Group '{group_name}' not found") - bcs = bcs_by_group[group_name] - return _validate_binding_constraints(file_study, bcs) - def validate_binding_constraint_groups(self, study: Study) -> bool: + constraints = grouped_constraints[group_name] + return _validate_binding_constraints(file_study, constraints) + + def validate_constraint_groups(self, study: Study) -> bool: + """ + Validates all groups of binding constraints within the given study. + + This method checks each group of binding constraints for validity based on specific criteria + (e.g., coherence between matrices lengths). If any group fails the validation, an aggregated + error detailing all incoherences is raised. + + Args: + study: The study object containing binding constraints. + + Returns: + True if all constraint groups are valid. + + Raises: + IncoherenceBetweenMatricesLength: If any validation checks fail. + """ storage_service = self.storage_service.get_storage(study) file_study = storage_service.get_raw(study) - bcs_by_group = self.get_binding_constraint_groups(study) + grouped_constraints = self.get_grouped_constraints(study) invalid_groups = {} - for group_name, bcs in bcs_by_group.items(): + + for group_name, bcs in grouped_constraints.items(): try: _validate_binding_constraints(file_study, bcs) except IncoherenceBetweenMatricesLength as e: invalid_groups[group_name] = e.detail + if invalid_groups: raise IncoherenceBetweenMatricesLength(invalid_groups) + return True def create_binding_constraint( self, study: Study, - data: BindingConstraintCreation, - ) -> BindingConstraintConfigType: + data: ConstraintCreation, + ) -> ConstraintOutput: bc_id = transform_name_to_id(data.name) version = int(study.version) @@ -528,12 +612,12 @@ def create_binding_constraint( check_attributes_coherence(data, version) - args = { + new_constraint = { "name": data.name, "enabled": data.enabled, "time_step": data.time_step, "operator": data.operator, - "coeffs": data.coeffs, + "coeffs": self.terms_to_coeffs(data.terms), "values": data.values, "less_term_matrix": data.less_term_matrix, "equal_term_matrix": data.equal_term_matrix, @@ -542,11 +626,12 @@ def create_binding_constraint( "filter_synthesis": data.filter_synthesis, "comments": data.comments or "", } + if version >= 870: - args["group"] = data.group or DEFAULT_GROUP + new_constraint["group"] = data.group or DEFAULT_GROUP command = CreateBindingConstraint( - **args, command_context=self.storage_service.variant_study_service.command_factory.command_context + **new_constraint, command_context=self.storage_service.variant_study_service.command_factory.command_context ) # Validates the matrices. Needed when the study is a variant because we only append the command to the list @@ -557,21 +642,21 @@ def create_binding_constraint( execute_or_add_commands(study, file_study, [command], self.storage_service) # Processes the constraints to add them inside the endpoint response. - args["id"] = bc_id - args["type"] = data.time_step - return BindingConstraintManager.process_constraint(args, version) + new_constraint["id"] = bc_id + new_constraint["type"] = data.time_step + return self.constraint_model_adapter(new_constraint, version) def update_binding_constraint( self, study: Study, binding_constraint_id: str, - data: BindingConstraintEdition, - ) -> BindingConstraintConfigType: + data: ConstraintInput, + ) -> ConstraintOutput: file_study = self.storage_service.get_storage(study).get_raw(study) - constraint = self.get_binding_constraint(study, BindingConstraintFilter(bc_id=binding_constraint_id)) + existing_constraint = self.get_binding_constraint(study, ConstraintFilters(bc_id=binding_constraint_id)) study_version = int(study.version) - if not isinstance(constraint, BindingConstraintConfig) and not isinstance( - constraint, BindingConstraintConfig870 + if not isinstance(existing_constraint, ConstraintOutputBase) and not isinstance( + existing_constraint, ConstraintOutput870 ): raise BindingConstraintNotFoundError(study.id) @@ -580,32 +665,35 @@ def update_binding_constraint( # Because the update_binding_constraint command requires every attribute we have to fill them all. # This creates a `big` command even though we only updated one field. # fixme : Change the architecture to avoid this type of misconception - binding_constraint_output = { + updated_constraint = { "id": binding_constraint_id, - "enabled": data.enabled or constraint.enabled, - "time_step": data.time_step or constraint.time_step, - "operator": data.operator or constraint.operator, - "coeffs": data.coeffs or BindingConstraintManager.constraints_to_coeffs(constraint), - "filter_year_by_year": data.filter_year_by_year or constraint.filter_year_by_year, - "filter_synthesis": data.filter_synthesis or constraint.filter_synthesis, - "comments": data.comments or constraint.comments, + "enabled": data.enabled if data.enabled is not None else existing_constraint.enabled, + "time_step": data.time_step or existing_constraint.time_step, + "operator": data.operator or existing_constraint.operator, + "coeffs": self.terms_to_coeffs(data.terms) or self.terms_to_coeffs(existing_constraint.terms), + "filter_year_by_year": data.filter_year_by_year or existing_constraint.filter_year_by_year, + "filter_synthesis": data.filter_synthesis or existing_constraint.filter_synthesis, + "comments": data.comments or existing_constraint.comments, } + if study_version >= 870: - binding_constraint_output["group"] = data.group or constraint.group # type: ignore + updated_constraint["group"] = data.group or existing_constraint.group args = { - **binding_constraint_output, + **updated_constraint, "command_context": self.storage_service.variant_study_service.command_factory.command_context, } + for term in ["values", "less_term_matrix", "equal_term_matrix", "greater_term_matrix"]: if matrices_to_update := getattr(data, term): args[term] = matrices_to_update - if data.time_step is not None and data.time_step != constraint.time_step: + if data.time_step is not None and data.time_step != existing_constraint.time_step: # The user changed the time step, we need to update the matrix accordingly args = _replace_matrices_according_to_frequency_and_version(data, study_version, args) command = UpdateBindingConstraint(**args) + # Validates the matrices. Needed when the study is a variant because we only append the command to the list if isinstance(study, VariantStudy): updated_matrices = [ @@ -614,12 +702,17 @@ def update_binding_constraint( command.validates_and_fills_matrices( specific_matrices=updated_matrices, version=study_version, create=False ) + execute_or_add_commands(study, file_study, [command], self.storage_service) # Processes the constraints to add them inside the endpoint response. - binding_constraint_output["name"] = constraint.name - binding_constraint_output["type"] = binding_constraint_output["time_step"] - return BindingConstraintManager.process_constraint(binding_constraint_output, study_version) + updated_constraint["name"] = existing_constraint.name + updated_constraint["type"] = updated_constraint["time_step"] + # Replace coeffs by the terms + del updated_constraint["coeffs"] + updated_constraint["terms"] = data.terms or existing_constraint.terms + + return self.constraint_model_adapter(updated_constraint, study_version) def remove_binding_constraint(self, study: Study, binding_constraint_id: str) -> None: command = RemoveBindingConstraint( @@ -630,7 +723,7 @@ def remove_binding_constraint(self, study: Study, binding_constraint_id: str) -> # Needed when the study is a variant because we only append the command to the list if isinstance(study, VariantStudy) and not self.get_binding_constraint( - study, BindingConstraintFilter(bc_id=binding_constraint_id) + study, ConstraintFilters(bc_id=binding_constraint_id) ): raise CommandApplicationError("Binding constraint not found") @@ -640,19 +733,19 @@ def update_constraint_term( self, study: Study, binding_constraint_id: str, - term: Union[ConstraintTermDTO, str], + term: ConstraintTerm, ) -> None: file_study = self.storage_service.get_storage(study).get_raw(study) - constraint = self.get_binding_constraint(study, BindingConstraintFilter(bc_id=binding_constraint_id)) + constraint = self.get_binding_constraint(study, ConstraintFilters(bc_id=binding_constraint_id)) - if not isinstance(constraint, BindingConstraintConfig) and not isinstance(constraint, BindingConstraintConfig): + if not isinstance(constraint, ConstraintOutputBase) and not isinstance(constraint, ConstraintOutputBase): raise BindingConstraintNotFoundError(study.id) constraint_terms = constraint.terms # existing constraint terms if constraint_terms is None: raise NoConstraintError(study.id) - term_id = term.id if isinstance(term, ConstraintTermDTO) else term + term_id = term.id if isinstance(term, ConstraintTerm) else term if term_id is None: raise ConstraintIdNotFoundError(study.id) @@ -660,11 +753,11 @@ def update_constraint_term( if term_id_index < 0: raise ConstraintIdNotFoundError(study.id) - if isinstance(term, ConstraintTermDTO): + if isinstance(term, ConstraintTerm): updated_term_id = term.data.generate_id() if term.data else term_id current_constraint = constraint_terms[term_id_index] - constraint_terms[term_id_index] = ConstraintTermDTO( + constraint_terms[term_id_index] = ConstraintTerm( id=updated_term_id, weight=term.weight or current_constraint.weight, offset=term.offset, @@ -688,15 +781,15 @@ def update_constraint_term( ) execute_or_add_commands(study, file_study, [command], self.storage_service) - def add_new_constraint_term( + def create_constraint_term( self, study: Study, binding_constraint_id: str, - constraint_term: ConstraintTermDTO, + constraint_term: ConstraintTerm, ) -> None: file_study = self.storage_service.get_storage(study).get_raw(study) - constraint = self.get_binding_constraint(study, BindingConstraintFilter(bc_id=binding_constraint_id)) - if not isinstance(constraint, BindingConstraintConfig) and not isinstance(constraint, BindingConstraintConfig): + constraint = self.get_binding_constraint(study, ConstraintFilters(bc_id=binding_constraint_id)) + if not isinstance(constraint, ConstraintOutputBase) and not isinstance(constraint, ConstraintOutputBase): raise BindingConstraintNotFoundError(study.id) if constraint_term.data is None: @@ -708,14 +801,16 @@ def add_new_constraint_term( raise ConstraintAlreadyExistError(study.id) constraint_terms.append( - ConstraintTermDTO( + ConstraintTerm( id=constraint_id, weight=constraint_term.weight if constraint_term.weight is not None else 0.0, offset=constraint_term.offset, data=constraint_term.data, ) ) + coeffs = {} + for term in constraint_terms: coeffs[term.id] = [term.weight] if term.offset is not None: @@ -745,7 +840,7 @@ def remove_constraint_term( def _replace_matrices_according_to_frequency_and_version( - data: BindingConstraintEdition, version: int, args: Dict[str, Any] + data: ConstraintInput, version: int, args: Dict[str, Any] ) -> Dict[str, Any]: if version < 870: if "values" not in args: @@ -767,7 +862,7 @@ def _replace_matrices_according_to_frequency_and_version( return args -def find_constraint_term_id(constraints_term: Sequence[ConstraintTermDTO], constraint_term_id: str) -> int: +def find_constraint_term_id(constraints_term: Sequence[ConstraintTerm], constraint_term_id: str) -> int: try: index = [elm.id for elm in constraints_term].index(constraint_term_id) return index @@ -775,9 +870,7 @@ def find_constraint_term_id(constraints_term: Sequence[ConstraintTermDTO], const return -1 -def check_attributes_coherence( - data: Union[BindingConstraintCreation, BindingConstraintEdition], study_version: int -) -> None: +def check_attributes_coherence(data: Union[ConstraintCreation, ConstraintInput], study_version: int) -> None: if study_version < 870: if data.group: raise InvalidFieldForVersionError( diff --git a/antarest/study/business/table_mode_management.py b/antarest/study/business/table_mode_management.py index 260d258a12..45472d63b5 100644 --- a/antarest/study/business/table_mode_management.py +++ b/antarest/study/business/table_mode_management.py @@ -482,7 +482,7 @@ def set_table_data( if current_binding: col_values = columns.dict(exclude_none=True) - current_binding_dto = BindingConstraintManager.process_constraint( + current_binding_dto = BindingConstraintManager.constraint_model_adapter( current_binding, int(study.version) ) diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index c6aa7fe6e6..8b3e2fe2c8 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -43,11 +43,11 @@ ThermalManager, ) from antarest.study.business.binding_constraint_management import ( - BindingConstraintConfigType, - BindingConstraintCreation, - BindingConstraintEdition, - BindingConstraintFilter, - ConstraintTermDTO, + ConstraintOutput, + ConstraintCreation, + ConstraintInput, + ConstraintFilters, + ConstraintTerm, ) from antarest.study.business.correlation_management import CorrelationFormFields, CorrelationManager, CorrelationMatrix from antarest.study.business.district_manager import DistrictCreationDTO, DistrictInfoDTO, DistrictUpdateDTO @@ -899,7 +899,7 @@ def update_version( "/studies/{uuid}/bindingconstraints", tags=[APITag.study_data], summary="Get binding constraint list", - response_model=None, # Dict[str, bool], + response_model=Union[ConstraintOutput, List[ConstraintOutput]], ) def get_binding_constraint_list( uuid: str, @@ -933,14 +933,14 @@ def get_binding_constraint_list( alias="clusterId", ), current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> Union[ConstraintOutput, List[ConstraintOutput]]: logger.info( f"Fetching binding constraint list for study {uuid}", extra={"user": current_user.id}, ) params = RequestParameters(user=current_user) study = study_service.check_study_access(uuid, StudyPermissionType.READ, params) - bc_filter = BindingConstraintFilter( + filters = ConstraintFilters( enabled=enabled, operator=operator, comments=comments, @@ -951,27 +951,27 @@ def get_binding_constraint_list( link_id=link_id, cluster_id=cluster_id, ) - return study_service.binding_constraint_manager.get_binding_constraint(study, bc_filter) + return study_service.binding_constraint_manager.get_binding_constraint(study, filters) @bp.get( "/studies/{uuid}/bindingconstraints/{binding_constraint_id}", tags=[APITag.study_data], summary="Get binding constraint", - response_model=None, # Dict[str, bool], + response_model=ConstraintOutput, ) def get_binding_constraint( uuid: str, binding_constraint_id: str, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Any: + ) -> ConstraintOutput: logger.info( f"Fetching binding constraint {binding_constraint_id} for study {uuid}", extra={"user": current_user.id}, ) params = RequestParameters(user=current_user) study = study_service.check_study_access(uuid, StudyPermissionType.READ, params) - bc_filter = BindingConstraintFilter(bc_id=binding_constraint_id) - return study_service.binding_constraint_manager.get_binding_constraint(study, bc_filter) + filters = ConstraintFilters(bc_id=binding_constraint_id) + return study_service.binding_constraint_manager.get_binding_constraint(study, filters) @bp.put( "/studies/{uuid}/bindingconstraints/{binding_constraint_id}", @@ -981,9 +981,9 @@ def get_binding_constraint( def update_binding_constraint( uuid: str, binding_constraint_id: str, - data: Union[BCKeyValueType, BindingConstraintEdition], + data: Union[BCKeyValueType, ConstraintInput], current_user: JWTUser = Depends(auth.get_current_user), - ) -> BindingConstraintConfigType: + ) -> ConstraintOutput: logger.info( f"Update binding constraint {binding_constraint_id} for study {uuid}", extra={"user": current_user.id}, @@ -994,13 +994,13 @@ def update_binding_constraint( if isinstance(data, dict): warnings.warn( "Using key / value format for binding constraint data is deprecated." - " Please use the BindingConstraintEdition format instead.", + " Please use the ConstraintInput format instead.", DeprecationWarning, ) _obj = {data["key"]: data["value"]} if "filterByYear" in _obj: _obj["filterYearByYear"] = _obj.pop("filterByYear") - data = BindingConstraintEdition(**_obj) + data = ConstraintInput(**_obj) return study_service.binding_constraint_manager.update_binding_constraint(study, binding_constraint_id, data) @@ -1009,10 +1009,10 @@ def update_binding_constraint( tags=[APITag.study_data], summary="Get the list of binding constraint groups", ) - def get_binding_constraint_groups( + def get_grouped_constraints( uuid: str, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Mapping[str, Sequence[BindingConstraintConfigType]]: + ) -> Mapping[str, Sequence[ConstraintOutput]]: """ Get the list of binding constraint groups for the study. @@ -1028,7 +1028,7 @@ def get_binding_constraint_groups( ) params = RequestParameters(user=current_user) study = study_service.check_study_access(uuid, StudyPermissionType.READ, params) - result = study_service.binding_constraint_manager.get_binding_constraint_groups(study) + result = study_service.binding_constraint_manager.get_grouped_constraints(study) return result @bp.get( @@ -1038,7 +1038,7 @@ def get_binding_constraint_groups( summary="Validate all binding constraint groups", response_model=None, ) - def validate_binding_constraint_groups( + def validate_constraint_groups( uuid: str, current_user: JWTUser = Depends(auth.get_current_user), ) -> bool: @@ -1061,18 +1061,18 @@ def validate_binding_constraint_groups( ) params = RequestParameters(user=current_user) study = study_service.check_study_access(uuid, StudyPermissionType.READ, params) - return study_service.binding_constraint_manager.validate_binding_constraint_groups(study) + return study_service.binding_constraint_manager.validate_constraint_groups(study) @bp.get( "/studies/{uuid}/constraint-groups/{group}", tags=[APITag.study_data], summary="Get the binding constraint group", ) - def get_binding_constraint_group( + def get_constraints_by_group( uuid: str, group: str, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Sequence[BindingConstraintConfigType]: + ) -> Sequence[ConstraintOutput]: """ Get the binding constraint group for the study. @@ -1092,7 +1092,7 @@ def get_binding_constraint_group( ) params = RequestParameters(user=current_user) study = study_service.check_study_access(uuid, StudyPermissionType.READ, params) - result = study_service.binding_constraint_manager.get_binding_constraint_group(study, group) + result = study_service.binding_constraint_manager.get_constraints_by_group(study, group) return result @bp.get( @@ -1101,7 +1101,7 @@ def get_binding_constraint_group( summary="Validate the binding constraint group", response_model=None, ) - def validate_binding_constraint_group( + def validate_constraint_group( uuid: str, group: str, current_user: JWTUser = Depends(auth.get_current_user), @@ -1127,14 +1127,14 @@ def validate_binding_constraint_group( ) params = RequestParameters(user=current_user) study = study_service.check_study_access(uuid, StudyPermissionType.READ, params) - return study_service.binding_constraint_manager.validate_binding_constraint_group(study, group) + return study_service.binding_constraint_manager.validate_constraint_group(study, group) @bp.post("/studies/{uuid}/bindingconstraints", tags=[APITag.study_data], summary="Create a binding constraint") def create_binding_constraint( uuid: str, - data: BindingConstraintCreation, + data: ConstraintCreation, current_user: JWTUser = Depends(auth.get_current_user), - ) -> BindingConstraintConfigType: + ) -> ConstraintOutput: logger.info( f"Creating a new binding constraint for study {uuid}", extra={"user": current_user.id}, @@ -1168,7 +1168,7 @@ def delete_binding_constraint( def add_constraint_term( uuid: str, binding_constraint_id: str, - term: ConstraintTermDTO, + term: ConstraintTerm, current_user: JWTUser = Depends(auth.get_current_user), ) -> Any: logger.info( @@ -1177,7 +1177,7 @@ def add_constraint_term( ) params = RequestParameters(user=current_user) study = study_service.check_study_access(uuid, StudyPermissionType.WRITE, params) - return study_service.binding_constraint_manager.add_new_constraint_term(study, binding_constraint_id, term) + return study_service.binding_constraint_manager.create_constraint_term(study, binding_constraint_id, term) @bp.put( "/studies/{uuid}/bindingconstraints/{binding_constraint_id}/term", @@ -1187,7 +1187,7 @@ def add_constraint_term( def update_constraint_term( uuid: str, binding_constraint_id: str, - term: ConstraintTermDTO, + term: ConstraintTerm, current_user: JWTUser = Depends(auth.get_current_user), ) -> Any: logger.info( diff --git a/tests/integration/study_data_blueprint/test_binding_constraints.py b/tests/integration/study_data_blueprint/test_binding_constraints.py index 3cf4a921cd..c60cb30309 100644 --- a/tests/integration/study_data_blueprint/test_binding_constraints.py +++ b/tests/integration/study_data_blueprint/test_binding_constraints.py @@ -2,10 +2,10 @@ import pytest from starlette.testclient import TestClient -from antarest.study.business.binding_constraint_management import AreaClusterDTO, AreaLinkDTO, ConstraintTermDTO +from antarest.study.business.binding_constraint_management import ClusterTerm, LinkTerm, ConstraintTerm -class TestAreaLinkDTO: +class TestLinkTerm: @pytest.mark.parametrize( "area1, area2, expected", [ @@ -16,11 +16,11 @@ class TestAreaLinkDTO: ], ) def test_constraint_id(self, area1: str, area2: str, expected: str) -> None: - info = AreaLinkDTO(area1=area1, area2=area2) + info = LinkTerm(area1=area1, area2=area2) assert info.generate_id() == expected -class TestAreaClusterDTO: +class TestClusterTerm: @pytest.mark.parametrize( "area, cluster, expected", [ @@ -30,31 +30,31 @@ class TestAreaClusterDTO: ], ) def test_constraint_id(self, area: str, cluster: str, expected: str) -> None: - info = AreaClusterDTO(area=area, cluster=cluster) + info = ClusterTerm(area=area, cluster=cluster) assert info.generate_id() == expected -class TestConstraintTermDTO: +class TestConstraintTerm: def test_constraint_id__link(self): - term = ConstraintTermDTO( + term = ConstraintTerm( id="foo", weight=3.14, offset=123, - data=AreaLinkDTO(area1="Area 1", area2="Area 2"), + data=LinkTerm(area1="Area 1", area2="Area 2"), ) assert term.generate_id() == term.data.generate_id() def test_constraint_id__cluster(self): - term = ConstraintTermDTO( + term = ConstraintTerm( id="foo", weight=3.14, offset=123, - data=AreaClusterDTO(area="Area 1", cluster="Cluster X"), + data=ClusterTerm(area="Area 1", cluster="Cluster X"), ) assert term.generate_id() == term.data.generate_id() def test_constraint_id__other(self): - term = ConstraintTermDTO( + term = ConstraintTerm( id="foo", weight=3.14, offset=123, From 3932fbd8bf97028b7059f5038d24e230db32f205 Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Thu, 28 Mar 2024 08:10:52 +0100 Subject: [PATCH 091/248] fix(bc): correct imports linting --- antarest/study/web/study_data_blueprint.py | 4 ++-- .../study_data_blueprint/test_binding_constraints.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index 8b3e2fe2c8..2e98149410 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -43,10 +43,10 @@ ThermalManager, ) from antarest.study.business.binding_constraint_management import ( - ConstraintOutput, ConstraintCreation, - ConstraintInput, ConstraintFilters, + ConstraintInput, + ConstraintOutput, ConstraintTerm, ) from antarest.study.business.correlation_management import CorrelationFormFields, CorrelationManager, CorrelationMatrix diff --git a/tests/integration/study_data_blueprint/test_binding_constraints.py b/tests/integration/study_data_blueprint/test_binding_constraints.py index c60cb30309..69cc5ac0d2 100644 --- a/tests/integration/study_data_blueprint/test_binding_constraints.py +++ b/tests/integration/study_data_blueprint/test_binding_constraints.py @@ -2,7 +2,7 @@ import pytest from starlette.testclient import TestClient -from antarest.study.business.binding_constraint_management import ClusterTerm, LinkTerm, ConstraintTerm +from antarest.study.business.binding_constraint_management import ClusterTerm, ConstraintTerm, LinkTerm class TestLinkTerm: From a91c8bb60f2b420d84d96fb6df4ef54020326952 Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Thu, 28 Mar 2024 09:58:32 +0100 Subject: [PATCH 092/248] test(bc): use `terms` in constraints tests --- .../test_binding_constraints.py | 38 +++++++++---------- tests/integration/test_integration.py | 4 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/tests/integration/study_data_blueprint/test_binding_constraints.py b/tests/integration/study_data_blueprint/test_binding_constraints.py index 69cc5ac0d2..5d6e596e68 100644 --- a/tests/integration/study_data_blueprint/test_binding_constraints.py +++ b/tests/integration/study_data_blueprint/test_binding_constraints.py @@ -35,7 +35,7 @@ def test_constraint_id(self, area: str, cluster: str, expected: str) -> None: class TestConstraintTerm: - def test_constraint_id__link(self): + def test_constraint_id__link(self) -> bool: term = ConstraintTerm( id="foo", weight=3.14, @@ -204,7 +204,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st "enabled": True, "timeStep": "hourly", "operator": "less", - "coeffs": {}, + "terms": {}, "comments": "New API", }, headers=user_headers, @@ -222,7 +222,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st expected = [ { "comments": "", - "constraints": [], # should be renamed to `terms` in the future. + "terms": [], "enabled": True, "filterSynthesis": "", "filterYearByYear": "", @@ -233,7 +233,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st }, { "comments": "", - "constraints": [], # should be renamed to `terms` in the future. + "terms": [], "enabled": True, "filterSynthesis": "", "filterYearByYear": "", @@ -244,7 +244,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st }, { "comments": "New API", - "constraints": [], # should be renamed to `terms` in the future. + "terms": [], "enabled": True, "filterSynthesis": "", "filterYearByYear": "", @@ -301,7 +301,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st ) assert res.status_code == 200, res.json() binding_constraint = res.json() - constraint_terms = binding_constraint["constraints"] # should be renamed to `terms` in the future. + constraint_terms = binding_constraint["terms"] expected = [ { "data": {"area1": area1_id, "area2": area2_id}, @@ -336,7 +336,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st ) assert res.status_code == 200, res.json() binding_constraint = res.json() - constraint_terms = binding_constraint["constraints"] # should be renamed to `terms` in the future. + constraint_terms = binding_constraint["terms"] expected = [ { "data": {"area1": area1_id, "area2": area2_id}, @@ -426,7 +426,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st # Check that the matrix is a daily/weekly matrix res = client.get( f"/v1/studies/{study_id}/raw", - params={"path": f"input/bindingconstraints/{bc_id}", "depth": 1, "formatted": True}, + params={"path": f"input/bindingconstraints/{bc_id}", "depth": 1, "formatted": True}, #type: ignore headers=user_headers, ) assert res.status_code == 200, res.json() @@ -446,7 +446,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st "enabled": True, "timeStep": "hourly", "operator": "less", - "coeffs": {}, + "terms": {}, "comments": "New API", }, headers=user_headers, @@ -465,7 +465,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st "enabled": True, "timeStep": "hourly", "operator": "less", - "coeffs": {}, + "terms": {}, "comments": "New API", }, headers=user_headers, @@ -484,7 +484,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st "enabled": True, "timeStep": "hourly", "operator": "less", - "coeffs": {}, + "terms": {}, "comments": "", }, headers=user_headers, @@ -499,7 +499,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st "enabled": True, "timeStep": "hourly", "operator": "less", - "coeffs": {}, + "terms": {}, "comments": "2 types of matrices", "values": [[]], "less_term_matrix": [[]], @@ -521,7 +521,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st "enabled": True, "timeStep": "hourly", "operator": "less", - "coeffs": {}, + "terms": {}, "comments": "Incoherent matrix with version", "less_term_matrix": [[]], }, @@ -538,7 +538,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st "enabled": True, "timeStep": "daily", "operator": "less", - "coeffs": {}, + "terms": {}, "comments": "Creation with matrix", "values": wrong_matrix.tolist(), } @@ -616,7 +616,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud # Creation of a bc without group bc_id_wo_group = "binding_constraint_1" - args = {"enabled": True, "timeStep": "hourly", "operator": "less", "coeffs": {}, "comments": "New API"} + 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}, @@ -657,7 +657,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud for term in ["lt", "gt", "eq"]: res = client.get( f"/v1/studies/{study_id}/raw", - params={"path": f"input/bindingconstraints/{bc_id_w_matrix}_{term}", "depth": 1, "formatted": True}, + params={"path": f"input/bindingconstraints/{bc_id_w_matrix}_{term}", "depth": 1, "formatted": True}, #type: ignore headers=admin_headers, ) assert res.status_code == 200 @@ -729,7 +729,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud "path": f"input/bindingconstraints/{bc_id_w_matrix}_{term_alias}", "depth": 1, "formatted": True, - }, + }, # type: ignore headers=admin_headers, ) assert res.status_code == 200 @@ -759,7 +759,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud "enabled": True, "timeStep": "hourly", "operator": "less", - "coeffs": {}, + "terms": {}, "comments": "New API", "values": [[]], }, @@ -932,7 +932,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud assert groups["Group 2"] == [ { "comments": "New API", - "constraints": [], + "terms": [], "enabled": True, "filterSynthesis": "", "filterYearByYear": "", diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index c99b8c9e0e..55607108cb 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -523,7 +523,7 @@ def test_area_management(client: TestClient, admin_access_token: str, study_id: "enabled": True, "time_step": BindingConstraintFrequency.HOURLY.value, "operator": BindingConstraintOperator.LESS.value, - "coeffs": {"area 1.cluster 1": [2.0, 4]}, + "terms": {"area 1.cluster 1": [2.0, 4]}, }, } ], @@ -541,7 +541,7 @@ def test_area_management(client: TestClient, admin_access_token: str, study_id: "enabled": True, "time_step": BindingConstraintFrequency.HOURLY.value, "operator": BindingConstraintOperator.LESS.value, - "coeffs": {}, + "terms": {}, }, } ], From 86efc5ffc51ab0fdcda013889eb6226aed99809b Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Thu, 28 Mar 2024 18:35:34 +0100 Subject: [PATCH 093/248] fix(bc): correct typing in constraints service --- .../business/binding_constraint_management.py | 10 +++--- .../study/business/table_mode_management.py | 2 +- antarest/study/web/study_data_blueprint.py | 6 ++-- .../test_binding_constraints.py | 34 +++++++++---------- tests/integration/test_integration.py | 4 +-- 5 files changed, 28 insertions(+), 28 deletions(-) diff --git a/antarest/study/business/binding_constraint_management.py b/antarest/study/business/binding_constraint_management.py index 181fbb5c07..cc1dea1105 100644 --- a/antarest/study/business/binding_constraint_management.py +++ b/antarest/study/business/binding_constraint_management.py @@ -391,7 +391,7 @@ def parse_constraint(key: str, value: str, char: str, new_config: ConstraintOutp return False @staticmethod - def constraint_model_adapter(constraint: ConstraintInput, version: int) -> ConstraintOutput: + def constraint_model_adapter(constraint: Mapping[str, Any], version: int) -> ConstraintOutput: """ Adapts a constraint configuration to the appropriate version-specific format. @@ -479,10 +479,10 @@ def get_binding_constraint( # If a specific constraint ID is provided, we return that constraint if filters.bc_id: - return filtered_constraints.get(filters.bc_id) + return filtered_constraints.get(filters.bc_id) # type: ignore # Else we return all the matching constraints, based on the given filters - return list(filtered_constraints.values()) + return list(filtered_constraints.values()) def get_grouped_constraints(self, study: Study) -> Mapping[str, Sequence[ConstraintOutput]]: """ @@ -677,7 +677,7 @@ def update_binding_constraint( } if study_version >= 870: - updated_constraint["group"] = data.group or existing_constraint.group + updated_constraint["group"] = data.group or existing_constraint.group # type: ignore args = { **updated_constraint, @@ -836,7 +836,7 @@ def remove_constraint_term( binding_constraint_id: str, term_id: str, ) -> None: - return self.update_constraint_term(study, binding_constraint_id, term_id) + return self.update_constraint_term(study, binding_constraint_id, term_id) # type: ignore def _replace_matrices_according_to_frequency_and_version( diff --git a/antarest/study/business/table_mode_management.py b/antarest/study/business/table_mode_management.py index 45472d63b5..8a83c21047 100644 --- a/antarest/study/business/table_mode_management.py +++ b/antarest/study/business/table_mode_management.py @@ -492,7 +492,7 @@ def set_table_data( enabled=col_values.get("enabled", current_binding_dto.enabled), time_step=col_values.get("type", current_binding_dto.time_step), operator=col_values.get("operator", current_binding_dto.operator), - coeffs=BindingConstraintManager.constraints_to_coeffs(current_binding_dto), + coeffs=BindingConstraintManager.terms_to_coeffs(current_binding_dto.terms), command_context=command_context, ) ) diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index 2e98149410..e8c068d13a 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -899,7 +899,7 @@ def update_version( "/studies/{uuid}/bindingconstraints", tags=[APITag.study_data], summary="Get binding constraint list", - response_model=Union[ConstraintOutput, List[ConstraintOutput]], + response_model=Union[ConstraintOutput, List[ConstraintOutput]], # type: ignore ) def get_binding_constraint_list( uuid: str, @@ -957,13 +957,13 @@ def get_binding_constraint_list( "/studies/{uuid}/bindingconstraints/{binding_constraint_id}", tags=[APITag.study_data], summary="Get binding constraint", - response_model=ConstraintOutput, + response_model=ConstraintOutput, # type: ignore ) def get_binding_constraint( uuid: str, binding_constraint_id: str, current_user: JWTUser = Depends(auth.get_current_user), - ) -> ConstraintOutput: + ) -> Union[ConstraintOutput, List[ConstraintOutput]]: logger.info( f"Fetching binding constraint {binding_constraint_id} for study {uuid}", extra={"user": current_user.id}, diff --git a/tests/integration/study_data_blueprint/test_binding_constraints.py b/tests/integration/study_data_blueprint/test_binding_constraints.py index 5d6e596e68..32f47e8805 100644 --- a/tests/integration/study_data_blueprint/test_binding_constraints.py +++ b/tests/integration/study_data_blueprint/test_binding_constraints.py @@ -204,7 +204,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st "enabled": True, "timeStep": "hourly", "operator": "less", - "terms": {}, + "terms": [], "comments": "New API", }, headers=user_headers, @@ -222,7 +222,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st expected = [ { "comments": "", - "terms": [], + "terms": [], "enabled": True, "filterSynthesis": "", "filterYearByYear": "", @@ -233,7 +233,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st }, { "comments": "", - "terms": [], + "terms": [], "enabled": True, "filterSynthesis": "", "filterYearByYear": "", @@ -244,7 +244,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st }, { "comments": "New API", - "terms": [], + "terms": [], "enabled": True, "filterSynthesis": "", "filterYearByYear": "", @@ -301,7 +301,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st ) assert res.status_code == 200, res.json() binding_constraint = res.json() - constraint_terms = binding_constraint["terms"] + constraint_terms = binding_constraint["terms"] expected = [ { "data": {"area1": area1_id, "area2": area2_id}, @@ -336,7 +336,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st ) assert res.status_code == 200, res.json() binding_constraint = res.json() - constraint_terms = binding_constraint["terms"] + constraint_terms = binding_constraint["terms"] expected = [ { "data": {"area1": area1_id, "area2": area2_id}, @@ -426,7 +426,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st # Check that the matrix is a daily/weekly matrix res = client.get( f"/v1/studies/{study_id}/raw", - params={"path": f"input/bindingconstraints/{bc_id}", "depth": 1, "formatted": True}, #type: ignore + params={"path": f"input/bindingconstraints/{bc_id}", "depth": 1, "formatted": True}, # type: ignore headers=user_headers, ) assert res.status_code == 200, res.json() @@ -446,7 +446,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st "enabled": True, "timeStep": "hourly", "operator": "less", - "terms": {}, + "terms": [], "comments": "New API", }, headers=user_headers, @@ -465,7 +465,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st "enabled": True, "timeStep": "hourly", "operator": "less", - "terms": {}, + "terms": [], "comments": "New API", }, headers=user_headers, @@ -484,7 +484,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st "enabled": True, "timeStep": "hourly", "operator": "less", - "terms": {}, + "terms": [], "comments": "", }, headers=user_headers, @@ -499,7 +499,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st "enabled": True, "timeStep": "hourly", "operator": "less", - "terms": {}, + "terms": [], "comments": "2 types of matrices", "values": [[]], "less_term_matrix": [[]], @@ -521,7 +521,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st "enabled": True, "timeStep": "hourly", "operator": "less", - "terms": {}, + "terms": [], "comments": "Incoherent matrix with version", "less_term_matrix": [[]], }, @@ -538,7 +538,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st "enabled": True, "timeStep": "daily", "operator": "less", - "terms": {}, + "terms": [], "comments": "Creation with matrix", "values": wrong_matrix.tolist(), } @@ -616,7 +616,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud # Creation of a bc without group bc_id_wo_group = "binding_constraint_1" - args = {"enabled": True, "timeStep": "hourly", "operator": "less", "terms": {}, "comments": "New API"} + 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}, @@ -657,7 +657,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud for term in ["lt", "gt", "eq"]: res = client.get( f"/v1/studies/{study_id}/raw", - params={"path": f"input/bindingconstraints/{bc_id_w_matrix}_{term}", "depth": 1, "formatted": True}, #type: ignore + params={"path": f"input/bindingconstraints/{bc_id_w_matrix}_{term}", "depth": 1, "formatted": True}, # type: ignore headers=admin_headers, ) assert res.status_code == 200 @@ -729,7 +729,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud "path": f"input/bindingconstraints/{bc_id_w_matrix}_{term_alias}", "depth": 1, "formatted": True, - }, # type: ignore + }, # type: ignore headers=admin_headers, ) assert res.status_code == 200 @@ -759,7 +759,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud "enabled": True, "timeStep": "hourly", "operator": "less", - "terms": {}, + "terms": [], "comments": "New API", "values": [[]], }, diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index 55607108cb..c99b8c9e0e 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -523,7 +523,7 @@ def test_area_management(client: TestClient, admin_access_token: str, study_id: "enabled": True, "time_step": BindingConstraintFrequency.HOURLY.value, "operator": BindingConstraintOperator.LESS.value, - "terms": {"area 1.cluster 1": [2.0, 4]}, + "coeffs": {"area 1.cluster 1": [2.0, 4]}, }, } ], @@ -541,7 +541,7 @@ def test_area_management(client: TestClient, admin_access_token: str, study_id: "enabled": True, "time_step": BindingConstraintFrequency.HOURLY.value, "operator": BindingConstraintOperator.LESS.value, - "terms": {}, + "coeffs": {}, }, } ], From def92ca7b22f578e4c3c79d80d7f97c5a3799997 Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Thu, 28 Mar 2024 14:06:12 +0100 Subject: [PATCH 094/248] fix(bc): ensure constraint filters accurately restrict results based on area and cluster names --- .../business/binding_constraint_management.py | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/antarest/study/business/binding_constraint_management.py b/antarest/study/business/binding_constraint_management.py index cc1dea1105..b2fd6ff35c 100644 --- a/antarest/study/business/binding_constraint_management.py +++ b/antarest/study/business/binding_constraint_management.py @@ -178,36 +178,32 @@ def match_filters(self, constraint: "ConstraintOutput") -> bool: if self.time_step is not None and self.time_step != constraint.time_step: return False - # Filter on terms terms = constraint.terms or [] if self.area_name: matching_terms = [] - for term in terms: if term.data: if isinstance(term.data, LinkTerm): - # Check if either area in the link matches the specified area_name + # Check if either area in the link term matches the specified area_name. if self.area_name.upper() in (term.data.area1.upper(), term.data.area2.upper()): matching_terms.append(term) elif isinstance(term.data, ClusterTerm): - # Check if the cluster's area matches the specified area_name + # Check if the area matches the specified area_name for a cluster term. if self.area_name.upper() == term.data.area.upper(): matching_terms.append(term) - - # If no terms match, the constraint should not pass if not matching_terms: return False if self.cluster_name: - all_clusters = [] + matching_terms = [] for term in terms: if term.data is None: continue - if isinstance(term.data, ClusterTerm): - all_clusters.append(term.data.cluster) - upper_cluster_name = self.cluster_name.upper() - if all_clusters and not any(upper_cluster_name in cluster.upper() for cluster in all_clusters): + if term.data and isinstance(term.data, ClusterTerm): + if self.cluster_name.upper() == term.data.cluster.upper(): + matching_terms.append(term) + if not matching_terms: return False if self.link_id: @@ -448,14 +444,12 @@ def terms_to_coeffs(terms: Sequence[ConstraintTerm]) -> Dict[str, List[float]]: :return: A dictionary of term IDs mapped to a list of their coefficients. """ coeffs = {} - if terms is not None: for term in terms: if term.id and term.weight is not None: coeffs[term.id] = [term.weight] if term.offset is not None: coeffs[term.id].append(term.offset) - return coeffs def get_binding_constraint( From 4f4ddc8b4cb3339218e6b3f7859b1557d2a9a29c Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Thu, 28 Mar 2024 18:15:35 +0100 Subject: [PATCH 095/248] fix(bc): wrong key access for `filter_year_by_year` and `filter_synthesis` --- antarest/study/business/binding_constraint_management.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/antarest/study/business/binding_constraint_management.py b/antarest/study/business/binding_constraint_management.py index b2fd6ff35c..7c7fc99548 100644 --- a/antarest/study/business/binding_constraint_management.py +++ b/antarest/study/business/binding_constraint_management.py @@ -416,8 +416,8 @@ def constraint_model_adapter(constraint: Mapping[str, Any], version: int) -> Con "time_step": constraint.get("type", BindingConstraintFrequency.HOURLY), "operator": constraint.get("operator", BindingConstraintOperator.EQUAL), "comments": constraint.get("comments", ""), - "filter_year_by_year": constraint.get("filter_year_by_year", ""), - "filter_synthesis": constraint.get("filter_synthesis", ""), + "filter_year_by_year": constraint.get("filter_year_by_year") or constraint.get("filter-year-by-year"), + "filter_synthesis": constraint.get("filter_synthesis") or constraint.get("filter-synthesis"), "terms": constraint.get("terms", []), } @@ -473,10 +473,10 @@ def get_binding_constraint( # If a specific constraint ID is provided, we return that constraint if filters.bc_id: - return filtered_constraints.get(filters.bc_id) # type: ignore + return filtered_constraints.get(filters.bc_id) # type: ignore # Else we return all the matching constraints, based on the given filters - return list(filtered_constraints.values()) + return list(filtered_constraints.values()) def get_grouped_constraints(self, study: Study) -> Mapping[str, Sequence[ConstraintOutput]]: """ From b323f6868ee5e5c3417ed87514532982939614cb Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Fri, 29 Mar 2024 10:55:55 +0100 Subject: [PATCH 096/248] fix(bc): add `terms` to thermal tests --- tests/integration/study_data_blueprint/test_thermal.py | 10 +++++++++- tests/integration/test_integration.py | 8 ++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/integration/study_data_blueprint/test_thermal.py b/tests/integration/study_data_blueprint/test_thermal.py index 50b17600ab..3297a1fba8 100644 --- a/tests/integration/study_data_blueprint/test_thermal.py +++ b/tests/integration/study_data_blueprint/test_thermal.py @@ -27,6 +27,7 @@ * delete a cluster (or several clusters) * validate the consistency of the matrices (and properties) """ + import json import re import typing as t @@ -536,7 +537,14 @@ def test_lifecycle( "enabled": True, "time_step": "hourly", "operator": "less", - "coeffs": {f"{area_id}.{fr_gas_conventional_id.lower()}": [2.0, 4]}, + "terms": [ + { + "id": f"{area_id}.{fr_gas_conventional_id.lower()}", + "weight": 2, + "offset": 5, + "data": {"area": area_id, "cluster": fr_gas_conventional_id.lower()}, + } + ], "comments": "New API", } matrix = np.random.randint(0, 1000, size=(8784, 3)) diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index c99b8c9e0e..3dc5391804 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -1706,10 +1706,10 @@ def test_area_management(client: TestClient, admin_access_token: str, study_id: binding_constraint_1 = res.json() assert res.status_code == 200 - constraint = binding_constraint_1["constraints"][0] # should be renamed to `terms` in the future. - assert constraint["id"] == "area 1.cluster 1" - assert constraint["weight"] == 2.0 - assert constraint["offset"] == 4.0 + term = binding_constraint_1["terms"][0] + assert term["id"] == "area 1.cluster 1" + assert term["weight"] == 2.0 + assert term["offset"] == 4.0 # --- TableMode END --- From be087870ec1d63b2d5e506e7e971c0a701431601 Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Fri, 29 Mar 2024 11:05:37 +0100 Subject: [PATCH 097/248] refactor(bc): handle versioning for output filters and optimize model adapter --- .../business/binding_constraint_management.py | 93 ++++++++++--------- 1 file changed, 49 insertions(+), 44 deletions(-) diff --git a/antarest/study/business/binding_constraint_management.py b/antarest/study/business/binding_constraint_management.py index 7c7fc99548..ec172678e2 100644 --- a/antarest/study/business/binding_constraint_management.py +++ b/antarest/study/business/binding_constraint_management.py @@ -1,7 +1,7 @@ import collections import itertools import logging -from typing import Any, Dict, List, Mapping, MutableSequence, Optional, Sequence, Union +from typing import Any, Dict, List, Mapping, MutableSequence, Optional, Sequence, Type, Union import numpy as np from pydantic import BaseModel, Field, root_validator, validator @@ -351,40 +351,35 @@ def __init__( self.storage_service = storage_service @staticmethod - def parse_constraint(key: str, value: str, char: str, new_config: ConstraintOutput) -> bool: - split = key.split(char) - if len(split) == 2: - value1 = split[0] - value2 = split[1] - weight = 0.0 - offset = None - try: - weight = float(value) - except ValueError: - weight_and_offset = value.split("%") - if len(weight_and_offset) == 2: - weight = float(weight_and_offset[0]) - offset = float(weight_and_offset[1]) - if new_config.terms is None: - new_config.terms = [] - new_config.terms.append( - ConstraintTerm( - id=key, - weight=weight, - offset=offset if offset is not None else None, - data=LinkTerm( - area1=value1, - area2=value2, + def parse_and_add_terms( + key: str, value: Any, adapted_constraint: Union[ConstraintOutputBase, ConstraintOutput870] + ) -> None: + """Parse a single term from the constraint dictionary and add it to the adapted_constraint model.""" + if "%" in key or "." in key: + separator = "%" if "%" in key else "." + term_data = key.split(separator) + weight, offset = (value, None) if isinstance(value, (float, int)) else map(float, value.split("%")) + + if separator == "%": + # Link term + adapted_constraint.terms.append( + ConstraintTerm( + id=key, + weight=weight, + offset=offset, + data={ + "area1": term_data[0], + "area2": term_data[1], + }, + ) + ) + # Cluster term + else: + adapted_constraint.terms.append( + ConstraintTerm( + id=key, weight=weight, offset=offset, data={"area": term_data[0], "cluster": term_data[1]} ) - if char == "%" - else ClusterTerm( - area=value1, - cluster=value2, - ), ) - ) - return True - return False @staticmethod def constraint_model_adapter(constraint: Mapping[str, Any], version: int) -> ConstraintOutput: @@ -409,6 +404,10 @@ def constraint_model_adapter(constraint: Mapping[str, Any], version: int) -> Con ensures data integrity when storing or retrieving constraint configurations from the database. """ + ConstraintModel: Type[Union[ConstraintOutputBase, ConstraintOutput870]] = ( + ConstraintOutput870 if version >= 870 else ConstraintOutputBase + ) + constraint_output = { "id": constraint["id"], "name": constraint["name"], @@ -416,22 +415,28 @@ def constraint_model_adapter(constraint: Mapping[str, Any], version: int) -> Con "time_step": constraint.get("type", BindingConstraintFrequency.HOURLY), "operator": constraint.get("operator", BindingConstraintOperator.EQUAL), "comments": constraint.get("comments", ""), - "filter_year_by_year": constraint.get("filter_year_by_year") or constraint.get("filter-year-by-year"), - "filter_synthesis": constraint.get("filter_synthesis") or constraint.get("filter-synthesis"), "terms": constraint.get("terms", []), } - if version < 870: - adapted_constraint = ConstraintOutputBase(**constraint_output) - else: + if version >= 840: + constraint_output["filter_year_by_year"] = constraint.get("filter_year_by_year", "") or constraint.get( + "filter-year-by-year", "" + ) + constraint_output["filter_synthesis"] = constraint.get("filter_synthesis", "") or constraint.get( + "filter-synthesis", "" + ) + + if version >= 870: constraint_output["group"] = constraint.get("group", DEFAULT_GROUP) - adapted_constraint = ConstraintOutput870(**constraint_output) - for key, value in constraint.items(): - if BindingConstraintManager.parse_constraint(key, value, "%", adapted_constraint): - continue - if BindingConstraintManager.parse_constraint(key, value, ".", adapted_constraint): - continue + adapted_constraint = ConstraintModel(**constraint_output) + + # If 'terms' were not directly provided in the input, parse and add terms dynamically + if not constraint.get("terms"): + for key, value in constraint.items(): + if key not in constraint_output: # Avoid re-processing keys already included + BindingConstraintManager.parse_and_add_terms(key, value, adapted_constraint) + return adapted_constraint @staticmethod From 7cb21d142d3d2530881d0271979e33097ff4635d Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 29 Mar 2024 15:40:00 +0100 Subject: [PATCH 098/248] fix(api-bc): correct `area_name` and `cluster_name` filtering --- .../business/binding_constraint_management.py | 54 ++--- .../test_binding_constraint_management.py | 204 ++++++++++++++++++ 2 files changed, 234 insertions(+), 24 deletions(-) create mode 100644 tests/study/business/test_binding_constraint_management.py diff --git a/antarest/study/business/binding_constraint_management.py b/antarest/study/business/binding_constraint_management.py index ec172678e2..d59cfaaf3e 100644 --- a/antarest/study/business/binding_constraint_management.py +++ b/antarest/study/business/binding_constraint_management.py @@ -162,16 +162,19 @@ def match_filters(self, constraint: "ConstraintOutput") -> bool: True if the constraint matches the filters, False otherwise """ if self.bc_id and self.bc_id != constraint.id: + # The `bc_id` filter is a case-sensitive exact match. return False if self.enabled is not None and self.enabled != constraint.enabled: return False if self.operator is not None and self.operator != constraint.operator: return False if self.comments: + # The `comments` filter is a case-insensitive substring match. comments = constraint.comments or "" if self.comments.upper() not in comments.upper(): return False if self.group: + # The `group` filter is a case-insensitive exact match. group = getattr(constraint, "group", DEFAULT_GROUP) if self.group.upper() != group.upper(): return False @@ -181,39 +184,41 @@ def match_filters(self, constraint: "ConstraintOutput") -> bool: terms = constraint.terms or [] if self.area_name: - matching_terms = [] - for term in terms: - if term.data: - if isinstance(term.data, LinkTerm): - # Check if either area in the link term matches the specified area_name. - if self.area_name.upper() in (term.data.area1.upper(), term.data.area2.upper()): - matching_terms.append(term) - elif isinstance(term.data, ClusterTerm): - # Check if the area matches the specified area_name for a cluster term. - if self.area_name.upper() == term.data.area.upper(): - matching_terms.append(term) - if not matching_terms: + # The `area_name` filter is a case-insensitive substring match. + area_name_upper = self.area_name.upper() + for data in (term.data for term in terms if term.data): + # fmt: off + if ( + isinstance(data, LinkTerm) + and (area_name_upper in data.area1.upper() or area_name_upper in data.area2.upper()) + ) or ( + isinstance(data, ClusterTerm) + and area_name_upper in data.area.upper() + ): + break + # fmt: on + else: return False if self.cluster_name: - matching_terms = [] - for term in terms: - if term.data is None: - continue - if term.data and isinstance(term.data, ClusterTerm): - if self.cluster_name.upper() == term.data.cluster.upper(): - matching_terms.append(term) - if not matching_terms: + # The `cluster_name` filter is a case-insensitive substring match. + cluster_name_upper = self.cluster_name.upper() + for data in (term.data for term in terms if term.data): + if isinstance(data, ClusterTerm) and cluster_name_upper in data.cluster.upper(): + break + else: return False if self.link_id: + # The `link_id` filter is a case-insensitive exact match. all_link_ids = [term.data.generate_id() for term in terms if isinstance(term.data, LinkTerm)] - if not any(self.link_id.lower() == link_id.lower() for link_id in all_link_ids): + if self.link_id.lower() not in all_link_ids: return False if self.cluster_id: + # The `cluster_id` filter is a case-insensitive exact match. all_cluster_ids = [term.data.generate_id() for term in terms if isinstance(term.data, ClusterTerm)] - if not any(self.cluster_id.lower() == cluster_id.lower() for cluster_id in all_cluster_ids): + if self.cluster_id.lower() not in all_cluster_ids: return False return True @@ -301,7 +306,7 @@ class ConstraintOutputBase(BindingConstraintProperties): @camel_case_model class ConstraintOutput870(ConstraintOutputBase): - group: str + group: str = DEFAULT_GROUP ConstraintOutput = Union[ConstraintOutputBase, ConstraintOutput870] @@ -466,7 +471,7 @@ def get_binding_constraint( file_study = storage_service.get_raw(study) config = file_study.tree.get(["input", "bindingconstraints", "bindingconstraints"]) - # TODO: if a single constraint ID is passed, and don't exist in the config raise an execption + # TODO: if a single constraint ID is passed, and don't exist in the config raise an execption => 404 constraints_by_id: Dict[str, ConstraintOutput] = {} @@ -478,6 +483,7 @@ def get_binding_constraint( # If a specific constraint ID is provided, we return that constraint if filters.bc_id: + # return filtered_constraints.get(filters.bc_id) # type: ignore # Else we return all the matching constraints, based on the given filters diff --git a/tests/study/business/test_binding_constraint_management.py b/tests/study/business/test_binding_constraint_management.py new file mode 100644 index 0000000000..17bf1628ec --- /dev/null +++ b/tests/study/business/test_binding_constraint_management.py @@ -0,0 +1,204 @@ +import typing as t + +import pytest + +from antarest.study.business.binding_constraint_management import ( + ClusterTerm, + ConstraintFilters, + ConstraintOutput, + ConstraintOutput870, + ConstraintOutputBase, + ConstraintTerm, + LinkTerm, +) + + +class TestConstraintFilter: + def test_init(self) -> None: + bc_filter = ConstraintFilters() + assert not bc_filter.bc_id + assert not bc_filter.enabled + assert not bc_filter.operator + assert not bc_filter.comments + assert not bc_filter.group + assert not bc_filter.time_step + assert not bc_filter.area_name + assert not bc_filter.cluster_name + assert not bc_filter.link_id + assert not bc_filter.cluster_id + + @pytest.mark.parametrize("bc_id, expected", [("bc1", True), ("BC1", False), ("bc2", False), ("", True)]) + @pytest.mark.parametrize("cls", [ConstraintOutputBase, ConstraintOutput870]) + def test_filter_by__bc_id(self, bc_id: str, expected: bool, cls: t.Type[ConstraintOutput]) -> None: + """ + The filter should match if the `bc_id` is equal to the constraint's `bc_id` or if the filter is empty. + Comparisons should be case-sensitive. + """ + bc_filter = ConstraintFilters(bc_id=bc_id) + constraint = cls(id="bc1", name="BC1") + assert bc_filter.match_filters(constraint) == expected, "" + + @pytest.mark.parametrize("enabled, expected", [(True, True), (False, False), (None, True)]) + @pytest.mark.parametrize("cls", [ConstraintOutputBase, ConstraintOutput870]) + def test_filter_by__enabled(self, enabled: t.Optional[bool], expected: bool, cls: t.Type[ConstraintOutput]) -> None: + """ + The filter should match if the `enabled` is equal to the constraint's `enabled` or if the filter is empty. + """ + bc_filter = ConstraintFilters(enabled=enabled) + constraint = cls(id="bc1", name="BC1") + assert bc_filter.match_filters(constraint) == expected + + @pytest.mark.parametrize("operator, expected", [("equal", True), ("both", False), (None, True)]) + @pytest.mark.parametrize("cls", [ConstraintOutputBase, ConstraintOutput870]) + def test_filter_by__operator(self, operator: str, expected: bool, cls: t.Type[ConstraintOutput]) -> None: + """ + The filter should match if the `operator` is equal to the constraint's `operator` or if the filter is empty. + """ + bc_filter = ConstraintFilters(operator=operator) + constraint = cls(id="bc1", name="BC1", operator="equal") + assert bc_filter.match_filters(constraint) == expected + + @pytest.mark.parametrize("comments, expected", [("hello", True), ("HELLO", True), ("goodbye", False), ("", True)]) + @pytest.mark.parametrize("cls", [ConstraintOutputBase, ConstraintOutput870]) + def test_filter_by__comments(self, comments: str, expected: bool, cls: t.Type[ConstraintOutput]) -> None: + """ + The filter should match if the constraint's `comments` contains the filter's `comments` or if the filter is empty. + Comparisons should be case-insensitive. + """ + bc_filter = ConstraintFilters(comments=comments) + constraint = cls(id="bc1", name="BC1", comments="Say hello!") + assert bc_filter.match_filters(constraint) == expected + + @pytest.mark.parametrize("group, expected", [("grp1", False), ("grp2", False), ("", True)]) + @pytest.mark.parametrize("cls", [ConstraintOutput]) + def test_filter_by__group(self, group: str, expected: bool, cls: t.Type[ConstraintOutput]) -> None: + """ + The filter should never match if the filter's `group` is not empty. + """ + bc_filter = ConstraintFilters(group=group) + constraint = cls(id="bc1", name="BC1") + assert bc_filter.match_filters(constraint) == expected + + @pytest.mark.parametrize("group, expected", [("grp1", True), ("GRP1", True), ("grp2", False), ("", True)]) + @pytest.mark.parametrize("cls", [ConstraintOutput870]) + def test_filter_by__group(self, group: str, expected: bool, cls: t.Type[ConstraintOutput870]) -> None: + """ + The filter should match if the `group` is equal to the constraint's `group` or if the filter is empty. + Comparisons should be case-insensitive. + """ + bc_filter = ConstraintFilters(group=group) + constraint = cls(id="bc1", name="BC1", group="Grp1") + assert bc_filter.match_filters(constraint) == expected + + @pytest.mark.parametrize("time_step, expected", [("hourly", True), ("daily", False), (None, True)]) + @pytest.mark.parametrize("cls", [ConstraintOutputBase, ConstraintOutput870]) + def test_filter_by__time_step(self, time_step: str, expected: bool, cls: t.Type[ConstraintOutput]) -> None: + """ + The filter should match if the `time_step` is hourly to the constraint's `time_step` or if the filter is empty. + """ + bc_filter = ConstraintFilters(time_step=time_step) + constraint = cls(id="bc1", name="BC1", time_step="hourly") + assert bc_filter.match_filters(constraint) == expected + + @pytest.mark.parametrize( + "area_name, expected", + [("FR", True), ("fr", True), ("DE", True), ("IT", True), ("HU", True), ("EN", False), ("", True)], + ) + @pytest.mark.parametrize("cls", [ConstraintOutputBase, ConstraintOutput870]) + def test_filter_by__area_name(self, area_name: str, expected: bool, cls: t.Type[ConstraintOutput]) -> None: + """ + The filter should match if one of the constraint's terms has an area name which contains + the filter's area name or if the filter is empty. + Comparisons should be case-insensitive. + """ + bc_filter = ConstraintFilters(area_name=area_name) + constraint = cls(id="bc1", name="BC1") + constraint.terms.extend( + [ + ConstraintTerm(weight=2.0, offset=5, data=LinkTerm(area1="area_FR_x", area2="area_DE_x")), + ConstraintTerm(weight=3.0, offset=5, data=LinkTerm(area1="area_IT_y", area2="area_DE_y")), + ConstraintTerm(weight=2.0, offset=5, data=ClusterTerm(area="area_HU_z", cluster="area_CL1_z")), + ] + ) + assert bc_filter.match_filters(constraint) == expected + + @pytest.mark.parametrize( + "cluster_name, expected", + [("cl1", True), ("CL1", True), ("cl2", False), ("", True)], + ) + @pytest.mark.parametrize("cls", [ConstraintOutputBase, ConstraintOutput870]) + def test_filter_by__cluster_name(self, cluster_name: str, expected: bool, cls: t.Type[ConstraintOutput]) -> None: + """ + The filter should match if one of the constraint's terms has a cluster name which contains + the filter's cluster name or if the filter is empty. + Comparisons should be case-insensitive. + """ + bc_filter = ConstraintFilters(cluster_name=cluster_name) + constraint = cls(id="bc1", name="BC1") + constraint.terms.extend( + [ + ConstraintTerm(weight=2.0, offset=5, data=LinkTerm(area1="area_FR_x", area2="area_DE_x")), + ConstraintTerm(weight=3.0, offset=5, data=LinkTerm(area1="area_IT_y", area2="area_DE_y")), + ConstraintTerm(weight=2.0, offset=5, data=ClusterTerm(area="area_HU_z", cluster="area_CL1_z")), + ] + ) + assert bc_filter.match_filters(constraint) == expected + + @pytest.mark.parametrize( + "link_id, expected", + [ + ("area_DE_x%area_FR_x", True), + ("AREA_DE_X%area_FR_x", True), + ("area_DE_x%AREA_FR_X", True), + ("AREA_DE_X%AREA_FR_X", True), + ("area_FR_x%area_DE_x", False), + ("fr%de", False), + ("", True), + ], + ) + @pytest.mark.parametrize("cls", [ConstraintOutputBase, ConstraintOutput870]) + def test_filter_by__link_id(self, link_id: str, expected: bool, cls: t.Type[ConstraintOutput]) -> None: + """ + The filter should match if one of the constraint's terms has a cluster name which contains + the filter's cluster name or if the filter is empty. + Comparisons should be case-insensitive. + """ + bc_filter = ConstraintFilters(link_id=link_id) + constraint = cls(id="bc1", name="BC1") + constraint.terms.extend( + [ + ConstraintTerm(weight=2.0, offset=5, data=LinkTerm(area1="area_FR_x", area2="area_DE_x")), + ConstraintTerm(weight=3.0, offset=5, data=LinkTerm(area1="area_IT_y", area2="area_DE_y")), + ConstraintTerm(weight=2.0, offset=5, data=ClusterTerm(area="area_HU_z", cluster="area_CL1_z")), + ] + ) + assert bc_filter.match_filters(constraint) == expected + + @pytest.mark.parametrize( + "cluster_id, expected", + [ + ("area_HU_z.area_CL1_z", True), + ("AREA_HU_Z.area_CL1_z", True), + ("area_HU_z.AREA_CL1_Z", True), + ("AREA_HU_Z.AREA_CL1_Z", True), + ("HU.CL1", False), + ("", True), + ], + ) + @pytest.mark.parametrize("cls", [ConstraintOutputBase, ConstraintOutput870]) + def test_filter_by__cluster_id(self, cluster_id: str, expected: bool, cls: t.Type[ConstraintOutput]) -> None: + """ + The filter should match if one of the constraint's terms has a cluster name which contains + the filter's cluster name or if the filter is empty. + Comparisons should be case-insensitive. + """ + bc_filter = ConstraintFilters(cluster_id=cluster_id) + constraint = cls(id="bc1", name="BC1") + constraint.terms.extend( + [ + ConstraintTerm(weight=2.0, offset=5, data=LinkTerm(area1="area_FR_x", area2="area_DE_x")), + ConstraintTerm(weight=3.0, offset=5, data=LinkTerm(area1="area_IT_y", area2="area_DE_y")), + ConstraintTerm(weight=2.0, offset=5, data=ClusterTerm(area="area_HU_z", cluster="area_CL1_z")), + ] + ) + assert bc_filter.match_filters(constraint) == expected From 84988e33853c42c40ecacaae91dfd996c81824fb Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 29 Mar 2024 16:14:09 +0100 Subject: [PATCH 099/248] refactor(api-bc): use two separate methods to get the constraints and the list of constraints --- .../business/binding_constraint_management.py | 137 ++++++++++-------- antarest/study/web/study_data_blueprint.py | 11 +- .../test_binding_constraints.py | 38 ++--- tests/integration/test_integration.py | 4 +- 4 files changed, 101 insertions(+), 89 deletions(-) diff --git a/antarest/study/business/binding_constraint_management.py b/antarest/study/business/binding_constraint_management.py index d59cfaaf3e..c6cf76d9d1 100644 --- a/antarest/study/business/binding_constraint_management.py +++ b/antarest/study/business/binding_constraint_management.py @@ -1,7 +1,7 @@ import collections import itertools import logging -from typing import Any, Dict, List, Mapping, MutableSequence, Optional, Sequence, Type, Union +from typing import Any, Dict, List, Mapping, MutableSequence, Optional, Sequence, Union import numpy as np from pydantic import BaseModel, Field, root_validator, validator @@ -9,7 +9,6 @@ from antarest.core.exceptions import ( BindingConstraintNotFoundError, - CommandApplicationError, ConstraintAlreadyExistError, ConstraintIdNotFoundError, DuplicateConstraintName, @@ -409,10 +408,6 @@ def constraint_model_adapter(constraint: Mapping[str, Any], version: int) -> Con ensures data integrity when storing or retrieving constraint configurations from the database. """ - ConstraintModel: Type[Union[ConstraintOutputBase, ConstraintOutput870]] = ( - ConstraintOutput870 if version >= 870 else ConstraintOutputBase - ) - constraint_output = { "id": constraint["id"], "name": constraint["name"], @@ -424,17 +419,19 @@ def constraint_model_adapter(constraint: Mapping[str, Any], version: int) -> Con } if version >= 840: - constraint_output["filter_year_by_year"] = constraint.get("filter_year_by_year", "") or constraint.get( + constraint_output["filter_year_by_year"] = constraint.get("filter_year_by_year") or constraint.get( "filter-year-by-year", "" ) - constraint_output["filter_synthesis"] = constraint.get("filter_synthesis", "") or constraint.get( + constraint_output["filter_synthesis"] = constraint.get("filter_synthesis") or constraint.get( "filter-synthesis", "" ) + adapted_constraint: Union[ConstraintOutputBase, ConstraintOutput870] if version >= 870: constraint_output["group"] = constraint.get("group", DEFAULT_GROUP) - - adapted_constraint = ConstraintModel(**constraint_output) + adapted_constraint = ConstraintOutput870(**constraint_output) + else: + adapted_constraint = ConstraintOutputBase(**constraint_output) # If 'terms' were not directly provided in the input, parse and add terms dynamically if not constraint.get("terms"): @@ -462,32 +459,54 @@ def terms_to_coeffs(terms: Sequence[ConstraintTerm]) -> Dict[str, List[float]]: coeffs[term.id].append(term.offset) return coeffs - def get_binding_constraint( - self, - study: Study, - filters: ConstraintFilters = ConstraintFilters(), - ) -> Union[ConstraintOutput, List[ConstraintOutput]]: + def get_binding_constraint(self, study: Study, bc_id: str) -> ConstraintOutput: + """ + Retrieves a binding constraint by its ID within a given study. + + Args: + study: The study from which to retrieve the constraint. + bc_id: The ID of the binding constraint to retrieve. + + Returns: + A ConstraintOutput object representing the binding constraint with the specified ID. + + Raises: + BindingConstraintNotFoundError: If no binding constraint with the specified ID is found. + """ storage_service = self.storage_service.get_storage(study) file_study = storage_service.get_raw(study) config = file_study.tree.get(["input", "bindingconstraints", "bindingconstraints"]) - # TODO: if a single constraint ID is passed, and don't exist in the config raise an execption => 404 - - constraints_by_id: Dict[str, ConstraintOutput] = {} + constraints_by_id: Dict[str, ConstraintOutput] = CaseInsensitiveDict() # type: ignore for constraint in config.values(): constraint_config = self.constraint_model_adapter(constraint, int(study.version)) constraints_by_id[constraint_config.id] = constraint_config - filtered_constraints = {bc_id: bc for bc_id, bc in constraints_by_id.items() if filters.match_filters(bc)} + if bc_id not in constraints_by_id: + raise BindingConstraintNotFoundError(f"Binding constraint '{bc_id}' not found") - # If a specific constraint ID is provided, we return that constraint - if filters.bc_id: - # - return filtered_constraints.get(filters.bc_id) # type: ignore + return constraints_by_id[bc_id] - # Else we return all the matching constraints, based on the given filters - return list(filtered_constraints.values()) + def get_binding_constraints( + self, study: Study, filters: ConstraintFilters = ConstraintFilters() + ) -> Sequence[ConstraintOutput]: + """ + Retrieves all binding constraints within a given study, optionally filtered by specific criteria. + + Args: + study: The study from which to retrieve the constraints. + filters: The filters to apply when retrieving the constraints. + + Returns: + A list of ConstraintOutput objects representing the binding constraints that match the specified filters. + """ + storage_service = self.storage_service.get_storage(study) + file_study = storage_service.get_raw(study) + config = file_study.tree.get(["input", "bindingconstraints", "bindingconstraints"]) + outputs = [self.constraint_model_adapter(c, int(study.version)) for c in config.values()] + filtered_constraints = list(filter(lambda c: filters.match_filters(c), outputs)) + return filtered_constraints def get_grouped_constraints(self, study: Study) -> Mapping[str, Sequence[ConstraintOutput]]: """ @@ -574,7 +593,7 @@ def validate_constraint_groups(self, study: Study) -> bool: This method checks each group of binding constraints for validity based on specific criteria (e.g., coherence between matrices lengths). If any group fails the validation, an aggregated - error detailing all incoherences is raised. + error detailing all incoherence is raised. Args: study: The study object containing binding constraints. @@ -612,7 +631,7 @@ def create_binding_constraint( if not bc_id: raise InvalidConstraintName(f"Invalid binding constraint name: {data.name}.") - if bc_id in {bc.id for bc in self.get_binding_constraint(study)}: # type: ignore + if bc_id in {bc.id for bc in self.get_binding_constraints(study)}: raise DuplicateConstraintName(f"A binding constraint with the same name already exists: {bc_id}.") check_attributes_coherence(data, version) @@ -658,34 +677,31 @@ def update_binding_constraint( data: ConstraintInput, ) -> ConstraintOutput: file_study = self.storage_service.get_storage(study).get_raw(study) - existing_constraint = self.get_binding_constraint(study, ConstraintFilters(bc_id=binding_constraint_id)) + existing_constraint = self.get_binding_constraint(study, binding_constraint_id) study_version = int(study.version) - if not isinstance(existing_constraint, ConstraintOutputBase) and not isinstance( - existing_constraint, ConstraintOutput870 - ): - raise BindingConstraintNotFoundError(study.id) - check_attributes_coherence(data, study_version) # Because the update_binding_constraint command requires every attribute we have to fill them all. # This creates a `big` command even though we only updated one field. # fixme : Change the architecture to avoid this type of misconception - updated_constraint = { + upd_constraint = { "id": binding_constraint_id, "enabled": data.enabled if data.enabled is not None else existing_constraint.enabled, "time_step": data.time_step or existing_constraint.time_step, "operator": data.operator or existing_constraint.operator, "coeffs": self.terms_to_coeffs(data.terms) or self.terms_to_coeffs(existing_constraint.terms), - "filter_year_by_year": data.filter_year_by_year or existing_constraint.filter_year_by_year, - "filter_synthesis": data.filter_synthesis or existing_constraint.filter_synthesis, "comments": data.comments or existing_constraint.comments, } + if study_version >= 840: + upd_constraint["filter_year_by_year"] = data.filter_year_by_year or existing_constraint.filter_year_by_year + upd_constraint["filter_synthesis"] = data.filter_synthesis or existing_constraint.filter_synthesis + if study_version >= 870: - updated_constraint["group"] = data.group or existing_constraint.group # type: ignore + upd_constraint["group"] = data.group or existing_constraint.group # type: ignore args = { - **updated_constraint, + **upd_constraint, "command_context": self.storage_service.variant_study_service.command_factory.command_context, } @@ -711,27 +727,30 @@ def update_binding_constraint( execute_or_add_commands(study, file_study, [command], self.storage_service) # Processes the constraints to add them inside the endpoint response. - updated_constraint["name"] = existing_constraint.name - updated_constraint["type"] = updated_constraint["time_step"] + upd_constraint["name"] = existing_constraint.name + upd_constraint["type"] = upd_constraint["time_step"] # Replace coeffs by the terms - del updated_constraint["coeffs"] - updated_constraint["terms"] = data.terms or existing_constraint.terms + del upd_constraint["coeffs"] + upd_constraint["terms"] = data.terms or existing_constraint.terms - return self.constraint_model_adapter(updated_constraint, study_version) + return self.constraint_model_adapter(upd_constraint, study_version) def remove_binding_constraint(self, study: Study, binding_constraint_id: str) -> None: - command = RemoveBindingConstraint( - id=binding_constraint_id, - command_context=self.storage_service.variant_study_service.command_factory.command_context, - ) - file_study = self.storage_service.get_storage(study).get_raw(study) + """ + Removes a binding constraint from a study. - # Needed when the study is a variant because we only append the command to the list - if isinstance(study, VariantStudy) and not self.get_binding_constraint( - study, ConstraintFilters(bc_id=binding_constraint_id) - ): - raise CommandApplicationError("Binding constraint not found") + Args: + study: The study from which to remove the constraint. + binding_constraint_id: The ID of the binding constraint to remove. + Raises: + BindingConstraintNotFoundError: If no binding constraint with the specified ID is found. + """ + # Check the existence of the binding constraint before removing it + bc = self.get_binding_constraint(study, binding_constraint_id) + command_context = self.storage_service.variant_study_service.command_factory.command_context + file_study = self.storage_service.get_storage(study).get_raw(study) + command = RemoveBindingConstraint(id=bc.id, command_context=command_context) execute_or_add_commands(study, file_study, [command], self.storage_service) def update_constraint_term( @@ -741,13 +760,9 @@ def update_constraint_term( term: ConstraintTerm, ) -> None: file_study = self.storage_service.get_storage(study).get_raw(study) - constraint = self.get_binding_constraint(study, ConstraintFilters(bc_id=binding_constraint_id)) - - if not isinstance(constraint, ConstraintOutputBase) and not isinstance(constraint, ConstraintOutputBase): - raise BindingConstraintNotFoundError(study.id) - + constraint = self.get_binding_constraint(study, binding_constraint_id) constraint_terms = constraint.terms # existing constraint terms - if constraint_terms is None: + if not constraint_terms: raise NoConstraintError(study.id) term_id = term.id if isinstance(term, ConstraintTerm) else term @@ -793,9 +808,7 @@ def create_constraint_term( constraint_term: ConstraintTerm, ) -> None: file_study = self.storage_service.get_storage(study).get_raw(study) - constraint = self.get_binding_constraint(study, ConstraintFilters(bc_id=binding_constraint_id)) - if not isinstance(constraint, ConstraintOutputBase) and not isinstance(constraint, ConstraintOutputBase): - raise BindingConstraintNotFoundError(study.id) + constraint = self.get_binding_constraint(study, binding_constraint_id) if constraint_term.data is None: raise MissingDataError("Add new constraint term : data is missing") diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index e8c068d13a..475514c687 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -899,7 +899,7 @@ def update_version( "/studies/{uuid}/bindingconstraints", tags=[APITag.study_data], summary="Get binding constraint list", - response_model=Union[ConstraintOutput, List[ConstraintOutput]], # type: ignore + response_model=List[ConstraintOutput], ) def get_binding_constraint_list( uuid: str, @@ -933,7 +933,7 @@ def get_binding_constraint_list( alias="clusterId", ), current_user: JWTUser = Depends(auth.get_current_user), - ) -> Union[ConstraintOutput, List[ConstraintOutput]]: + ) -> Sequence[ConstraintOutput]: logger.info( f"Fetching binding constraint list for study {uuid}", extra={"user": current_user.id}, @@ -951,7 +951,7 @@ def get_binding_constraint_list( link_id=link_id, cluster_id=cluster_id, ) - return study_service.binding_constraint_manager.get_binding_constraint(study, filters) + return study_service.binding_constraint_manager.get_binding_constraints(study, filters) @bp.get( "/studies/{uuid}/bindingconstraints/{binding_constraint_id}", @@ -963,15 +963,14 @@ def get_binding_constraint( uuid: str, binding_constraint_id: str, current_user: JWTUser = Depends(auth.get_current_user), - ) -> Union[ConstraintOutput, List[ConstraintOutput]]: + ) -> ConstraintOutput: logger.info( f"Fetching binding constraint {binding_constraint_id} for study {uuid}", extra={"user": current_user.id}, ) params = RequestParameters(user=current_user) study = study_service.check_study_access(uuid, StudyPermissionType.READ, params) - filters = ConstraintFilters(bc_id=binding_constraint_id) - return study_service.binding_constraint_manager.get_binding_constraint(study, filters) + return study_service.binding_constraint_manager.get_binding_constraint(study, binding_constraint_id) @bp.put( "/studies/{uuid}/bindingconstraints/{binding_constraint_id}", diff --git a/tests/integration/study_data_blueprint/test_binding_constraints.py b/tests/integration/study_data_blueprint/test_binding_constraints.py index 32f47e8805..8f6fa015e4 100644 --- a/tests/integration/study_data_blueprint/test_binding_constraints.py +++ b/tests/integration/study_data_blueprint/test_binding_constraints.py @@ -35,7 +35,7 @@ def test_constraint_id(self, area: str, cluster: str, expected: str) -> None: class TestConstraintTerm: - def test_constraint_id__link(self) -> bool: + def test_constraint_id__link(self) -> None: term = ConstraintTerm( id="foo", weight=3.14, @@ -44,7 +44,7 @@ def test_constraint_id__link(self) -> bool: ) assert term.generate_id() == term.data.generate_id() - def test_constraint_id__cluster(self): + def test_constraint_id__cluster(self) -> None: term = ConstraintTerm( id="foo", weight=3.14, @@ -53,7 +53,7 @@ def test_constraint_id__cluster(self): ) assert term.generate_id() == term.data.generate_id() - def test_constraint_id__other(self): + def test_constraint_id__other(self) -> None: term = ConstraintTerm( id="foo", weight=3.14, @@ -306,13 +306,13 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st { "data": {"area1": area1_id, "area2": area2_id}, "id": f"{area1_id}%{area2_id}", - "offset": 2.0, + "offset": 2, "weight": 1.0, }, { "data": {"area": area1_id, "cluster": cluster_id.lower()}, "id": f"{area1_id}.{cluster_id.lower()}", - "offset": 2.0, + "offset": 2, "weight": 1.0, }, ] @@ -341,7 +341,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st { "data": {"area1": area1_id, "area2": area2_id}, "id": f"{area1_id}%{area2_id}", - "offset": 2.0, + "offset": 2, "weight": 1.0, }, { @@ -556,9 +556,9 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st # Delete a fake binding constraint res = client.delete(f"/v1/studies/{study_id}/bindingconstraints/fake_bc", headers=user_headers) - assert res.status_code == 500 - assert res.json()["exception"] == "CommandApplicationError" - assert res.json()["description"] == "Binding constraint not found" + assert res.status_code == 404, res.json() + assert res.json()["exception"] == "BindingConstraintNotFoundError" + assert res.json()["description"] == "Binding constraint 'fake_bc' not found" # Add a group before v8.7 grp_name = "random_grp" @@ -567,7 +567,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st json={"group": grp_name}, headers=user_headers, ) - assert res.status_code == 422 + assert res.status_code == 422, res.json() assert res.json()["exception"] == "InvalidFieldForVersionError" assert ( res.json()["description"] @@ -580,7 +580,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st json={"less_term_matrix": [[]]}, headers=user_headers, ) - assert res.status_code == 422 + assert res.status_code == 422, res.json() assert res.json()["exception"] == "InvalidFieldForVersionError" assert res.json()["description"] == "You cannot fill a 'matrix_term' as these values refer to v8.7+ studies" @@ -660,7 +660,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud params={"path": f"input/bindingconstraints/{bc_id_w_matrix}_{term}", "depth": 1, "formatted": True}, # type: ignore headers=admin_headers, ) - assert res.status_code == 200 + assert res.status_code == 200, res.json() data = res.json()["data"] if term == "lt": assert data == matrix_lt3.tolist() @@ -678,7 +678,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud json={"group": grp_name}, headers=admin_headers, ) - assert res.status_code == 200 + assert res.status_code == 200, res.json() assert res.json()["group"] == grp_name # Update matrix_term @@ -694,7 +694,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud params={"path": f"input/bindingconstraints/{bc_id_w_matrix}_gt"}, headers=admin_headers, ) - assert res.status_code == 200 + assert res.status_code == 200, res.json() assert res.json()["data"] == matrix_lt3.tolist() # The user changed the timeStep to daily instead of hourly. @@ -732,7 +732,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud }, # type: ignore headers=admin_headers, ) - assert res.status_code == 200 + assert res.status_code == 200, res.json() assert res.json()["data"] == expected_matrix.tolist() # ============================= @@ -765,7 +765,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud }, headers=admin_headers, ) - assert res.status_code == 422 + assert res.status_code == 422, res.json() assert res.json()["description"] == "You cannot fill 'values' as it refers to the matrix before v8.7" # Update with old matrices @@ -774,7 +774,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud json={"values": [[]]}, headers=admin_headers, ) - assert res.status_code == 422 + assert res.status_code == 422, res.json() assert res.json()["exception"] == "InvalidFieldForVersionError" assert res.json()["description"] == "You cannot fill 'values' as it refers to the matrix before v8.7" @@ -836,7 +836,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud # validate the BC group "Group 1" res = client.get(f"/v1/studies/{study_id}/constraint-groups/Group 1/validate", headers=admin_headers) - assert res.status_code == 422 + assert res.status_code == 422, res.json() assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" description = res.json()["description"] assert description == { @@ -883,7 +883,7 @@ def test_for_version_870(self, client: TestClient, admin_access_token: str, stud # validate the BC group "Group 1" res = client.get(f"/v1/studies/{study_id}/constraint-groups/Group 1/validate", headers=admin_headers) - assert res.status_code == 422 + assert res.status_code == 422, res.json() assert res.json()["exception"] == "IncoherenceBetweenMatricesLength" description = res.json()["description"] assert description == { diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index 3dc5391804..6f0a72fde5 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -1704,12 +1704,12 @@ def test_area_management(client: TestClient, admin_access_token: str, study_id: res = client.get(f"/v1/studies/{study_id}/bindingconstraints/binding constraint 1", headers=admin_headers) binding_constraint_1 = res.json() - assert res.status_code == 200 + assert res.status_code == 200, res.json() term = binding_constraint_1["terms"][0] assert term["id"] == "area 1.cluster 1" assert term["weight"] == 2.0 - assert term["offset"] == 4.0 + assert term["offset"] == 4 # --- TableMode END --- From 75c2e6dca9e3e2f43192e8bc44435df4929d144c Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Fri, 29 Mar 2024 18:05:01 +0100 Subject: [PATCH 100/248] refactor(api-bc): turn `offset` value to integer --- .../study/business/binding_constraint_management.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/antarest/study/business/binding_constraint_management.py b/antarest/study/business/binding_constraint_management.py index c6cf76d9d1..a382277bd5 100644 --- a/antarest/study/business/binding_constraint_management.py +++ b/antarest/study/business/binding_constraint_management.py @@ -105,7 +105,7 @@ class ConstraintTerm(BaseModel): id: Optional[str] weight: Optional[float] - offset: Optional[float] + offset: Optional[int] data: Optional[Union[LinkTerm, ClusterTerm]] @validator("id") @@ -362,7 +362,12 @@ def parse_and_add_terms( if "%" in key or "." in key: separator = "%" if "%" in key else "." term_data = key.split(separator) - weight, offset = (value, None) if isinstance(value, (float, int)) else map(float, value.split("%")) + if isinstance(value, (float, int)): + weight, offset = (float(value), None) + else: + _parts = value.partition("%") + weight = float(_parts[0]) + offset = int(_parts[2]) if _parts[2] else None if separator == "%": # Link term @@ -455,7 +460,7 @@ def terms_to_coeffs(terms: Sequence[ConstraintTerm]) -> Dict[str, List[float]]: for term in terms: if term.id and term.weight is not None: coeffs[term.id] = [term.weight] - if term.offset is not None: + if term.offset: coeffs[term.id].append(term.offset) return coeffs @@ -831,7 +836,7 @@ def create_constraint_term( for term in constraint_terms: coeffs[term.id] = [term.weight] - if term.offset is not None: + if term.offset: coeffs[term.id].append(term.offset) command = UpdateBindingConstraint( From 700a6bd06b50aac3e90cf9e8ff26654e96f2d0ec Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Thu, 14 Mar 2024 18:21:23 +0100 Subject: [PATCH 101/248] refactor(ui-bc): overhaul naming and apply minor optimizations on `BindingConstView` --- webapp/src/common/types.ts | 4 + .../AddConstraintTermForm/OptionsList.tsx | 16 +- .../AddConstraintTermForm/index.tsx | 22 +- .../AddConstraintTermDialog/index.tsx | 24 +- .../BindingConstView/BindingConstForm.tsx | 441 ++++++++---------- .../ConstraintTerm/OptionsList.tsx | 24 +- .../BindingConstView/ConstraintTerm/index.tsx | 50 +- .../BindingConstView/ConstraintTerm/style.ts | 8 - .../BindingConstView/index.tsx | 15 +- .../BindingConstView/style.ts | 10 +- .../BindingConstView/utils.ts | 42 +- .../Modelization/BindingConstraints/index.tsx | 23 +- webapp/src/services/api/studydata.ts | 58 +-- 13 files changed, 362 insertions(+), 375 deletions(-) delete mode 100644 webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/ConstraintTerm/style.ts diff --git a/webapp/src/common/types.ts b/webapp/src/common/types.ts index 509ac4c4ff..0c35290380 100644 --- a/webapp/src/common/types.ts +++ b/webapp/src/common/types.ts @@ -465,6 +465,10 @@ export interface LinkClusterElement { name: string; } +// TODO wrong types +// LinkWithClusters +// ClusterWithLinks +// Combine both export interface LinkClusterItem { element: LinkClusterElement; item_list: LinkClusterElement[]; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/AddConstraintTermDialog/AddConstraintTermForm/OptionsList.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/AddConstraintTermDialog/AddConstraintTermForm/OptionsList.tsx index 8c8bf80ad0..8cdb9c82ec 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/AddConstraintTermDialog/AddConstraintTermForm/OptionsList.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/AddConstraintTermDialog/AddConstraintTermForm/OptionsList.tsx @@ -10,7 +10,7 @@ import SelectFE from "../../../../../../../../common/fieldEditors/SelectFE"; import { ControlPlus } from "../../../../../../../../common/Form/types"; import { BindingConstFields, - ConstraintType, + ConstraintTerm, dataToId, isTermExist, } from "../../utils"; @@ -18,11 +18,11 @@ import { interface Props { list: AllClustersAndLinks; isLink: boolean; - control: ControlPlus; - watch: UseFormWatch; - constraintsTerm: BindingConstFields["constraints"]; - setValue: UseFormSetValue; - unregister: UseFormUnregister; + control: ControlPlus; + watch: UseFormWatch; + constraintTerms: BindingConstFields["constraints"]; + setValue: UseFormSetValue; + unregister: UseFormUnregister; } export default function OptionsList(props: Props) { @@ -30,7 +30,7 @@ export default function OptionsList(props: Props) { list, isLink, control, - constraintsTerm, + constraintTerms, watch, setValue, unregister, @@ -71,7 +71,7 @@ export default function OptionsList(props: Props) { .filter( (elm) => !isTermExist( - constraintsTerm, + constraintTerms, dataToId( isLink ? { diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/AddConstraintTermDialog/AddConstraintTermForm/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/AddConstraintTermDialog/AddConstraintTermForm/index.tsx index e833cbe810..1810a3f28e 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/AddConstraintTermDialog/AddConstraintTermForm/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/AddConstraintTermDialog/AddConstraintTermForm/index.tsx @@ -6,20 +6,19 @@ import { AllClustersAndLinks } from "../../../../../../../../../common/types"; import OptionsList from "./OptionsList"; import NumberFE from "../../../../../../../../common/fieldEditors/NumberFE"; import { useFormContextPlus } from "../../../../../../../../common/Form"; -import { ConstraintItemRoot } from "../../ConstraintTerm/style"; -import { BindingConstFields, ConstraintType } from "../../utils"; +import { BindingConstFields, ConstraintTerm } from "../../utils"; import ConstraintElement from "../../constraintviews/ConstraintElement"; import OffsetInput from "../../constraintviews/OffsetInput"; interface Props { options: AllClustersAndLinks; - constraintsTerm: BindingConstFields["constraints"]; + constraintTerms: BindingConstFields["constraints"]; } export default function AddConstraintTermForm(props: Props) { - const { options, constraintsTerm } = props; + const { options, constraintTerms } = props; const { control, watch, unregister, setValue } = - useFormContextPlus(); + useFormContextPlus(); const [t] = useTranslation(); const [isLink, setIsLink] = useState(true); @@ -34,7 +33,14 @@ export default function AddConstraintTermForm(props: Props) { width: "100%", }} > - + )} - + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/AddConstraintTermDialog/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/AddConstraintTermDialog/index.tsx index 5197645e5b..f4be53714f 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/AddConstraintTermDialog/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/AddConstraintTermDialog/index.tsx @@ -9,7 +9,7 @@ import { SubmitHandlerPlus } from "../../../../../../../common/Form/types"; import useEnqueueErrorSnackbar from "../../../../../../../../hooks/useEnqueueErrorSnackbar"; import { BindingConstFields, - ConstraintType, + ConstraintTerm, dataToId, isDataLink, isOptionExist, @@ -27,9 +27,9 @@ import { getLinksAndClusters } from "../../../../../../../../redux/selectors"; interface Props extends Omit { studyId: string; - bindingConstraint: string; + constraintId: string; append: UseFieldArrayAppend; - constraintsTerm: BindingConstFields["constraints"]; + constraintTerms: BindingConstFields["constraints"]; options: AllClustersAndLinks; } @@ -39,14 +39,14 @@ function AddConstraintTermDialog(props: Props) { const { enqueueSnackbar } = useSnackbar(); const { studyId, - bindingConstraint, + constraintId, options, - constraintsTerm, + constraintTerms, append, ...dialogProps } = props; const { onCancel } = dialogProps; - const defaultValues: ConstraintType = { + const defaultValues: ConstraintTerm = { id: "", weight: 0, offset: 0, @@ -66,7 +66,7 @@ function AddConstraintTermDialog(props: Props) { const handleSubmit = async (values: SubmitHandlerPlus) => { try { - const tmpValues = values.dirtyValues as ConstraintType; + const tmpValues = values.dirtyValues as ConstraintTerm; const isLink = isDataLink(tmpValues.data); if (tmpValues.weight === undefined) { tmpValues.weight = 0.0; @@ -103,7 +103,7 @@ function AddConstraintTermDialog(props: Props) { // Verify if this term already exist in current term list const termId = dataToId(data); - if (isTermExist(constraintsTerm, termId)) { + if (isTermExist(constraintTerms, termId)) { enqueueSnackbar(t("study.error.termAlreadyExist"), { variant: "error", }); @@ -114,12 +114,12 @@ function AddConstraintTermDialog(props: Props) { // Send await addConstraintTerm( studyId, - bindingConstraint, - values.dirtyValues as ConstraintType, + constraintId, + values.dirtyValues as ConstraintTerm, ); // Add to current UX - append(tmpValues as ConstraintType); + append(tmpValues as ConstraintTerm); enqueueSnackbar(t("study.success.addConstraintTerm"), { variant: "success", }); @@ -144,7 +144,7 @@ function AddConstraintTermDialog(props: Props) { {optionsItems && ( )} diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/BindingConstForm.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/BindingConstForm.tsx index a6c574497b..c4878441a9 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/BindingConstForm.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/BindingConstForm.tsx @@ -1,5 +1,5 @@ import { AxiosError } from "axios"; -import { useCallback, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Box, Button, Tab, Typography } from "@mui/material"; import { useFieldArray } from "react-hook-form"; @@ -9,9 +9,12 @@ import { useNavigate } from "react-router-dom"; import useEnqueueErrorSnackbar from "../../../../../../../hooks/useEnqueueErrorSnackbar"; import { ACTIVE_WINDOWS_DOC_PATH, + BC_PATH, BindingConstFields, - ConstraintType, + type ConstraintTerm, + OPERATORS, dataToId, + TIME_STEPS, } from "./utils"; import { AllClustersAndLinks, @@ -20,7 +23,9 @@ import { } from "../../../../../../../common/types"; import { IFormGenerator } from "../../../../../../common/FormGenerator"; import AutoSubmitGeneratorForm from "../../../../../../common/FormGenerator/AutoSubmitGenerator"; -import ConstraintItem, { ConstraintWithNullableOffset } from "./ConstraintTerm"; +import ConstraintTermItem, { + ConstraintWithNullableOffset, +} from "./ConstraintTerm"; import { useFormContextPlus } from "../../../../../../common/Form"; import { deleteConstraintTerm, @@ -28,13 +33,7 @@ import { updateConstraintTerm, } from "../../../../../../../services/api/studydata"; import TextSeparator from "../../../../../../common/TextSeparator"; -import { - ConstraintHeader, - ConstraintList, - ConstraintTerm, - MatrixContainer, - StyledTab, -} from "./style"; +import { MatrixContainer, StyledTab, TermsHeader, TermsList } from "./style"; import AddConstraintTermDialog from "./AddConstraintTermDialog"; import MatrixInput from "../../../../../../common/MatrixInput"; import ConfirmationDialog from "../../../../../../common/dialogs/ConfirmationDialog"; @@ -46,108 +45,105 @@ import { setCurrentBindingConst } from "../../../../../../../redux/ducks/studySy import OutputFilters from "../../../common/OutputFilters"; import DocLink from "../../../../../../common/DocLink"; -const DEBOUNCE_DELAY = 200; - interface Props { study: StudyMetadata; - bindingConst: string; + constraintId: string; options: AllClustersAndLinks; } -export default function BindingConstForm(props: Props) { - const { study, options, bindingConst } = props; - const studyId = study.id; - const { enqueueSnackbar } = useSnackbar(); - const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); +function BindingConstForm({ study, options, constraintId }: Props) { + const { version: studyVersion, id: studyId } = study; const [t] = useTranslation(); - const dispatch = useAppDispatch(); const navigate = useNavigate(); - const { control } = useFormContextPlus(); + const dispatch = useAppDispatch(); + const { enqueueSnackbar } = useSnackbar(); + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); + + const [tabValue, setTabValue] = useState(0); + const [termToDelete, setTermToDelete] = useState(); + const [constraintToDelete, setConstraintToDelete] = useState(false); + const [openConstraintTermDialog, setOpenConstraintTermDialog] = + useState(false); + + const { control, getValues } = useFormContextPlus(); + const { fields, update, append, remove } = useFieldArray({ control, name: "constraints", }); - const constraintsTerm = useMemo( - () => fields.map((elm) => ({ ...elm, id: dataToId(elm.data) })), + const constraintTerms = useMemo( + () => fields.map((term) => ({ ...term, id: dataToId(term.data) })), [fields], ); - const pathPrefix = `input/bindingconstraints/bindingconstraints`; - - const optionOperator = useMemo( + const operatorOptions = useMemo( () => - ["less", "equal", "greater", "both"].map((item) => ({ - label: t(`study.modelization.bindingConst.operator.${item}`), - value: item.toLowerCase(), + OPERATORS.map((operator) => ({ + label: t(`study.modelization.bindingConst.operator.${operator}`), + value: operator, })), [t], ); + const currentOperator = getValues("operator"); + console.log("currentOperator", currentOperator); + const typeOptions = useMemo( () => - ["hourly", "daily", "weekly"].map((item) => ({ - label: t(`global.time.${item}`), - value: item, + TIME_STEPS.map((timeStep) => ({ + label: t(`global.time.${timeStep}`), + value: timeStep, })), [t], ); - const [addConstraintTermDialog, setAddConstraintTermDialog] = useState(false); - const [deleteConstraint, setDeleteConstraint] = useState(false); - const [termToDelete, setTermToDelete] = useState(); - const [tabValue, setTabValue] = useState(0); - //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// - const saveValue = useCallback( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async (name: string, data: any) => { - try { - await updateBindingConstraint(studyId, bindingConst, { - key: name, - value: data, - }); - } catch (error) { - enqueueErrorSnackbar(t("study.error.updateUI"), error as AxiosError); - } - }, - [bindingConst, enqueueErrorSnackbar, studyId, t], - ); + const handleSaveValue = async (filter: string, data: unknown) => { + console.log("filter", filter); + console.log("data", data); + try { + await updateBindingConstraint(studyId, constraintId, { + key: filter, + value: data, + }); + } catch (error) { + enqueueErrorSnackbar(t("study.error.updateUI"), error as AxiosError); + } + }; - const saveValueFormGenerator = useCallback( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async (name: string, path: string, defaultValues: any, data: any) => - saveValue(name, data), - [saveValue], - ); + const handleSaveFormValue = async ( + name: string, + _path: string, + _defaultValues: unknown, + data: unknown, + ) => handleSaveValue(name, data); - const saveContraintValue = useDebounce( + const handleUpdateTerm = useDebounce( async ( index: number, - prevConst: ConstraintType, - constraint: ConstraintWithNullableOffset, + prevTerm: ConstraintTerm, + newTerm: ConstraintWithNullableOffset, ) => { try { - const tmpConst = prevConst; - if (constraint.weight !== undefined) { - tmpConst.weight = constraint.weight; - } - if (constraint.data) { - tmpConst.data = constraint.data; - } - tmpConst.id = dataToId(tmpConst.data); - if (constraint.offset !== undefined) { - tmpConst.offset = - constraint.offset !== null ? constraint.offset : undefined; - } - await updateConstraintTerm(study.id, bindingConst, { - ...constraint, - offset: tmpConst.offset, + const updatedTerm = { + ...prevTerm, + weight: newTerm.weight || prevTerm.weight, + data: newTerm.data || prevTerm.data, + offset: newTerm.offset || prevTerm.offset, + }; + + updatedTerm.id = dataToId(updatedTerm.data); + + await updateConstraintTerm(study.id, constraintId, { + ...newTerm, + offset: updatedTerm.offset, }); - update(index, tmpConst); + + update(index, updatedTerm); } catch (error) { enqueueErrorSnackbar( t("study.error.updateConstraintTerm"), @@ -155,63 +151,55 @@ export default function BindingConstForm(props: Props) { ); } }, - DEBOUNCE_DELAY, + 200, ); - const deleteTerm = useCallback( - async (index: number) => { - try { - const constraintId = dataToId(constraintsTerm[index].data); - await deleteConstraintTerm(study.id, bindingConst, constraintId); - remove(index); - } catch (error) { - enqueueErrorSnackbar( - t("study.error.deleteConstraintTerm"), - error as AxiosError, - ); - } finally { - setTermToDelete(undefined); - } - }, - [bindingConst, enqueueErrorSnackbar, constraintsTerm, remove, study.id, t], - ); + const handleDeleteTerm = async (termToDelete: number) => { + try { + const termId = dataToId(constraintTerms[termToDelete].data); + await deleteConstraintTerm(study.id, constraintId, termId); + remove(termToDelete); + } catch (error) { + enqueueErrorSnackbar( + t("study.error.deleteConstraintTerm"), + error as AxiosError, + ); + } finally { + setTermToDelete(undefined); + } + }; - const handleConstraintDeletion = useCallback(async () => { + const handleDeleteConstraint = async () => { try { await appendCommands(study.id, [ { action: CommandEnum.REMOVE_BINDING_CONSTRAINT, args: { - id: bindingConst, + id: constraintId, }, }, ]); + enqueueSnackbar(t("study.success.deleteConstraint"), { variant: "success", }); + dispatch(setCurrentBindingConst("")); + navigate(`/studies/${study.id}/explore/modelization/bindingcontraint`); } catch (e) { enqueueErrorSnackbar(t("study.error.deleteConstraint"), e as AxiosError); } finally { - setDeleteConstraint(false); + setConstraintToDelete(false); } - }, [ - bindingConst, - dispatch, - enqueueErrorSnackbar, - enqueueSnackbar, - navigate, - study.id, - t, - ]); + }; const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { setTabValue(newValue); }; //////////////////////////////////////////////////////////////// - // JSX + // Utils //////////////////////////////////////////////////////////////// const jsonGenerator: IFormGenerator = useMemo( @@ -236,15 +224,8 @@ export default function BindingConstForm(props: Props) { @@ -256,173 +237,153 @@ export default function BindingConstForm(props: Props) { { type: "text", name: "name", - path: `${pathPrefix}/name`, + path: `${BC_PATH}/name`, label: t("global.name"), disabled: true, - required: t("form.field.required") as string, + required: t("form.field.required"), }, { type: "text", name: "comments", - path: `${pathPrefix}/comments`, + path: `${BC_PATH}/comments`, label: t("study.modelization.bindingConst.comments"), }, { type: "select", name: "time_step", - path: `${pathPrefix}/type`, + path: `${BC_PATH}/type`, label: t("study.modelization.bindingConst.type"), options: typeOptions, }, { type: "select", name: "operator", - path: `${pathPrefix}/operator`, + path: `${BC_PATH}/operator`, label: t("study.modelization.bindingConst.operator"), - options: optionOperator, + options: operatorOptions, }, { type: "switch", name: "enabled", - path: `${pathPrefix}/enabled`, + path: `${BC_PATH}/enabled`, label: t("study.modelization.bindingConst.enabled"), }, ], }, ], - [optionOperator, pathPrefix, t, typeOptions], + [t, operatorOptions, typeOptions], ); + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + return ( <> - {Number(study.version) >= 840 && ( - + + {studyVersion >= "840" && ( + )} + + + + + + - - - - - - {tabValue === 0 ? ( - <> - - - - - {constraintsTerm.map( - (constraint: ConstraintType, index: number) => { - return index > 0 ? ( - - - - saveContraintValue(index, constraint, value) - } - constraint={constraint} - deleteTerm={() => setTermToDelete(index)} - constraintsTerm={constraintsTerm} - /> - - ) : ( - - saveContraintValue(index, constraint, value) - } - constraint={constraint} - deleteTerm={() => setTermToDelete(index)} - constraintsTerm={constraintsTerm} - /> - ); - }, + {tabValue === 0 && ( + + + + + {constraintTerms.map((term: ConstraintTerm, index: number) => ( + + {index > 0 && ( + )} - - {addConstraintTermDialog && ( - setAddConstraintTermDialog(false)} - append={append} - constraintsTerm={constraintsTerm} + + handleUpdateTerm(index, term, newTerm) + } + term={term} + deleteTerm={() => setTermToDelete(index)} + constraintTerms={constraintTerms} /> - )} - {termToDelete !== undefined && ( - setTermToDelete(undefined)} - onConfirm={() => deleteTerm(termToDelete)} - alert="warning" - open - > - {t( - "study.modelization.bindingConst.question.deleteConstraintTerm", - )} - - )} - {deleteConstraint && ( - setDeleteConstraint(false)} - onConfirm={() => handleConstraintDeletion()} - alert="warning" - open - > - {t( - "study.modelization.bindingConst.question.deleteBindingConstraint", - )} - - )} - - ) : ( - - ", "="]} - computStats={MatrixStats.NOCOL} - /> - - )} - + + ))} + + )} + + {tabValue === 1 && ( + + ", "="]} + computStats={MatrixStats.NOCOL} + /> + + )} + + {openConstraintTermDialog && ( + setOpenConstraintTermDialog(false)} + append={append} + constraintTerms={constraintTerms} + options={options} + /> + )} + + {termToDelete !== undefined && ( + setTermToDelete(undefined)} + onConfirm={() => handleDeleteTerm(termToDelete)} + alert="warning" + open + > + {t("study.modelization.bindingConst.question.deleteConstraintTerm")} + + )} + + {constraintToDelete && ( + setConstraintToDelete(false)} + onConfirm={() => handleDeleteConstraint()} + alert="warning" + open + > + {t( + "study.modelization.bindingConst.question.deleteBindingConstraint", + )} + + )} ); } + +export default BindingConstForm; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/ConstraintTerm/OptionsList.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/ConstraintTerm/OptionsList.tsx index 3fbfae70f3..aad0c2b189 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/ConstraintTerm/OptionsList.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/ConstraintTerm/OptionsList.tsx @@ -2,14 +2,14 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { AllClustersAndLinks } from "../../../../../../../../common/types"; import SelectSingle from "../../../../../../../common/SelectSingle"; -import { ConstraintType, dataToId, isTermExist } from "../utils"; +import { ConstraintTerm, dataToId, isTermExist } from "../utils"; interface Props { list: AllClustersAndLinks; isLink: boolean; - constraint: ConstraintType; - constraintsTerm: ConstraintType[]; - saveValue: (constraint: Partial) => void; + term: ConstraintTerm; + constraintTerms: ConstraintTerm[]; + saveValue: (constraint: Partial) => void; value1: string; value2: string; setValue1: (value: string) => void; @@ -20,10 +20,10 @@ export default function OptionsList(props: Props) { const { list, isLink, - constraint, + term, value1, value2, - constraintsTerm, + constraintTerms, saveValue, setValue1, setValue2, @@ -50,7 +50,7 @@ export default function OptionsList(props: Props) { (elm) => elm.id === value2 || !isTermExist( - constraintsTerm, + constraintTerms, dataToId( isLink ? { @@ -66,7 +66,7 @@ export default function OptionsList(props: Props) { id: elm.id.toLowerCase(), })); return tmp; - }, [constraintsTerm, isLink, options, value1, value2]); + }, [constraintTerms, isLink, options, value1, value2]); const getFirstValue2 = useCallback( (value: string): string => { @@ -87,7 +87,7 @@ export default function OptionsList(props: Props) { (value: string) => { const v2 = getFirstValue2(value); saveValue({ - id: constraint.id, + id: term.id, data: isLink ? { area1: value, @@ -101,14 +101,14 @@ export default function OptionsList(props: Props) { setValue1(value); setValue2(v2); }, - [constraint.id, getFirstValue2, isLink, saveValue, setValue1, setValue2], + [term.id, getFirstValue2, isLink, saveValue, setValue1, setValue2], ); const handleValue2 = useCallback( (value: string) => { setValue2(value); saveValue({ - id: constraint.id, + id: term.id, data: isLink ? { area1: value1, @@ -120,7 +120,7 @@ export default function OptionsList(props: Props) { }, }); }, - [constraint.id, isLink, saveValue, setValue2, value1], + [term.id, isLink, saveValue, setValue2, value1], ); //////////////////////////////////////////////////////////////// diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/ConstraintTerm/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/ConstraintTerm/index.tsx index 136f0f287d..1039d36f1e 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/ConstraintTerm/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/ConstraintTerm/index.tsx @@ -3,41 +3,40 @@ import { Box, Button, TextField, Typography } from "@mui/material"; import AddCircleOutlineRoundedIcon from "@mui/icons-material/AddCircleOutlineRounded"; import DeleteRoundedIcon from "@mui/icons-material/DeleteRounded"; import { useTranslation } from "react-i18next"; -import { ConstraintType, isDataLink } from "../utils"; +import { ConstraintTerm, isDataLink } from "../utils"; import { AllClustersAndLinks, ClusterElement, LinkCreationInfoDTO, } from "../../../../../../../../common/types"; import OptionsList from "./OptionsList"; -import { ConstraintItemRoot } from "./style"; import ConstraintElement from "../constraintviews/ConstraintElement"; import OffsetInput from "../constraintviews/OffsetInput"; export type ConstraintWithNullableOffset = Partial< - Omit & { offset: number | null | undefined } + Omit & { offset: number | null | undefined } >; interface Props { options: AllClustersAndLinks; - constraint: ConstraintType; - constraintsTerm: ConstraintType[]; - saveValue: (constraint: ConstraintWithNullableOffset) => void; + term: ConstraintTerm; + constraintTerms: ConstraintTerm[]; + saveValue: (term: ConstraintWithNullableOffset) => void; deleteTerm: () => void; } -export default function ConstraintItem(props: Props) { - const { options, constraint, constraintsTerm, saveValue, deleteTerm } = props; +function ConstraintTermItem(props: Props) { + const { options, term, constraintTerms, saveValue, deleteTerm } = props; const [t] = useTranslation(); - const [weight, setWeight] = useState(constraint.weight); - const [offset, setOffset] = useState(constraint.offset); - const isLink = useMemo(() => isDataLink(constraint.data), [constraint.data]); + const [weight, setWeight] = useState(term.weight); + const [offset, setOffset] = useState(term.offset); + const isLink = useMemo(() => isDataLink(term.data), [term.data]); const initValue1 = isLink - ? (constraint.data as LinkCreationInfoDTO).area1 - : (constraint.data as ClusterElement).area; + ? (term.data as LinkCreationInfoDTO).area1 + : (term.data as ClusterElement).area; const initValue2 = isLink - ? (constraint.data as LinkCreationInfoDTO).area2 - : (constraint.data as ClusterElement).cluster; + ? (term.data as LinkCreationInfoDTO).area2 + : (term.data as ClusterElement).cluster; const [value1, setValue1] = useState(initValue1); const [value2, setValue2] = useState(initValue2); @@ -62,11 +61,11 @@ export default function ConstraintItem(props: Props) { setOffset(pValue); } saveValue({ - id: constraint.id, + id: term.id, [name]: value === null ? value : pValue, }); }, - [constraint.id, saveValue], + [term.id, saveValue], ); //////////////////////////////////////////////////////////////// @@ -74,7 +73,14 @@ export default function ConstraintItem(props: Props) { /// ///////////////////////////////////////////////////////////// return ( - + } @@ -142,6 +148,8 @@ export default function ConstraintItem(props: Props) { > {t("global.delete")} - + ); } + +export default ConstraintTermItem; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/ConstraintTerm/style.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/ConstraintTerm/style.ts deleted file mode 100644 index d5f6016bf3..0000000000 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/ConstraintTerm/style.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Box, styled } from "@mui/material"; - -export const ConstraintItemRoot = styled(Box)(({ theme }) => ({ - display: "flex", - width: "100%", - padding: 0, - alignItems: "center", -})); 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 afaef4287b..e7cb35e903 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 @@ -12,16 +12,17 @@ import useStudySynthesis from "../../../../../../../redux/hooks/useStudySynthesi import { getLinksAndClusters } from "../../../../../../../redux/selectors"; interface Props { - bindingConst: string; + constraintId: string; } -function BindingConstView(props: Props) { +function BindingConstView({ constraintId }: Props) { const { study } = useOutletContext<{ study: StudyMetadata }>(); - const { bindingConst } = props; + const defaultValuesRes = usePromise( - () => getDefaultValues(study.id, bindingConst), - [study.id, bindingConst], + () => getDefaultValues(study.id, constraintId), + [study.id, constraintId], ); + const optionsRes = useStudySynthesis({ studyId: study.id, selector: (state) => getLinksAndClusters(state, study.id), @@ -38,10 +39,10 @@ function BindingConstView(props: Props) { response={mergeResponses(defaultValuesRes, optionsRes)} ifResolved={([defaultValues, options]) => (

    - {bindingConst && ( + {constraintId && ( )} diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/style.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/style.ts index 6d2b44dfb0..19e1f4d856 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/style.ts +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/style.ts @@ -1,13 +1,13 @@ import { Box, Tabs, styled } from "@mui/material"; -export const ConstraintList = styled(Box)(({ theme }) => ({ +export const TermsList = styled(Box)(({ theme }) => ({ display: "flex", width: "100%", flexDirection: "column", marginBottom: theme.spacing(1), })); -export const ConstraintHeader = styled(Box)(({ theme }) => ({ +export const TermsHeader = styled(Box)(({ theme }) => ({ width: "100%", display: "flex", justifyContent: "flex-end", @@ -15,12 +15,6 @@ export const ConstraintHeader = styled(Box)(({ theme }) => ({ marginBottom: theme.spacing(2), })); -export const ConstraintTerm = styled(Box)(({ theme }) => ({ - display: "flex", - width: "100%", - flexDirection: "column", -})); - export const MatrixContainer = styled(Box)(({ theme }) => ({ display: "flex", flexDirection: "column", diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/utils.ts index 65af894d24..ede2747a76 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/utils.ts @@ -6,9 +6,24 @@ import { import { getBindingConstraint } from "../../../../../../../services/api/studydata"; import { FilteringType } from "../../../common/types"; -type OperatorType = "less" | "equal" | "greater" | "both"; +//////////////////////////////////////////////////////////////// +// Constants +//////////////////////////////////////////////////////////////// -export interface ConstraintType { +export const BC_PATH = `input/bindingconstraints/bindingconstraints`; +export const OPERATORS = ["less", "equal", "greater", "both"] as const; +export const TIME_STEPS = ["hourly", "daily", "weekly"] as const; +export const ACTIVE_WINDOWS_DOC_PATH = + "https://antares-simulator.readthedocs.io/en/latest/reference-guide/04-active_windows/"; + +//////////////////////////////////////////////////////////////// +// Types +//////////////////////////////////////////////////////////////// + +export type Operator = (typeof OPERATORS)[number]; +export type TimeStep = (typeof TIME_STEPS)[number]; + +export interface ConstraintTerm { id: string; weight: number; offset?: number; @@ -25,28 +40,32 @@ export interface BindingConstFields { name: string; id: string; enabled: boolean; - time_step: Exclude; - operator: OperatorType; + time_step: TimeStep; + operator: Operator; comments?: string; filterByYear: FilteringType[]; filterSynthesis: FilteringType[]; - constraints: ConstraintType[]; + constraints: ConstraintTerm[]; } export interface BindingConstFieldsDTO { name: string; id: string; enabled: boolean; - time_step: Exclude; - operator: OperatorType; + time_step: TimeStep; + operator: Operator; comments?: string; filter_year_by_year?: string; filter_synthesis?: string; - constraints: ConstraintType[]; + constraints: ConstraintTerm[]; } export type BindingConstPath = Record; +//////////////////////////////////////////////////////////////// +// Functions +//////////////////////////////////////////////////////////////// + export async function getDefaultValues( studyId: string, bindingConstId: string, @@ -104,11 +123,8 @@ export const isOptionExist = ( }; export const isTermExist = ( - list: ConstraintType[], + terms: ConstraintTerm[], termId: string, ): boolean => { - return list.findIndex((item) => item.id === termId) >= 0; + return terms.findIndex((term) => term.id === termId) >= 0; }; - -export const ACTIVE_WINDOWS_DOC_PATH = - "https://antares-simulator.readthedocs.io/en/latest/reference-guide/04-active_windows/"; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/index.tsx index 2836f7fce0..e4768e4a13 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/index.tsx @@ -21,21 +21,25 @@ import UsePromiseCond from "../../../../../common/utils/UsePromiseCond"; function BindingConstraints() { const { study } = useOutletContext<{ study: StudyMetadata }>(); + const dispatch = useAppDispatch(); + + const currentConstraintId = useAppSelector(getCurrentBindingConstId); + const bindingConstraints = useAppSelector((state) => getBindingConst(state, study.id), ); - const res = usePromise( + + // TODO find better name + const constraints = usePromise( () => getBindingConstraintList(study.id), [study.id, bindingConstraints], ); - const currentBindingConst = useAppSelector(getCurrentBindingConstId); - const dispatch = useAppDispatch(); //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// - const handleBindingConstClick = (bindingConstId: string): void => { + const handleConstraintChange = (bindingConstId: string): void => { dispatch(setCurrentBindingConst(bindingConstId)); }; @@ -43,19 +47,20 @@ function BindingConstraints() { // JSX //////////////////////////////////////////////////////////////// + // TODO use Split + Refactor logic to be simpler and select the first contraint by default return ( } ifResolved={(data) => ( )} @@ -66,10 +71,10 @@ function BindingConstraints() { {R.cond([ // Binding constraints list [ - () => !!currentBindingConst && res.data !== undefined, + () => !!currentConstraintId && constraints.data !== undefined, () => ( - + ) as ReactNode, ], // No Areas diff --git a/webapp/src/services/api/studydata.ts b/webapp/src/services/api/studydata.ts index 561d8197fe..b36778c757 100644 --- a/webapp/src/services/api/studydata.ts +++ b/webapp/src/services/api/studydata.ts @@ -8,7 +8,7 @@ import { CreateBindingConstraint } from "../../components/App/Singlestudy/Comman import { BindingConstFields, BindingConstFieldsDTO, - ConstraintType, + ConstraintTerm, UpdateBindingConstraint, } from "../../components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/utils"; import { StudyMapNode } from "../../redux/ducks/studyMaps"; @@ -71,73 +71,73 @@ export const deleteLink = async ( }; export const updateConstraintTerm = async ( - uuid: string, - bindingConst: string, - constraint: Partial, + studyId: string, + constraintId: string, + term: Partial, ): Promise => { const res = await client.put( - `/v1/studies/${uuid}/bindingconstraints/${encodeURIComponent( - bindingConst, + `/v1/studies/${studyId}/bindingconstraints/${encodeURIComponent( + constraintId, )}/term`, - constraint, + term, ); return res.data; }; export const addConstraintTerm = async ( - uuid: string, - bindingConst: string, - constraint: ConstraintType, -): Promise => { + studyId: string, + constraintId: string, + term: ConstraintTerm, +): Promise => { const res = await client.post( - `/v1/studies/${uuid}/bindingconstraints/${encodeURIComponent( - bindingConst, + `/v1/studies/${studyId}/bindingconstraints/${encodeURIComponent( + constraintId, )}/term`, - constraint, + term, ); return res.data; }; export const deleteConstraintTerm = async ( - uuid: string, - bindingConst: string, - termId: ConstraintType["id"], + studyId: string, + constraintId: string, + termId: ConstraintTerm["id"], ): Promise => { const res = await client.delete( - `/v1/studies/${uuid}/bindingconstraints/${encodeURIComponent( - bindingConst, + `/v1/studies/${studyId}/bindingconstraints/${encodeURIComponent( + constraintId, )}/term/${encodeURIComponent(termId)}`, ); return res.data; }; export const getBindingConstraint = async ( - uuid: string, - bindingConst: string, + studyId: string, + constraintId: string, ): Promise => { const res = await client.get( - `/v1/studies/${uuid}/bindingconstraints/${encodeURIComponent( - bindingConst, + `/v1/studies/${studyId}/bindingconstraints/${encodeURIComponent( + constraintId, )}`, ); return res.data; }; export const getBindingConstraintList = async ( - uuid: string, + studyId: string, ): Promise => { - const res = await client.get(`/v1/studies/${uuid}/bindingconstraints`); + const res = await client.get(`/v1/studies/${studyId}/bindingconstraints`); return res.data; }; export const updateBindingConstraint = async ( - uuid: string, - bindingConst: string, + studyId: string, + constraintId: string, data: UpdateBindingConstraint, ): Promise => { const res = await client.put( - `/v1/studies/${uuid}/bindingconstraints/${encodeURIComponent( - bindingConst, + `/v1/studies/${studyId}/bindingconstraints/${encodeURIComponent( + constraintId, )}`, data, ); From e5de0316e1e49a2fefc81666a502873426799f19 Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Fri, 15 Mar 2024 16:31:31 +0100 Subject: [PATCH 102/248] feat(ui-bc): ui improvements for `BindingConstForm` --- .../BindingConstView/BindingConstForm.tsx | 18 ++- .../ConstraintTerm/OptionsList.tsx | 14 +- .../BindingConstView/ConstraintTerm/index.tsx | 139 +++++++++--------- .../constraintviews/ConstraintElement.tsx | 2 +- .../constraintviews/OffsetInput.tsx | 4 +- .../BindingConstView/constraintviews/style.ts | 1 + .../explore/common/OutputFilters.tsx | 5 +- .../components/common/FormGenerator/index.tsx | 29 +++- webapp/src/components/common/SelectSingle.tsx | 14 +- .../src/components/common/TextSeparator.tsx | 2 +- 10 files changed, 133 insertions(+), 95 deletions(-) diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/BindingConstForm.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/BindingConstForm.tsx index c4878441a9..7beba49bbc 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/BindingConstForm.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/BindingConstForm.tsx @@ -2,6 +2,7 @@ import { AxiosError } from "axios"; import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Box, Button, Tab, Typography } from "@mui/material"; +import AddCircleOutlineRoundedIcon from "@mui/icons-material/AddCircleOutlineRounded"; import { useFieldArray } from "react-hook-form"; import DeleteIcon from "@mui/icons-material/Delete"; import { useSnackbar } from "notistack"; @@ -151,7 +152,7 @@ function BindingConstForm({ study, options, constraintId }: Props) { ); } }, - 200, + 500, ); const handleDeleteTerm = async (termToDelete: number) => { @@ -241,12 +242,21 @@ function BindingConstForm({ study, options, constraintId }: Props) { label: t("global.name"), disabled: true, required: t("form.field.required"), + sx: { maxWidth: 200 }, + }, + { + type: "text", + name: "comments", // TODO group + path: `${BC_PATH}/group`, + label: t("global.group"), + sx: { maxWidth: 200 }, }, { type: "text", name: "comments", path: `${BC_PATH}/comments`, label: t("study.modelization.bindingConst.comments"), + sx: { maxWidth: 200 }, }, { type: "select", @@ -254,6 +264,7 @@ function BindingConstForm({ study, options, constraintId }: Props) { path: `${BC_PATH}/type`, label: t("study.modelization.bindingConst.type"), options: typeOptions, + sx: { maxWidth: 120 }, }, { type: "select", @@ -261,6 +272,7 @@ function BindingConstForm({ study, options, constraintId }: Props) { path: `${BC_PATH}/operator`, label: t("study.modelization.bindingConst.operator"), options: operatorOptions, + sx: { maxWidth: 120 }, }, { type: "switch", @@ -305,8 +317,10 @@ function BindingConstForm({ study, options, constraintId }: Props) { + )} + + + - )} - + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/constraintviews/ConstraintElement.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/constraintviews/ConstraintElement.tsx index c675363650..8bd0662372 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/constraintviews/ConstraintElement.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/constraintviews/ConstraintElement.tsx @@ -7,7 +7,7 @@ import { } from "./style"; interface ElementProps { - title: string; + title?: string; left: ReactNode; right: ReactNode; operator?: string; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/constraintviews/OffsetInput.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/constraintviews/OffsetInput.tsx index fee733c5c0..c2e7715f7b 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/constraintviews/OffsetInput.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/constraintviews/OffsetInput.tsx @@ -20,8 +20,8 @@ export default function OffsetInput(props: PropsWithChildren) { ({ display: "flex", flexDirection: "column", padding: theme.spacing(1), + borderRadius: 5, backgroundColor: PAPER_BACKGROUND_NO_TRANSPARENCY, })); diff --git a/webapp/src/components/App/Singlestudy/explore/common/OutputFilters.tsx b/webapp/src/components/App/Singlestudy/explore/common/OutputFilters.tsx index 3f8875107d..046ada24c3 100644 --- a/webapp/src/components/App/Singlestudy/explore/common/OutputFilters.tsx +++ b/webapp/src/components/App/Singlestudy/explore/common/OutputFilters.tsx @@ -35,6 +35,8 @@ function OutputFilters(props: Props) { multiple options={filterOptions} label={t(`study.outputFilters.${filterName}`)} + size="small" + variant="outlined" control={control} rules={{ onAutoSubmit: (value) => { @@ -44,11 +46,12 @@ function OutputFilters(props: Props) { onAutoSubmit(filterName, selection.join(", ")); }, }} + sx={{ maxWidth: 220 }} /> ); return ( -
    +
    {renderFilter("filterSynthesis")} {renderFilter("filterByYear")}
    diff --git a/webapp/src/components/common/FormGenerator/index.tsx b/webapp/src/components/common/FormGenerator/index.tsx index b91dc7c9bc..fb8a09af0f 100644 --- a/webapp/src/components/common/FormGenerator/index.tsx +++ b/webapp/src/components/common/FormGenerator/index.tsx @@ -3,8 +3,8 @@ import * as RA from "ramda-adjunct"; import { v4 as uuidv4 } from "uuid"; import { FieldValues, FormState, Path } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { SxProps, Theme } from "@mui/material"; -import { Fragment, ReactNode, useMemo } from "react"; +import { Box, SxProps, Theme } from "@mui/material"; +import { ReactNode, useMemo } from "react"; import SelectFE, { SelectFEProps } from "../fieldEditors/SelectFE"; import StringFE from "../fieldEditors/StringFE"; import Fieldset from "../Fieldset"; @@ -73,6 +73,7 @@ function formateFieldset(fieldset: IFieldsetType) { return { ...otherProps, fields: formattedFields, id: uuidv4() }; } +// TODO Refactor BindingConstraints Form and remove this garbage code ASAP. export default function FormGenerator( props: FormGeneratorProps, ) { @@ -95,6 +96,7 @@ export default function FormGenerator( legend={ RA.isString(fieldset.legend) ? t(fieldset.legend) : fieldset.legend } + sx={{ py: 1 }} > {fieldset.fields.map((field) => { const { id, path, rules, type, required, ...otherProps } = field; @@ -102,16 +104,24 @@ export default function FormGenerator( ? rules(field.name, path, required, defaultValues) : undefined; return ( - + {R.cond([ [ R.equals("text"), () => ( ), ], @@ -120,7 +130,8 @@ export default function FormGenerator( () => ( @@ -141,7 +152,8 @@ export default function FormGenerator( () => ( @@ -156,14 +168,15 @@ export default function FormGenerator( .options || [] } {...otherProps} - variant="filled" + variant="outlined" + size="small" control={control} rules={vRules} /> ), ], ])(type)} - + ); })}
    diff --git a/webapp/src/components/common/SelectSingle.tsx b/webapp/src/components/common/SelectSingle.tsx index e0a4f212fa..2ada882027 100644 --- a/webapp/src/components/common/SelectSingle.tsx +++ b/webapp/src/components/common/SelectSingle.tsx @@ -4,13 +4,14 @@ import { MenuItem, Select, SelectChangeEvent, + SelectProps, SxProps, Theme, } from "@mui/material"; import { useTranslation } from "react-i18next"; import { GenericInfo } from "../../common/types"; -interface Props { +interface Props extends SelectProps { name: string; label?: string; list: GenericInfo[]; @@ -40,12 +41,9 @@ function SelectSingle(props: Props) { } = props; const [t] = useTranslation(); - const basicHandleChange = (event: SelectChangeEvent) => { - const { - target: { value }, - } = event; + const basicHandleChange = (e: SelectChangeEvent) => { if (setValue) { - setValue(value); + setValue(e.target.value as string); } }; @@ -57,6 +55,7 @@ function SelectSingle(props: Props) { {label}