From 5091be886611dbe6638756a9ef51b97f2df83263 Mon Sep 17 00:00:00 2001 From: TLAIDI Date: Wed, 23 Aug 2023 17:05:30 +0200 Subject: [PATCH] feat(st-storage): add ST Storage API endpoints (#1697) feat(st-storage): add ST Storage API endpoints (#1697) --- antarest/core/exceptions.py | 35 + antarest/study/business/st_storage_manager.py | 599 +++++++++++++++++ antarest/study/business/utils.py | 56 +- antarest/study/service.py | 2 + .../rawstudy/model/filesystem/config/files.py | 2 +- .../rawstudy/model/filesystem/config/model.py | 2 +- .../model/filesystem/config/st_storage.py | 5 +- .../input/st_storage/clusters/area/list.py | 4 +- .../root/input/st_storage/series/area/area.py | 6 +- .../model/command/create_st_storage.py | 12 +- antarest/study/web/study_data_blueprint.py | 376 ++++++++++- .../study_data_blueprint/test_st_storage.py | 422 ++++++++++++ tests/study/business/conftest.py | 21 + .../study/business/test_st_storage_manager.py | 614 ++++++++++++++++++ .../model/command/test_create_st_storage.py | 103 +-- 15 files changed, 2151 insertions(+), 108 deletions(-) create mode 100644 antarest/study/business/st_storage_manager.py create mode 100644 tests/integration/study_data_blueprint/test_st_storage.py create mode 100644 tests/study/business/conftest.py create mode 100644 tests/study/business/test_st_storage_manager.py diff --git a/antarest/core/exceptions.py b/antarest/core/exceptions.py index 7b51171165..f49d3edd19 100644 --- a/antarest/core/exceptions.py +++ b/antarest/core/exceptions.py @@ -8,6 +8,41 @@ class ShouldNotHappenException(Exception): pass +class STStorageFieldsNotFoundError(HTTPException): + """Fields of the short-term storage are not found""" + + def __init__(self, storage_id: str) -> None: + detail = f"Fields of storage '{storage_id}' not found" + super().__init__(HTTPStatus.NOT_FOUND, detail) + + def __str__(self) -> str: + return self.detail + + +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" + super().__init__(HTTPStatus.NOT_FOUND, detail) + + def __str__(self) -> str: + return self.detail + + +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:" + super().__init__(HTTPStatus.NOT_FOUND, 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/st_storage_manager.py b/antarest/study/business/st_storage_manager.py new file mode 100644 index 0000000000..6ff24e1920 --- /dev/null +++ b/antarest/study/business/st_storage_manager.py @@ -0,0 +1,599 @@ +import functools +import json +import operator +from typing import Any, Dict, List, Mapping, MutableMapping, Sequence + +import numpy as np +from antarest.core.exceptions import ( + STStorageConfigNotFoundError, + STStorageFieldsNotFoundError, + STStorageMatrixNotFoundError, +) +from antarest.study.business.utils import ( + AllOptionalMetaclass, + FormFieldsBaseModel, + execute_or_add_commands, +) +from antarest.study.model import Study +from antarest.study.storage.rawstudy.model.filesystem.config.st_storage import ( + STStorageConfig, + STStorageGroup, +) +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.update_config import ( + UpdateConfig, +) +from pydantic import BaseModel, Extra, Field, root_validator, validator +from typing_extensions import Literal + +_HOURS_IN_YEAR = 8760 + + +class FormBaseModel(FormFieldsBaseModel): + """ + A foundational model for all form-based models, providing common configurations. + """ + + class Config: + validate_assignment = True + allow_population_by_field_name = True + + +class StorageCreation(FormBaseModel): + """ + Model representing the form used to create a new short-term storage entry. + """ + + name: str = Field( + description="Name of the storage.", + regex=r"[a-zA-Z0-9_(),& -]+", + ) + group: STStorageGroup = Field( + description="Energy storage system group.", + ) + + class Config: + @staticmethod + def schema_extra(schema: MutableMapping[str, Any]) -> None: + schema["example"] = StorageCreation( + name="Siemens Battery", + group=STStorageGroup.BATTERY, + ) + + @property + def to_config(self) -> STStorageConfig: + values = self.dict(by_alias=False) + return STStorageConfig(**values) + + +class StorageUpdate(StorageCreation, metaclass=AllOptionalMetaclass): + """set name, group as optional fields""" + + +class StorageInput(StorageUpdate): + """ + Model representing the form used to edit existing short-term storage details. + """ + + injection_nominal_capacity: float = Field( + description="Injection nominal capacity (MW)", + ge=0, + ) + withdrawal_nominal_capacity: float = Field( + description="Withdrawal nominal capacity (MW)", + ge=0, + ) + reservoir_capacity: float = Field( + description="Reservoir capacity (MWh)", + ge=0, + ) + efficiency: float = Field( + description="Efficiency of the storage system", + ge=0, + le=1, + ) + initial_level: float = Field( + description="Initial level of the storage system", + ge=0, + ) + initial_level_optim: bool = Field( + description="Flag indicating if the initial level is optimized", + ) + + class Config: + @staticmethod + def schema_extra(schema: MutableMapping[str, Any]) -> None: + schema["example"] = StorageInput( + name="Siemens Battery", + group=STStorageGroup.BATTERY, + injection_nominal_capacity=150, + withdrawal_nominal_capacity=150, + reservoir_capacity=600, + efficiency=0.94, + initial_level_optim=True, + ) + + +class StorageOutput(StorageInput): + """ + Model representing the form used to display the details of a short-term storage entry. + """ + + id: str = Field( + description="Short-term storage ID", + regex=r"[a-zA-Z0-9_(),& -]+", + ) + + class Config: + @staticmethod + def schema_extra(schema: MutableMapping[str, Any]) -> None: + schema["example"] = StorageOutput( + id="siemens_battery", + name="Siemens Battery", + group=STStorageGroup.BATTERY, + injection_nominal_capacity=150, + withdrawal_nominal_capacity=150, + reservoir_capacity=600, + efficiency=0.94, + initial_level_optim=True, + ) + + @classmethod + def from_config( + cls, storage_id: str, config: Mapping[str, Any] + ) -> "StorageOutput": + storage = STStorageConfig(**config, id=storage_id) + values = storage.dict(by_alias=False) + return cls(**values) + + +# ============= +# Time series +# ============= + + +class STStorageMatrix(BaseModel): + """ + Short-Term Storage Matrix Model. + + This model represents a matrix associated with short-term storage + and validates its integrity against specific conditions. + + Attributes: + data: The 2D-array matrix containing time series values. + index: List of lines for the data matrix. + columns: List of columns for the data matrix. + """ + + class Config: + extra = Extra.forbid + + data: List[List[float]] + index: List[int] + columns: List[int] + + @validator("data") + def validate_time_series( + cls, data: List[List[float]] + ) -> List[List[float]]: + """ + Validator to check the integrity of the time series data. + + Note: + - The time series must have a shape of (8760, 1). + - Time series values must not be empty or contain NaN values. + """ + array = np.array(data) + if array.size == 0: + raise ValueError("time series must not be empty") + if array.shape != (_HOURS_IN_YEAR, 1): + raise ValueError( + f"time series must have shape ({_HOURS_IN_YEAR}, 1)" + ) + if np.any(np.isnan(array)): + raise ValueError("time series must not contain NaN values") + return data + + +# noinspection SpellCheckingInspection +class STStorageMatrices(BaseModel): + """ + Short-Term Storage Matrices Validation Model. + + This model is designed to validate constraints on short-term storage matrices. + + Attributes: + pmax_injection: Matrix representing maximum injection values. + pmax_withdrawal: Matrix representing maximum withdrawal values. + lower_rule_curve: Matrix representing lower rule curve values. + upper_rule_curve: Matrix representing upper rule curve values. + inflows: Matrix representing inflow values. + """ + + class Config: + extra = Extra.forbid + + pmax_injection: STStorageMatrix + pmax_withdrawal: STStorageMatrix + lower_rule_curve: STStorageMatrix + upper_rule_curve: STStorageMatrix + inflows: STStorageMatrix + + @validator( + "pmax_injection", + "pmax_withdrawal", + "lower_rule_curve", + "upper_rule_curve", + ) + def validate_time_series(cls, matrix: STStorageMatrix) -> STStorageMatrix: + """ + Validator to check if matrix values are within the range [0, 1]. + """ + array = np.array(matrix.data) + if np.any((array < 0) | (array > 1)): + raise ValueError("Matrix values should be between 0 and 1") + return matrix + + @root_validator() + def validate_rule_curve( + cls, values: MutableMapping[str, STStorageMatrix] + ) -> MutableMapping[str, STStorageMatrix]: + """ + Validator to ensure 'lower_rule_curve' values are less than + or equal to 'upper_rule_curve' values. + """ + if "lower_rule_curve" in values and "upper_rule_curve" in values: + lower_rule_curve = values["lower_rule_curve"] + upper_rule_curve = values["upper_rule_curve"] + lower_array = np.array(lower_rule_curve.data, dtype=np.float64) + 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'" + ) + return values + + +# noinspection SpellCheckingInspection +STStorageTimeSeries = Literal[ + "pmax_injection", + "pmax_withdrawal", + "lower_rule_curve", + "upper_rule_curve", + "inflows", +] + +# ============================ +# Short-term storage manager +# ============================ + + +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}" +) + + +class STStorageManager: + """ + Manage short-term storage configuration in a study + """ + + def __init__(self, storage_service: StudyStorageService): + self.storage_service = storage_service + + def create_storage( + self, + study: Study, + area_id: str, + form: StorageCreation, + ) -> StorageOutput: + """ + Create a new short-term storage configuration for the given `study`, `area_id`, and `form fields`. + + Args: + study: The study object. + area_id: The area ID of the short-term storage. + form: Form used to Create a new short-term 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.storage_service.get_storage(study).get_raw(study) + execute_or_add_commands( + study, + file_study, + [command], + self.storage_service, + ) + + return self.get_storage(study, area_id, storage_id=storage.id) + + def get_storages( + self, + study: Study, + area_id: str, + ) -> Sequence[StorageOutput]: + """ + Get the list of short-term storage configurations for the given `study`, and `area_id`. + + Args: + study: The study object. + area_id: The area ID of the short-term storage. + + Returns: + The list of forms used to display the short-term storages. + """ + # fmt: off + file_study = self.storage_service.get_storage(study).get_raw(study) + path = STORAGE_LIST_PATH.format(area_id=area_id, storage_id="")[:-1] + try: + config = file_study.tree.get(path.split("/"), depth=3) + except KeyError: + raise STStorageConfigNotFoundError(study.id, area_id) from None + # fmt: on + # Sort STStorageConfig by groups and then by name + order_by = operator.attrgetter("group", "name") + all_configs = sorted( + ( + STStorageConfig(id=storage_id, **options) + for storage_id, options in config.items() + ), + key=order_by, + ) + return tuple( + StorageOutput(**config.dict(by_alias=False)) + for config in all_configs + ) + + def get_storage( + self, + study: Study, + area_id: str, + storage_id: str, + ) -> StorageOutput: + """ + Get short-term storage configuration for the given `study`, `area_id`, and `storage_id`. + + Args: + study: The study object. + area_id: The area ID of the short-term storage. + storage_id: The ID of the short-term storage. + + Returns: + Form used to display and edit a short-term storage. + """ + # fmt: off + file_study = self.storage_service.get_storage(study).get_raw(study) + 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 + return StorageOutput.from_config(storage_id, config) + + def update_storage( + self, + study: Study, + area_id: str, + storage_id: str, + form: StorageInput, + ) -> StorageOutput: + """ + Set short-term storage configuration for the given `study`, `area_id`, and `storage_id`. + + Args: + study: The study object. + area_id: The area ID of the short-term storage. + storage_id: The ID of the short-term storage. + form: Form used to Update a short-term storage. + Returns: + Updated form of short-term storage. + """ + file_study = self.storage_service.get_storage(study).get_raw(study) + 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 = STStorageConfig(**values) + + # use Python values to synchronize Config and Form values + old_values = old_config.dict(exclude={"id"}) + new_values = form.dict(by_alias=False, exclude_none=True) + updated = {**old_values, **new_values} + new_config = STStorageConfig(**updated, id=storage_id) + new_data = json.loads(new_config.json(by_alias=True, exclude={"id"})) + + # create the dict containing the old values (excluding defaults), + # the updated values (including defaults) + data: Dict[str, Any] = {} + for field_name, field in new_config.__fields__.items(): + if field_name in {"id"}: + continue + value = getattr(new_config, field_name) + if field_name in new_values or value != field.get_default(): + # use the JSON-converted value + data[field.alias] = new_data[field.alias] + + # create the update config command with the modified data + command = UpdateConfig( + target=path, + data=data, + command_context=self.storage_service.variant_study_service.command_factory.command_context, + ) + file_study = self.storage_service.get_storage(study).get_raw(study) + execute_or_add_commands( + study, file_study, [command], self.storage_service + ) + + values = new_config.dict(by_alias=False) + return StorageOutput(**values) + + def delete_storage( + self, + study: Study, + area_id: str, + storage_id: str, + ) -> None: + """ + Delete a short-term storage configuration form the given study and area_id. + + Args: + study: The study object. + area_id: The area ID of the short-term storage. + storage_id: The ID of the short-term storage to remove. + """ + command = RemoveSTStorage( + area_id=area_id, + storage_id=storage_id, + command_context=self.storage_service.variant_study_service.command_factory.command_context, + ) + file_study = self.storage_service.get_storage(study).get_raw(study) + execute_or_add_commands( + study, file_study, [command], self.storage_service + ) + + def get_matrix( + self, + study: Study, + area_id: str, + storage_id: str, + ts_name: STStorageTimeSeries, + ) -> STStorageMatrix: + """ + Get the time series `ts_name` for the given `study`, `area_id`, and `storage_id`. + + Args: + study: The study object. + area_id: The area ID of the short-term storage. + storage_id: The ID of the short-term storage. + ts_name: Name of the time series to get. + + Returns: + STStorageMatrix object containing the short-term storage time series. + """ + matrix = self._get_matrix_obj(study, area_id, storage_id, ts_name) + return STStorageMatrix(**matrix) + + def _get_matrix_obj( + self, + study: Study, + area_id: str, + storage_id: str, + ts_name: STStorageTimeSeries, + ) -> MutableMapping[str, Any]: + # fmt: off + file_study = self.storage_service.get_storage(study).get_raw(study) + 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 + return matrix + # fmt: on + + def update_matrix( + self, + study: Study, + area_id: str, + storage_id: str, + ts_name: STStorageTimeSeries, + ts: STStorageMatrix, + ) -> None: + """ + Update the time series `ts_name` for the given `study`, `area_id`, and `storage_id`. + + Args: + study: The study object. + area_id: The area ID of the short-term storage. + storage_id: The ID of the short-term storage. + 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 + ) + + def _save_matrix_obj( + self, + study: Study, + area_id: str, + storage_id: str, + ts_name: STStorageTimeSeries, + matrix_obj: Dict[str, Any], + ) -> None: + # fmt: off + file_study = self.storage_service.get_storage(study).get_raw(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 + # fmt: on + + def validate_matrices( + self, + study: Study, + area_id: str, + storage_id: str, + ) -> bool: + """ + Validate the short-term storage matrices. + + This function validates the integrity of various matrices + associated with a short-term storage in a given study and area. + + Note: + - All matrices except "inflows" should have values between 0 and 1 (inclusive). + - The values in the "lower_rule_curve" matrix should be less than or equal to + the corresponding values in the "upper_rule_curve" matrix. + + Args: + study: The study object. + area_id: The area ID of the short-term storage. + storage_id: The ID of the short-term storage to validate. + + Raises: + ValidationError: If any of the matrices is invalid. + + Returns: + bool: True if validation is successful. + """ + # Create a partial function to retrieve matrix objects + get_matrix_obj = functools.partial( + self._get_matrix_obj, study, area_id, storage_id + ) + + # Validate matrices by constructing the `STStorageMatrices` object + # noinspection SpellCheckingInspection + STStorageMatrices( + pmax_injection=get_matrix_obj("pmax_injection"), + pmax_withdrawal=get_matrix_obj("pmax_withdrawal"), + lower_rule_curve=get_matrix_obj("lower_rule_curve"), + upper_rule_curve=get_matrix_obj("upper_rule_curve"), + inflows=get_matrix_obj("inflows"), + ) + + # Validation successful + return True diff --git a/antarest/study/business/utils.py b/antarest/study/business/utils.py index a94145e9d4..e25756b03b 100644 --- a/antarest/study/business/utils.py +++ b/antarest/study/business/utils.py @@ -1,12 +1,22 @@ -from typing import List, Sequence, TypedDict, Any, Optional, Callable +from typing import ( + Any, + Callable, + MutableSequence, + Optional, + Sequence, + TypedDict, + Type, + Tuple, + Dict, +) -from pydantic import BaseModel, Extra +import pydantic from antarest.core.exceptions import CommandApplicationError from antarest.core.jwt import DEFAULT_ADMIN_USER from antarest.core.requests import RequestParameters from antarest.core.utils.string import to_camel_case -from antarest.study.model import Study, RawStudy +from antarest.study.model import RawStudy, Study from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy from antarest.study.storage.storage_service import StudyStorageService from antarest.study.storage.utils import is_managed @@ -14,7 +24,9 @@ transform_command_to_dto, ) from antarest.study.storage.variantstudy.model.command.icommand import ICommand +from pydantic import BaseModel, Extra +# noinspection SpellCheckingInspection GENERAL_DATA_PATH = "settings/generaldata" @@ -25,7 +37,7 @@ def execute_or_add_commands( storage_service: StudyStorageService, ) -> None: if isinstance(study, RawStudy): - executed_commands: List[ICommand] = [] + executed_commands: MutableSequence[ICommand] = [] for command in commands: result = command.apply(file_study) if not result.status: @@ -79,3 +91,39 @@ class FieldInfo(TypedDict, total=False): encode: Optional[Callable[[Any], Any]] # (encoded_value, current_value) -> decoded_value decode: Optional[Callable[[Any, Optional[Any]], Any]] + + +class AllOptionalMetaclass(pydantic.main.ModelMetaclass): + """ + Metaclass that makes all fields of a Pydantic model optional. + + This metaclass modifies the class's annotations to make all fields + optional by wrapping them with the `Optional` type. + + Usage: + class MyModel(BaseModel, metaclass=AllOptionalMetaclass): + field1: str + field2: int + ... + + The fields defined in the model will be automatically converted to optional + fields, allowing instances of the model to be created even if not all fields + are provided during initialization. + """ + + def __new__( + cls: Type["AllOptionalMetaclass"], + name: str, + bases: Tuple[Type[Any], ...], + namespaces: Dict[str, Any], + **kwargs: Dict[str, Any], + ) -> Any: + annotations = namespaces.get("__annotations__", {}) + for base in bases: + annotations.update(base.__annotations__) + for field, field_type in annotations.items(): + if not field.startswith("__"): + # Optional fields are correctly handled + annotations[field] = Optional[annotations[field]] + namespaces["__annotations__"] = annotations + return super().__new__(cls, name, bases, namespaces) diff --git a/antarest/study/service.py b/antarest/study/service.py index 200a32123b..9715dae13a 100644 --- a/antarest/study/service.py +++ b/antarest/study/service.py @@ -93,6 +93,7 @@ from antarest.study.business.scenario_builder_management import ( ScenarioBuilderManager, ) +from antarest.study.business.st_storage_manager import STStorageManager from antarest.study.business.table_mode_management import TableModeManager from antarest.study.business.thematic_trimming_management import ( ThematicTrimmingManager, @@ -325,6 +326,7 @@ def __init__( self.properties_manager = PropertiesManager(self.storage_service) self.renewable_manager = RenewableManager(self.storage_service) self.thermal_manager = ThermalManager(self.storage_service) + self.st_storage_manager = STStorageManager(self.storage_service) self.ts_config_manager = TimeSeriesConfigManager(self.storage_service) self.table_mode_manager = TableModeManager(self.storage_service) self.playlist_manager = PlaylistManager(self.storage_service) diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/files.py b/antarest/study/storage/rawstudy/model/filesystem/config/files.py index 87789b429f..f9bf2fbd5d 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/config/files.py +++ b/antarest/study/storage/rawstudy/model/filesystem/config/files.py @@ -399,7 +399,7 @@ def _parse_st_storage(root: Path, area: str) -> List[STStorageConfig]: file_type=FileType.SIMPLE_INI, ) return [ - STStorageConfig(**dict(values, id=storage_id)) + STStorageConfig(**values, id=storage_id) for storage_id, values in config_dict.items() ] diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/model.py b/antarest/study/storage/rawstudy/model/filesystem/config/model.py index 31374e44ee..80fe989a3a 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/config/model.py +++ b/antarest/study/storage/rawstudy/model/filesystem/config/model.py @@ -214,7 +214,7 @@ def get_thermal_names( ], ) - def get_st_storage_names(self, area: str) -> List[str]: + def get_st_storage_ids(self, area: str) -> List[str]: return self.cache.get( f"%st-storage%{area}", [s.id for s in self.areas[area].st_storages] ) diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/st_storage.py b/antarest/study/storage/rawstudy/model/filesystem/config/st_storage.py index 077f591d54..4301fd71a6 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/config/st_storage.py +++ b/antarest/study/storage/rawstudy/model/filesystem/config/st_storage.py @@ -40,12 +40,11 @@ class Config: extra = Extra.forbid allow_population_by_field_name = True - # The `id` field is a calculated field that is excluded when converting - # the model to a dictionary or JSON format (`model_dump`). + # The `id` field is a calculated from the `name` if not provided. + # This value must be stored in the config cache. id: str = Field( description="Short-term storage ID", regex=r"[a-zA-Z0-9_(),& -]+", - exclude=True, ) name: str = Field( description="Short-term storage name", diff --git a/antarest/study/storage/rawstudy/model/filesystem/root/input/st_storage/clusters/area/list.py b/antarest/study/storage/rawstudy/model/filesystem/root/input/st_storage/clusters/area/list.py index f47ac522a9..70b14d266c 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/root/input/st_storage/clusters/area/list.py +++ b/antarest/study/storage/rawstudy/model/filesystem/root/input/st_storage/clusters/area/list.py @@ -27,7 +27,7 @@ def __init__( # - a withdrawal nominal capacity (double > 0) # - an injection nominal capacity (double > 0) types = { - st_storage: dict - for st_storage in config.get_st_storage_names(area) + st_storage_id: dict + for st_storage_id in config.get_st_storage_ids(area) } super().__init__(context, config, types) diff --git a/antarest/study/storage/rawstudy/model/filesystem/root/input/st_storage/series/area/area.py b/antarest/study/storage/rawstudy/model/filesystem/root/input/st_storage/series/area/area.py index ee1e238819..396342a4c6 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/root/input/st_storage/series/area/area.py +++ b/antarest/study/storage/rawstudy/model/filesystem/root/input/st_storage/series/area/area.py @@ -25,9 +25,9 @@ def __init__( def build(self) -> TREE: children: TREE = { - st_storage: InputSTStorageAreaStorage( - self.context, self.config.next_file(st_storage) + st_storage_id: InputSTStorageAreaStorage( + self.context, self.config.next_file(st_storage_id) ) - for st_storage in self.config.get_st_storage_names(self.area) + for st_storage_id in self.config.get_st_storage_ids(self.area) } return children diff --git a/antarest/study/storage/variantstudy/model/command/create_st_storage.py b/antarest/study/storage/variantstudy/model/command/create_st_storage.py index b029336fab..6c04bc1f0a 100644 --- a/antarest/study/storage/variantstudy/model/command/create_st_storage.py +++ b/antarest/study/storage/variantstudy/model/command/create_st_storage.py @@ -253,7 +253,11 @@ def _apply(self, study_data: FileStudy) -> CommandOutput: ["input", "st-storage", "clusters", self.area_id, "list"] ) config[self.storage_id] = json.loads( - self.parameters.json(by_alias=True) + self.parameters.json( + by_alias=True, + exclude={"id"}, + exclude_defaults=True, + ) ) new_data: JSON = { @@ -283,7 +287,9 @@ def to_dto(self) -> CommandDTO: Returns: The DTO object representing the current command. """ - parameters = json.loads(self.parameters.json(by_alias=True)) + parameters = json.loads( + self.parameters.json(by_alias=True, exclude={"id"}) + ) return CommandDTO( action=self.command_name.value, args={ @@ -359,7 +365,7 @@ def _create_diff(self, other: "ICommand") -> List["ICommand"]: ] if self.parameters != other.parameters: data: Dict[str, Any] = json.loads( - other.parameters.json(by_alias=True) + other.parameters.json(by_alias=True, exclude={"id"}) ) commands.append( UpdateConfig( diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index 610d61ae82..868e298c85 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -1,6 +1,6 @@ import logging from http import HTTPStatus -from typing import Any, Dict, List, Optional, Union, cast +from typing import Any, Dict, List, Optional, Union, cast, Sequence from antarest.core.config import Config from antarest.core.jwt import JWTUser @@ -8,9 +8,7 @@ from antarest.core.requests import RequestParameters from antarest.core.utils.web import APITag from antarest.login.auth import Auth -from antarest.matrixstore.matrix_editor import ( - MatrixEditInstruction, -) +from antarest.matrixstore.matrix_editor import MatrixEditInstruction from antarest.study.business.adequacy_patch_management import ( AdequacyPatchFormFields, ) @@ -28,9 +26,16 @@ AreaUI, LayerInfoDTO, ) +from antarest.study.business.areas.hydro_management import ( + ManagementOptionsFormFields, +) from antarest.study.business.areas.properties_management import ( PropertiesFormFields, ) +from antarest.study.business.areas.renewable_management import ( + RenewableFormFields, +) +from antarest.study.business.areas.thermal_management import ThermalFormFields from antarest.study.business.binding_constraint_management import ( ConstraintTermDTO, UpdateBindingConstProps, @@ -46,16 +51,17 @@ DistrictUpdateDTO, ) from antarest.study.business.general_management import GeneralFormFields -from antarest.study.business.areas.hydro_management import ( - ManagementOptionsFormFields, -) 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.areas.renewable_management import ( - RenewableFormFields, +from antarest.study.business.st_storage_manager import ( + StorageCreation, + StorageInput, + StorageOutput, + STStorageMatrix, + STStorageTimeSeries, ) from antarest.study.business.table_mode_management import ( ColumnsModelTypes, @@ -64,7 +70,6 @@ from antarest.study.business.thematic_trimming_management import ( ThematicTrimmingFormFields, ) -from antarest.study.business.areas.thermal_management import ThermalFormFields from antarest.study.business.timeseries_config_management import TSFormFields from antarest.study.model import PatchArea, PatchCluster from antarest.study.service import StudyService @@ -315,7 +320,7 @@ def update_layer( ) params = RequestParameters(user=current_user) study = study_service.check_study_access( - uuid, StudyPermissionType.READ, params + uuid, StudyPermissionType.WRITE, params ) if name: study_service.areas.update_layer_name(study, layer_id, name) @@ -339,7 +344,7 @@ def remove_layer( ) params = RequestParameters(user=current_user) study = study_service.check_study_access( - uuid, StudyPermissionType.READ, params + uuid, StudyPermissionType.DELETE, params ) study_service.areas.remove_layer(study, layer_id) @@ -401,7 +406,7 @@ def update_district( ) params = RequestParameters(user=current_user) study = study_service.check_study_access( - uuid, StudyPermissionType.READ, params + uuid, StudyPermissionType.WRITE, params ) study_service.district_manager.update_district(study, district_id, dto) @@ -421,7 +426,7 @@ def remove_district( ) params = RequestParameters(user=current_user) study = study_service.check_study_access( - uuid, StudyPermissionType.READ, params + uuid, StudyPermissionType.WRITE, params ) study_service.district_manager.remove_district(study, district_id) @@ -970,7 +975,7 @@ def update_binding_constraint( ) params = RequestParameters(user=current_user) study = study_service.check_study_access( - uuid, StudyPermissionType.READ, params + uuid, StudyPermissionType.WRITE, params ) return ( study_service.binding_constraint_manager.update_binding_constraint( @@ -1577,4 +1582,345 @@ def set_thermal_form_values( study, area_id, cluster_id, form_fields ) + @bp.get( + path="/studies/{uuid}/areas/{area_id}/storages/{storage_id}", + tags=[APITag.study_data], + summary="Get the short-term storage properties", + response_model=StorageOutput, + ) + def get_st_storage( + uuid: str, + area_id: str, + storage_id: str, + current_user: JWTUser = Depends(auth.get_current_user), + ) -> StorageOutput: + """ + Retrieve the storages by given uuid and area id of a study. + + Args: + - `uuid`: The UUID of the study. + - `area_id`: The area ID of the study. + - `storage_id`: The storage ID of the study. + + Returns: One storage with the following attributes: + - `id`: The storage ID of the study. + - `name`: The name of the storage. + - `group`: The group of the storage. + - `injectionNominalCapacity`: The injection Nominal Capacity of the storage. + - `withdrawalNominalCapacity`: The withdrawal Nominal Capacity of the storage. + - `reservoirCapacity`: The reservoir capacity of the storage. + - `efficiency`: The efficiency of the storage. + - `initialLevel`: The initial Level of the storage. + - `initialLevelOptim`: The initial Level Optim of the storage. + + Permissions: + The user must have READ permission on the study. + """ + logger.info( + f"Getting values for study {uuid} and short term storage {storage_id}", + extra={"user": current_user.id}, + ) + params = RequestParameters(user=current_user) + study = study_service.check_study_access( + uuid, StudyPermissionType.READ, params + ) + return study_service.st_storage_manager.get_storage( + study, area_id, storage_id + ) + + @bp.get( + path="/studies/{uuid}/areas/{area_id}/storages", + tags=[APITag.study_data], + summary="Get the list of short-term storage properties", + response_model=Sequence[StorageOutput], + ) + def get_st_storages( + uuid: str, + area_id: str, + current_user: JWTUser = Depends(auth.get_current_user), + ) -> Sequence[StorageOutput]: + """ + Retrieve the short-term storages by given uuid and area ID of a study. + + Args: + - `uuid`: The UUID of the study. + - `area_id`: The area ID. + + Returns: A list of storages with the following attributes: + - `id`: The storage ID of the study. + - `name`: The name of the storage. + - `group`: The group of the storage. + - `injectionNominalCapacity`: The injection Nominal Capacity of the storage. + - `withdrawalNominalCapacity`: The withdrawal Nominal Capacity of the storage. + - `reservoirCapacity`: The reservoir capacity of the storage. + - `efficiency`: The efficiency of the storage. + - `initialLevel`: The initial Level of the storage. + - `initialLevelOptim`: The initial Level Optim of the storage. + + Permissions: + The user must have READ permission on the study. + """ + logger.info( + f"Getting storages for study {uuid} in a given area {area_id}", + extra={"user": current_user.id}, + ) + params = RequestParameters(user=current_user) + study = study_service.check_study_access( + uuid, StudyPermissionType.READ, params + ) + return study_service.st_storage_manager.get_storages(study, area_id) + + @bp.get( + path="/studies/{uuid}/areas/{area_id}/storages/{storage_id}/series/{ts_name}", + tags=[APITag.study_data], + summary="Get a short-term storage time series", + response_model=STStorageMatrix, + ) + def get_st_storage_matrix( + uuid: str, + area_id: str, + storage_id: str, + ts_name: STStorageTimeSeries, + current_user: JWTUser = Depends(auth.get_current_user), + ) -> STStorageMatrix: + """ + Retrieve the matrix of the specified time series for the given short-term storage. + + Args: + - `uuid`: The UUID of the study. + - `area_id`: The area ID. + - `storage_id`: The ID of the short-term storage. + - `ts_name`: The name of the time series to retrieve. + + Returns: The time series matrix with the following attributes: + - `index`: A list of 0-indexed time series lines (8760 lines). + - `columns`: A list of 0-indexed time series columns (1 column). + - `data`: A 2D-array matrix representing the time series. + + Permissions: + - User must have READ permission on the study. + """ + logger.info( + f"Retrieving time series for study {uuid} and short-term storage {storage_id}", + extra={"user": current_user.id}, + ) + params = RequestParameters(user=current_user) + study = study_service.check_study_access( + uuid, StudyPermissionType.READ, params + ) + return study_service.st_storage_manager.get_matrix( + study, area_id, storage_id, ts_name + ) + + @bp.put( + path="/studies/{uuid}/areas/{area_id}/storages/{storage_id}/series/{ts_name}", + tags=[APITag.study_data], + summary="Update a short-term storage time series", + ) + def update_st_storage_matrix( + uuid: str, + area_id: str, + storage_id: str, + ts_name: STStorageTimeSeries, + ts: STStorageMatrix, + current_user: JWTUser = Depends(auth.get_current_user), + ) -> None: + """ + Update the matrix of the specified time series for the given short-term storage. + + Args: + - `uuid`: The UUID of the study. + - `area_id`: The area ID. + - `storage_id`: The ID of the short-term storage. + - `ts_name`: The name of the time series to retrieve. + - `ts`: The time series matrix to update. + + Permissions: + - User must have WRITE permission on the study. + """ + logger.info( + f"Update time series for study {uuid} and short-term storage {storage_id}", + extra={"user": current_user.id}, + ) + params = RequestParameters(user=current_user) + study = study_service.check_study_access( + uuid, StudyPermissionType.WRITE, params + ) + study_service.st_storage_manager.update_matrix( + study, area_id, storage_id, ts_name, ts + ) + + @bp.get( + path="/studies/{uuid}/areas/{area_id}/storages/{storage_id}/validate", + tags=[APITag.study_data], + summary="Validate all the short-term storage time series", + ) + def validate_st_storage_matrices( + uuid: str, + area_id: str, + storage_id: str, + current_user: JWTUser = Depends(auth.get_current_user), + ) -> bool: + """ + Validate the consistency of all time series for the given short-term storage. + + Args: + - `uuid`: The UUID of the study. + - `area_id`: The area ID. + - `storage_id`: The ID of the short-term storage. + + Permissions: + - User must have READ permission on the study. + """ + logger.info( + f"Validating time series for study {uuid} and short-term storage {storage_id}", + extra={"user": current_user.id}, + ) + params = RequestParameters(user=current_user) + study = study_service.check_study_access( + uuid, StudyPermissionType.READ, params + ) + return study_service.st_storage_manager.validate_matrices( + study, area_id, storage_id + ) + + @bp.post( + path="/studies/{uuid}/areas/{area_id}/storages", + tags=[APITag.study_data], + summary="Create a new short-term storage in an area", + response_model=StorageOutput, + ) + def create_st_storage( + uuid: str, + area_id: str, + form: StorageCreation, + current_user: JWTUser = Depends(auth.get_current_user), + ) -> StorageOutput: + """ + Create a new short-term storage in an area. + + Args: + - `uuid`: The UUID of the study. + - `area_id`: The area ID. + - `form`: The name and the group(PSP_open, PSP_closed, Pondage, Battery, Other1, Other2, Other3, Other4, Other5) + of the storage that we want to create. + + Returns: New storage with the following attributes: + - `id`: The storage ID of the study. + - `name`: The name of the storage. + - `group`: The group of the storage. + - `injectionNominalCapacity`: The injection Nominal Capacity of the storage. + - `withdrawalNominalCapacity`: The withdrawal Nominal Capacity of the storage. + - `reservoirCapacity`: The reservoir capacity of the storage. + - `efficiency`: The efficiency of the storage. + - `initialLevel`: The initial Level of the storage. + - `initialLevelOptim`: The initial Level Optim of the storage. + + Permissions: + - User must have READ/WRITE permission on the study. + """ + + logger.info( + f"Create short-term storage from {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 + ) + return study_service.st_storage_manager.create_storage( + study, area_id, form + ) + + @bp.patch( + path="/studies/{uuid}/areas/{area_id}/storages/{storage_id}", + tags=[APITag.study_data], + summary="Update the short-term storage properties", + ) + def update_st_storage( + uuid: str, + area_id: str, + storage_id: str, + form: StorageInput, + current_user: JWTUser = Depends(auth.get_current_user), + ) -> StorageOutput: + """ + Update short-term storage of a study. + + Args: + - `uuid`: The UUID of the study. + - `area_id`: The area ID. + - `storage_id`: The storage id of the study that we want to update. + - `form`: The characteristic of the storage that we can update: + - `name`: The name of the updated storage. + - `group`: The group of the updated storage. + - `injectionNominalCapacity`: The injection Nominal Capacity of the updated storage. + - `withdrawalNominalCapacity`: The withdrawal Nominal Capacity of the updated storage. + - `reservoirCapacity`: The reservoir capacity of the updated storage. + - `efficiency`: The efficiency of the updated storage + - `initialLevel`: The initial Level of the updated storage + - `initialLevelOptim`: The initial Level Optim of the updated storage + + Returns: The updated storage with the following attributes: + - `name`: The name of the updated storage. + - `group`: The group of the updated storage. + - `injectionNominalCapacity`: The injection Nominal Capacity of the updated storage. + - `withdrawalNominalCapacity`: The withdrawal Nominal Capacity of the updated storage. + - `reservoirCapacity`: The reservoir capacity of the updated storage. + - `efficiency`: The efficiency of the updated storage + - `initialLevel`: The initial Level of the updated storage + - `initialLevelOptim`: The initial Level Optim of the updated storage + - `id`: The storage ID of the study that we want to update. + + Permissions: + - User must have READ/WRITE permission on the study. + """ + + logger.info( + f"Update short-term storage {storage_id} from {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 + ) + return study_service.st_storage_manager.update_storage( + study, area_id, storage_id, form + ) + + @bp.delete( + path="/studies/{uuid}/areas/{area_id}/storages/{storage_id}", + tags=[APITag.study_data], + summary="Remove a short-term storage from an area", + status_code=HTTPStatus.NO_CONTENT, + ) + def delete_st_storage( + uuid: str, + area_id: str, + storage_id: str, + current_user: JWTUser = Depends(auth.get_current_user), + ) -> None: + """ + Delete a short-term storage from an area. + + Args: + - `uuid`: The UUID of the study. + - `area_id`: The area ID. + - `storage_id`: The storage id of the study that we want to delete. + + Permissions: + - User must have DELETED permission on the study. + """ + logger.info( + f"Delete short-term storage {storage_id} from {area_id} for study {uuid}", + extra={"user": current_user.id}, + ) + params = RequestParameters(user=current_user) + study = study_service.check_study_access( + uuid, StudyPermissionType.DELETE, params + ) + study_service.st_storage_manager.delete_storage( + study, area_id, storage_id + ) + return bp diff --git a/tests/integration/study_data_blueprint/test_st_storage.py b/tests/integration/study_data_blueprint/test_st_storage.py new file mode 100644 index 0000000000..8af3fcdc84 --- /dev/null +++ b/tests/integration/study_data_blueprint/test_st_storage.py @@ -0,0 +1,422 @@ +import json +import re + +import numpy as np +import pytest +from starlette.testclient import TestClient + +from antarest.core.tasks.model import TaskStatus +from antarest.study.storage.rawstudy.model.filesystem.config.model import ( + transform_name_to_id, +) +from tests.integration.utils import wait_task_completion # + + +@pytest.mark.unit_test +class TestSTStorage: + # noinspection GrazieInspection + """ + Test the end points related to short term storage. + + Those tests use the "examples/studies/STA-mini.zip" Study, + which contains the following areas: ["de", "es", "fr", "it"]. + """ + + def test_lifecycle__nominal( + self, + client: TestClient, + user_access_token: str, + study_id: str, + ) -> None: + """ + The purpose of this integration test is to test the endpoints + related to short-term storage management. + + To ensure functionality, the test needs to be performed on a study + in version 860 or higher. That's why we will upgrade the "STA-mini" + study, which is in an older version. + + We will test the creation of multiple short-term storages. + + We will test reading the properties of a short-term storage + and reading a matrix from a short-term storage. + + We will test reading the list of short-term storages and + verify that the list is properly ordered by group and name. + + We will test updating a short-term storage: + + - updating properties, + - updating a matrix. + + We will test the deletion of short-term storages. + """ + + # Upgrade study to version 860 + res = client.put( + f"/v1/studies/{study_id}/upgrade", + headers={"Authorization": f"Bearer {user_access_token}"}, + params={"target_version": 860}, + ) + res.raise_for_status() + task_id = res.json() + task = wait_task_completion(client, user_access_token, task_id) + assert task.status == TaskStatus.COMPLETED, task + + # creation with default values (only mandatory properties specified) + area_id = transform_name_to_id("FR") + siemens_battery = "Siemens Battery" + res = client.post( + f"/v1/studies/{study_id}/areas/{area_id}/storages", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={"name": siemens_battery, "group": "Battery"}, + ) + assert res.status_code == 200, res.json() + siemens_battery_id = res.json()["id"] + assert siemens_battery_id == transform_name_to_id(siemens_battery) + assert res.json() == { + "efficiency": 1.0, + "group": "Battery", + "id": siemens_battery_id, + "initialLevel": 0.0, + "initialLevelOptim": False, + "injectionNominalCapacity": 0.0, + "name": siemens_battery, + "reservoirCapacity": 0.0, + "withdrawalNominalCapacity": 0.0, + } + + # 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}"}, + ) + assert res.status_code == 200, res.json() + assert res.json() == { + "efficiency": 1.0, + "group": "Battery", + "id": siemens_battery_id, + "initialLevel": 0.0, + "initialLevelOptim": False, + "injectionNominalCapacity": 0.0, + "name": siemens_battery, + "reservoirCapacity": 0.0, + "withdrawalNominalCapacity": 0.0, + } + + # updating the matrix of a short-term storage + array = np.random.rand(8760, 1) * 1000 + 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(), + }, + ) + assert res.status_code == 200, res.json() + assert res.json() is None + + # 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}"}, + ) + assert res.status_code == 200, res.json() + matrix = res.json() + actual = np.array(matrix["data"], dtype=np.float64) + assert actual.all() == array.all() + + # 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}"}, + ) + assert res.status_code == 200, res.json() + assert res.json() is True + + # 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}"}, + ) + assert res.status_code == 200, res.json() + assert res.json() == [ + { + "efficiency": 1.0, + "group": "Battery", + "id": siemens_battery_id, + "initialLevel": 0.0, + "initialLevelOptim": False, + "injectionNominalCapacity": 0.0, + "name": siemens_battery, + "reservoirCapacity": 0.0, + "withdrawalNominalCapacity": 0.0, + } + ] + + # updating properties + res = client.patch( + f"/v1/studies/{study_id}/areas/{area_id}/storages/{siemens_battery_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={ + "name": "New Siemens Battery", + "reservoirCapacity": 2500, + }, + ) + assert res.status_code == 200, res.json() + assert json.loads(res.text) == { + "id": siemens_battery_id, + "name": "New Siemens Battery", + "group": "Battery", + "efficiency": 1.0, + "initialLevel": 0.0, + "initialLevelOptim": False, + "injectionNominalCapacity": 0.0, + "withdrawalNominalCapacity": 0.0, + "reservoirCapacity": 2500, + } + + res = client.get( + f"/v1/studies/{study_id}/areas/{area_id}/storages/{siemens_battery_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == 200, res.json() + assert res.json() == { + "id": siemens_battery_id, + "name": "New Siemens Battery", + "group": "Battery", + "efficiency": 1.0, + "initialLevel": 0.0, + "initialLevelOptim": False, + "injectionNominalCapacity": 0.0, + "withdrawalNominalCapacity": 0.0, + "reservoirCapacity": 2500, + } + + # updating properties + res = client.patch( + f"/v1/studies/{study_id}/areas/{area_id}/storages/{siemens_battery_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={ + "initialLevel": 5900, + "reservoirCapacity": 0, + }, + ) + assert res.status_code == 200, res.json() + assert json.loads(res.text) == { + "id": siemens_battery_id, + "name": "New Siemens Battery", + "group": "Battery", + "efficiency": 1.0, + "initialLevel": 5900, + "initialLevelOptim": False, + "injectionNominalCapacity": 0.0, + "withdrawalNominalCapacity": 0.0, + "reservoirCapacity": 0, + } + + res = client.get( + f"/v1/studies/{study_id}/areas/{area_id}/storages/{siemens_battery_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == 200, res.json() + assert res.json() == { + "id": siemens_battery_id, + "name": "New Siemens Battery", + "group": "Battery", + "efficiency": 1.0, + "initialLevel": 5900, + "initialLevelOptim": False, + "injectionNominalCapacity": 0.0, + "withdrawalNominalCapacity": 0.0, + "reservoirCapacity": 0, + } + + # deletion of short-term storages + res = client.delete( + f"/v1/studies/{study_id}/areas/{area_id}/storages/{siemens_battery_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == 204, res.json() + assert res.text == "null" + + # Check the removal + res = client.get( + f"/v1/studies/{study_id}/areas/{area_id}/storages/{siemens_battery_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + obj = res.json() + description = obj["description"] + assert siemens_battery_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 res.status_code == 404, res.json() + + # Check delete with the wrong value of area_id + bad_area_id = "bad_area" + res = client.delete( + f"/v1/studies/{study_id}/areas/{bad_area_id}/storages/{siemens_battery_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == 500, res.json() + obj = res.json() + description = obj["description"] + assert bad_area_id in description + assert re.search( + r"CommandName.REMOVE_ST_STORAGE", + description, + flags=re.IGNORECASE, + ) + + # Check delete with the wrong value of study_id + bad_study_id = "bad_study" + res = client.delete( + f"/v1/studies/{bad_study_id}/areas/{area_id}/storages/{siemens_battery_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + obj = res.json() + description = obj["description"] + assert res.status_code == 404, res.json() + assert bad_study_id in description + + # 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}"}, + ) + obj = res.json() + description = obj["description"] + assert bad_area_id in description + assert res.status_code == 404, res.json() + + # 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}"}, + ) + obj = res.json() + description = obj["description"] + assert res.status_code == 404, res.json() + assert bad_study_id in description + + # 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}"}, + json={"name": siemens_battery, "group": "Battery"}, + ) + obj = res.json() + description = obj["description"] + assert res.status_code == 404, res.json() + assert bad_study_id in description + + # 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}"}, + json={"name": siemens_battery, "group": "Battery"}, + ) + assert res.status_code == 500, res.json() + 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) + + # Check post with wrong group + res = client.post( + f"/v1/studies/{study_id}/areas/{bad_area_id}/storages", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={"name": siemens_battery, "group": "GroupFoo"}, + ) + assert res.status_code == 422, res.json() + obj = res.json() + description = obj["description"] + assert re.search( + r"not a valid enumeration member", description, flags=re.IGNORECASE + ) + + # Check the put 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}"}, + json={ + "efficiency": 1.0, + "initialLevel": 0.0, + "initialLevelOptim": True, + "injectionNominalCapacity": 2450, + "name": "New Siemens Battery", + "reservoirCapacity": 2500, + "withdrawalNominalCapacity": 2350, + }, + ) + assert res.status_code == 404, res.json() + obj = res.json() + description = obj["description"] + assert bad_area_id in description + assert re.search(r"not a child of ", description, flags=re.IGNORECASE) + + # Check the put with the wrong siemens_battery_id + res = client.patch( + f"/v1/studies/{study_id}/areas/{area_id}/storages/{siemens_battery_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={ + "efficiency": 1.0, + "initialLevel": 0.0, + "initialLevelOptim": True, + "injectionNominalCapacity": 2450, + "name": "New Siemens Battery", + "reservoirCapacity": 2500, + "withdrawalNominalCapacity": 2350, + }, + ) + assert res.status_code == 404, res.json() + obj = res.json() + description = obj["description"] + assert siemens_battery_id in description + assert re.search( + r"fields of storage", description, flags=re.IGNORECASE + ) + assert re.search(r"not found", description, flags=re.IGNORECASE) + + # Check the put 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}"}, + json={ + "efficiency": 1.0, + "initialLevel": 0.0, + "initialLevelOptim": True, + "injectionNominalCapacity": 2450, + "name": "New Siemens Battery", + "reservoirCapacity": 2500, + "withdrawalNominalCapacity": 2350, + }, + ) + assert res.status_code == 404, res.json() + obj = res.json() + description = obj["description"] + assert bad_study_id in description + + # Check the put with the wrong efficiency + res = client.patch( + f"/v1/studies/{bad_study_id}/areas/{area_id}/storages/{siemens_battery_id}", + headers={"Authorization": f"Bearer {user_access_token}"}, + json={ + "efficiency": 2.0, + "initialLevel": 0.0, + "initialLevelOptim": True, + "injectionNominalCapacity": 2450, + "name": "New Siemens Battery", + "reservoirCapacity": 2500, + "withdrawalNominalCapacity": 2350, + }, + ) + assert res.status_code == 422, res.json() diff --git a/tests/study/business/conftest.py b/tests/study/business/conftest.py new file mode 100644 index 0000000000..d3ffd30826 --- /dev/null +++ b/tests/study/business/conftest.py @@ -0,0 +1,21 @@ +import contextlib + +import pytest +from antarest.dbmodel import Base +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + + +@pytest.fixture(scope="function", name="db_engine") +def db_engine_fixture(): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + yield engine + engine.dispose() + + +@pytest.fixture(scope="function", name="db_session") +def db_session_fixture(db_engine): + make_session = sessionmaker(bind=db_engine) + with contextlib.closing(make_session()) as session: + yield session diff --git a/tests/study/business/test_st_storage_manager.py b/tests/study/business/test_st_storage_manager.py new file mode 100644 index 0000000000..0fe9dc1ef2 --- /dev/null +++ b/tests/study/business/test_st_storage_manager.py @@ -0,0 +1,614 @@ +import datetime +import io +import re +import uuid +from typing import Any, MutableMapping, Sequence, cast +from unittest.mock import Mock + +import numpy as np +import pytest +from antarest.core.exceptions import ( + STStorageConfigNotFoundError, + STStorageFieldsNotFoundError, + STStorageMatrixNotFoundError, +) +from antarest.core.model import PublicMode +from antarest.login.model import Group, User +from antarest.study.business.st_storage_manager import STStorageManager +from antarest.study.model import RawStudy, Study, StudyContentStatus +from antarest.study.storage.rawstudy.io.reader import IniReader +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.root.filestudytree import ( + FileStudyTree, +) +from antarest.study.storage.rawstudy.raw_study_service import RawStudyService +from antarest.study.storage.storage_service import StudyStorageService +from antarest.study.storage.variantstudy.command_factory import CommandFactory +from antarest.study.storage.variantstudy.model.command_context import ( + CommandContext, +) +from antarest.study.storage.variantstudy.variant_study_service import ( + VariantStudyService, +) +from pydantic import ValidationError +from sqlalchemy.orm.session import Session # type: ignore + +# noinspection SpellCheckingInspection +LIST_INI = """ +[storage1] +name = Storage1 +group = Battery +injectionnominalcapacity = 1500 +withdrawalnominalcapacity = 1500 +reservoircapacity = 20000 +efficiency = 0.94 +initialleveloptim = true + +[storage2] +name = Storage2 +group = PSP_closed +injectionnominalcapacity = 2000 +withdrawalnominalcapacity = 1500 +reservoircapacity = 20000 +efficiency = 0.78 +initiallevel = 10000 + +[storage3] +name = Storage3 +group = PSP_closed +injectionnominalcapacity = 1500 +withdrawalnominalcapacity = 1500 +reservoircapacity = 21000 +efficiency = 0.72 +initiallevel = 20000 +""" + +LIST_CFG = IniReader().read(io.StringIO(LIST_INI)) + + +class TestSTStorageManager: + @pytest.fixture(name="study_storage_service") + def study_storage_service(self) -> StudyStorageService: + """Return a mocked StudyStorageService.""" + return Mock( + spec=StudyStorageService, + variant_study_service=Mock( + spec=VariantStudyService, + command_factory=Mock( + spec=CommandFactory, + command_context=Mock(spec=CommandContext), + ), + ), + get_storage=Mock( + return_value=Mock( + spec=RawStudyService, get_raw=Mock(spec=FileStudy) + ) + ), + ) + + # noinspection PyArgumentList + @pytest.fixture(name="study_uuid") + def study_uuid_fixture(self, db_session: Session) -> str: + user = User(id=0, name="admin") + group = Group(id="my-group", name="group") + raw_study = RawStudy( + id=str(uuid.uuid4()), + name="Dummy", + version="850", + author="John Smith", + created_at=datetime.datetime.now(datetime.timezone.utc), + updated_at=datetime.datetime.now(datetime.timezone.utc), + public_mode=PublicMode.FULL, + owner=user, + groups=[group], + workspace="default", + path="/path/to/study", + content_status=StudyContentStatus.WARNING, + ) + db_session.add(raw_study) + db_session.commit() + return cast(str, raw_study.id) + + def test_get_st_storages__nominal_case( + self, + db_session: Session, + study_storage_service: StudyStorageService, + study_uuid: str, + ) -> None: + """ + This unit test is to verify the behavior of the `get_storages` + method in the `STStorageManager` class under nominal conditions. + It checks whether the method returns the expected storage list + based on a specific configuration. + """ + # The study must be fetched from the database + study: RawStudy = db_session.query(Study).get(study_uuid) + + # Prepare the mocks + storage = study_storage_service.get_storage(study) + file_study = storage.get_raw(study) + file_study.tree = Mock( + spec=FileStudyTree, + get=Mock(return_value=LIST_CFG), + ) + + # Given the following arguments + manager = STStorageManager(study_storage_service) + + # run + groups = manager.get_storages(study, area_id="West") + + # Check + actual = [form.dict(by_alias=True) for form in groups] + expected = [ + { + "efficiency": 0.94, + "group": STStorageGroup.BATTERY, + "id": "storage1", + "initialLevel": 0.0, + "initialLevelOptim": True, + "injectionNominalCapacity": 1500.0, + "name": "Storage1", + "reservoirCapacity": 20000.0, + "withdrawalNominalCapacity": 1500.0, + }, + { + "efficiency": 0.78, + "group": STStorageGroup.PSP_CLOSED, + "id": "storage2", + "initialLevel": 10000.0, + "initialLevelOptim": False, + "injectionNominalCapacity": 2000.0, + "name": "Storage2", + "reservoirCapacity": 20000.0, + "withdrawalNominalCapacity": 1500.0, + }, + { + "efficiency": 0.72, + "group": STStorageGroup.PSP_CLOSED, + "id": "storage3", + "initialLevel": 20000.0, + "initialLevelOptim": False, + "injectionNominalCapacity": 1500.0, + "name": "Storage3", + "reservoirCapacity": 21000.0, + "withdrawalNominalCapacity": 1500.0, + }, + ] + assert actual == expected + + def test_get_st_storages__config_not_found( + self, + db_session: Session, + study_storage_service: StudyStorageService, + study_uuid: str, + ) -> None: + """ + 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 + message containing the study ID and area ID. + """ + # The study must be fetched from the database + study: RawStudy = db_session.query(Study).get(study_uuid) + + # Prepare the mocks + storage = study_storage_service.get_storage(study) + file_study = storage.get_raw(study) + file_study.tree = Mock( + spec=FileStudyTree, + get=Mock(side_effect=KeyError("Oops!")), + ) + + # Given the following arguments + manager = STStorageManager(study_storage_service) + + # run + with pytest.raises( + STStorageConfigNotFoundError, 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 + err_msg = str(ctx.value) + assert "West" in err_msg + + def test_get_st_storage__nominal_case( + self, + db_session: Session, + study_storage_service: StudyStorageService, + study_uuid: str, + ) -> None: + """ + Test the `get_st_storage` method of the `STStorageManager` class under nominal conditions. + + This test verifies that the `get_st_storage` method returns the expected storage fields + for a specific study, area, and storage ID combination. + + Args: + db_session: A database session fixture. + study_storage_service: A study storage service fixture. + study_uuid: The UUID of the study to be tested. + """ + + # The study must be fetched from the database + study: RawStudy = db_session.query(Study).get(study_uuid) + + # Prepare the mocks + storage = study_storage_service.get_storage(study) + file_study = storage.get_raw(study) + file_study.tree = Mock( + spec=FileStudyTree, + get=Mock(return_value=LIST_CFG["storage1"]), + ) + + # Given the following arguments + manager = STStorageManager(study_storage_service) + + # Run the method being tested + edit_form = manager.get_storage( + study, area_id="West", storage_id="storage1" + ) + + # Assert that the returned storage fields match the expected fields + actual = edit_form.dict(by_alias=True) + expected = { + "efficiency": 0.94, + "group": STStorageGroup.BATTERY, + "id": "storage1", + "initialLevel": 0.0, + "initialLevelOptim": True, + "injectionNominalCapacity": 1500.0, + "name": "Storage1", + "reservoirCapacity": 20000.0, + "withdrawalNominalCapacity": 1500.0, + } + assert actual == expected + + def test_get_st_storage__config_not_found( + self, + db_session: Session, + study_storage_service: StudyStorageService, + study_uuid: str, + ) -> None: + """ + 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` + exception when the configuration for the provided study, area, and storage ID combination is not found. + + Args: + db_session: A database session fixture. + study_storage_service: A study storage service fixture. + study_uuid: The UUID of the study to be tested. + """ + + # The study must be fetched from the database + study: RawStudy = db_session.query(Study).get(study_uuid) + + # Prepare the mocks + storage = study_storage_service.get_storage(study) + file_study = storage.get_raw(study) + file_study.tree = Mock( + spec=FileStudyTree, + get=Mock(side_effect=KeyError("Oops!")), + ) + + # Given the following arguments + manager = STStorageManager(study_storage_service) + + # Run the method being tested and expect an exception + with pytest.raises( + STStorageFieldsNotFoundError, 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) + assert "storage1" in err_msg + + def test_get_matrix__nominal_case( + self, + db_session: Session, + study_storage_service: StudyStorageService, + study_uuid: str, + ) -> None: + """ + Test the `get_matrix` method of the `STStorageManager` class under nominal conditions. + + This test verifies that the `get_matrix` method returns the expected storage matrix + for a specific study, area, storage ID, and Time Series combination. + + Args: + db_session: A database session fixture. + study_storage_service: A study storage service fixture. + study_uuid: The UUID of the study to be tested. + """ + + # The study must be fetched from the database + study: RawStudy = db_session.query(Study).get(study_uuid) + + # Prepare the mocks + storage = study_storage_service.get_storage(study) + file_study = storage.get_raw(study) + array = np.random.rand(8760, 1) * 1000 + file_study.tree = Mock( + spec=FileStudyTree, + get=Mock( + return_value={ + "index": list(range(8760)), + "columns": [0], + "data": array.tolist(), + } + ), + ) + + # Given the following arguments + manager = STStorageManager(study_storage_service) + + # Run the method being tested + matrix = manager.get_matrix( + study, area_id="West", storage_id="storage1", ts_name="inflows" + ) + + # Assert that the returned storage fields match the expected fields + actual = matrix.dict(by_alias=True) + assert actual == matrix + + def test_get_matrix__config_not_found( + self, + db_session: Session, + study_storage_service: StudyStorageService, + study_uuid: str, + ) -> None: + """ + 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` + exception when the configuration for the provided study, area, time series, + and storage ID combination is not found. + + Args: + db_session: A database session fixture. + study_storage_service: A study storage service fixture. + study_uuid: The UUID of the study to be tested. + """ + + # The study must be fetched from the database + study: RawStudy = db_session.query(Study).get(study_uuid) + + # Prepare the mocks + storage = study_storage_service.get_storage(study) + file_study = storage.get_raw(study) + file_study.tree = Mock( + spec=FileStudyTree, + get=Mock(side_effect=KeyError("Oops!")), + ) + + # Given the following arguments + manager = STStorageManager(study_storage_service) + + # Run the method being tested and expect an exception + with pytest.raises( + STStorageMatrixNotFoundError, 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) + assert "storage1" in err_msg + assert "inflows" in err_msg + + def test_get_matrix__invalid_matrix( + self, + db_session: Session, + study_storage_service: StudyStorageService, + study_uuid: str, + ) -> None: + """ + 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` + exception when the configuration for the provided study, area, time series, + and storage ID combination is not found. + + Args: + db_session: A database session fixture. + study_storage_service: A study storage service fixture. + study_uuid: The UUID of the study to be tested. + """ + + # The study must be fetched from the database + study: RawStudy = db_session.query(Study).get(study_uuid) + + # Prepare the mocks + storage = study_storage_service.get_storage(study) + file_study = storage.get_raw(study) + array = np.random.rand(365, 1) * 1000 + matrix = { + "index": list(range(365)), + "columns": [0], + "data": array.tolist(), + } + file_study.tree = Mock( + spec=FileStudyTree, + get=Mock(return_value=matrix), + ) + + # Given the following arguments + manager = STStorageManager(study_storage_service) + + # Run the method being tested and expect an exception + with pytest.raises( + ValidationError, + match=re.escape("time series must have shape (8760, 1)"), + ): + manager.get_matrix( + study, area_id="West", storage_id="storage1", ts_name="inflows" + ) + + # noinspection SpellCheckingInspection + def test_validate_matrices__nominal( + self, + db_session: Session, + study_storage_service: StudyStorageService, + study_uuid: str, + ) -> None: + # The study must be fetched from the database + study: RawStudy = db_session.query(Study).get(study_uuid) + + # prepare some random matrices, insuring `lower_rule_curve` <= `upper_rule_curve` + matrices = { + # fmt: off + "pmax_injection": np.random.rand(8760, 1), + "pmax_withdrawal": np.random.rand(8760, 1), + "lower_rule_curve": np.random.rand(8760, 1) / 2, + "upper_rule_curve": np.random.rand(8760, 1) / 2 + 0.5, + "inflows": np.random.rand(8760, 1) * 1000, + # fmt: on + } + + # Prepare the mocks + def tree_get(url: Sequence[str], **_: Any) -> MutableMapping[str, Any]: + name = url[-1] + array = matrices[name] + return { + "index": list(range(array.shape[0])), + "columns": list(range(array.shape[1])), + "data": array.tolist(), + } + + storage = study_storage_service.get_storage(study) + file_study = storage.get_raw(study) + file_study.tree = Mock(spec=FileStudyTree, get=tree_get) + + # Given the following arguments, the validation shouldn't raise any exception + manager = STStorageManager(study_storage_service) + assert manager.validate_matrices( + study, area_id="West", storage_id="storage1" + ) + + # noinspection SpellCheckingInspection + def test_validate_matrices__out_of_bound( + self, + db_session: Session, + study_storage_service: StudyStorageService, + study_uuid: str, + ) -> None: + # The study must be fetched from the database + study: RawStudy = db_session.query(Study).get(study_uuid) + + # prepare some random matrices, insuring `lower_rule_curve` <= `upper_rule_curve` + matrices = { + # fmt: off + "pmax_injection": np.random.rand(8760, 1) * 2 - 0.5, # out of bound + "pmax_withdrawal": np.random.rand(8760, 1) * 2 - 0.5, # out of bound + "lower_rule_curve": np.random.rand(8760, 1) * 2 - 0.5, # out of bound + "upper_rule_curve": np.random.rand(8760, 1) * 2 - 0.5, # out of bound + "inflows": np.random.rand(8760, 1) * 1000, + # fmt: on + } + + # Prepare the mocks + def tree_get(url: Sequence[str], **_: Any) -> MutableMapping[str, Any]: + name = url[-1] + array = matrices[name] + return { + "index": list(range(array.shape[0])), + "columns": list(range(array.shape[1])), + "data": array.tolist(), + } + + storage = study_storage_service.get_storage(study) + file_study = storage.get_raw(study) + file_study.tree = Mock(spec=FileStudyTree, get=tree_get) + + # Given the following arguments, the validation shouldn't raise any exception + manager = STStorageManager(study_storage_service) + + # Run the method being tested and expect an exception + with pytest.raises( + ValidationError, + match=re.escape("4 validation errors"), + ) as ctx: + manager.validate_matrices( + study, area_id="West", storage_id="storage1" + ) + errors = ctx.value.errors() + assert errors == [ + { + "loc": ("pmax_injection",), + "msg": "Matrix values should be between 0 and 1", + "type": "value_error", + }, + { + "loc": ("pmax_withdrawal",), + "msg": "Matrix values should be between 0 and 1", + "type": "value_error", + }, + { + "loc": ("lower_rule_curve",), + "msg": "Matrix values should be between 0 and 1", + "type": "value_error", + }, + { + "loc": ("upper_rule_curve",), + "msg": "Matrix values should be between 0 and 1", + "type": "value_error", + }, + ] + + # noinspection SpellCheckingInspection + def test_validate_matrices__rule_curve( + self, + db_session: Session, + study_storage_service: StudyStorageService, + study_uuid: str, + ) -> None: + # The study must be fetched from the database + study: RawStudy = db_session.query(Study).get(study_uuid) + + # prepare some random matrices, insuring `lower_rule_curve` <= `upper_rule_curve` + matrices = { + # fmt: off + "pmax_injection": np.random.rand(8760, 1), + "pmax_withdrawal": np.random.rand(8760, 1), + "lower_rule_curve": np.random.rand(8760, 1), + "upper_rule_curve": np.random.rand(8760, 1), + "inflows": np.random.rand(8760, 1) * 1000, + # fmt: on + } + + # Prepare the mocks + def tree_get(url: Sequence[str], **_: Any) -> MutableMapping[str, Any]: + name = url[-1] + array = matrices[name] + return { + "index": list(range(array.shape[0])), + "columns": list(range(array.shape[1])), + "data": array.tolist(), + } + + storage = study_storage_service.get_storage(study) + file_study = storage.get_raw(study) + file_study.tree = Mock(spec=FileStudyTree, get=tree_get) + + # Given the following arguments, the validation shouldn't raise any exception + manager = STStorageManager(study_storage_service) + + # Run the method being tested and expect an exception + with pytest.raises( + ValidationError, + match=re.escape("1 validation error"), + ) as ctx: + manager.validate_matrices( + study, area_id="West", storage_id="storage1" + ) + error = ctx.value.errors()[0] + assert error["loc"] == ("__root__",) + assert "lower_rule_curve" in error["msg"] + assert "upper_rule_curve" in error["msg"] diff --git a/tests/variantstudy/model/command/test_create_st_storage.py b/tests/variantstudy/model/command/test_create_st_storage.py index 338324b8c9..0e670f0ae0 100644 --- a/tests/variantstudy/model/command/test_create_st_storage.py +++ b/tests/variantstudy/model/command/test_create_st_storage.py @@ -1,16 +1,12 @@ import re -from typing import Dict, Union -from unittest.mock import Mock import numpy as np import pytest +from pydantic import ValidationError + 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, -) from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy from antarest.study.storage.study_upgrader import upgrade_study from antarest.study.storage.variantstudy.business.utils import ( @@ -25,8 +21,8 @@ from antarest.study.storage.variantstudy.model.command.create_st_storage import ( REQUIRED_VERSION, CreateSTStorage, + STStorageConfig, ) -from antarest.study.storage.variantstudy.model.command.icommand import ICommand from antarest.study.storage.variantstudy.model.command.replace_matrix import ( ReplaceMatrix, ) @@ -37,7 +33,6 @@ CommandContext, ) from antarest.study.storage.variantstudy.model.model import CommandDTO -from pydantic import ValidationError @pytest.fixture(name="recent_study") @@ -84,7 +79,7 @@ def recent_study_fixture(empty_study: FileStudy) -> FileStudy: class TestCreateSTStorage: # noinspection SpellCheckingInspection - def test_init(self, command_context: CommandContext) -> None: + def test_init(self, command_context: CommandContext): pmax_injection = np.random.rand(8760, 1) inflows = np.random.uniform(0, 1000, size=(8760, 1)) cmd = CreateSTStorage( @@ -115,7 +110,7 @@ def test_init(self, command_context: CommandContext) -> None: def test_init__invalid_storage_name( self, recent_study: FileStudy, command_context: CommandContext - ) -> None: + ): # When we apply the config for a new ST Storage with a bad name with pytest.raises(ValidationError) as ctx: parameters = {**PARAMETERS, "name": "?%$$"} # bad name @@ -136,7 +131,7 @@ def test_init__invalid_storage_name( # noinspection SpellCheckingInspection def test_init__invalid_matrix_values( self, command_context: CommandContext - ) -> None: + ): array = np.random.rand(8760, 1) # OK array[10] = 25 # BAD with pytest.raises(ValidationError) as ctx: @@ -155,9 +150,7 @@ def test_init__invalid_matrix_values( ] # noinspection SpellCheckingInspection - def test_init__invalid_matrix_shape( - self, command_context: CommandContext - ) -> None: + def test_init__invalid_matrix_shape(self, command_context: CommandContext): array = np.random.rand(24, 1) # BAD SHAPE with pytest.raises(ValidationError) as ctx: CreateSTStorage( @@ -176,9 +169,7 @@ def test_init__invalid_matrix_shape( # noinspection SpellCheckingInspection - def test_init__invalid_nan_value( - self, command_context: CommandContext - ) -> None: + def test_init__invalid_nan_value(self, command_context: CommandContext): array = np.random.rand(8760, 1) # OK array[20] = np.nan # BAD with pytest.raises(ValidationError) as ctx: @@ -198,9 +189,7 @@ def test_init__invalid_nan_value( # noinspection SpellCheckingInspection - def test_init__invalid_matrix_type( - self, command_context: CommandContext - ) -> None: + def test_init__invalid_matrix_type(self, command_context: CommandContext): array = {"data": [1, 2, 3]} with pytest.raises(ValidationError) as ctx: CreateSTStorage( @@ -224,7 +213,7 @@ def test_init__invalid_matrix_type( def test_apply_config__invalid_version( self, empty_study: FileStudy, command_context: CommandContext - ) -> None: + ): # Given an old study in version 720 # When we apply the config to add a new ST Storage create_st_storage = CreateSTStorage( @@ -244,7 +233,7 @@ def test_apply_config__invalid_version( def test_apply_config__missing_area( self, recent_study: FileStudy, command_context: CommandContext - ) -> None: + ): # Given a study without "unknown area" area # When we apply the config to add a new ST Storage create_st_storage = CreateSTStorage( @@ -264,7 +253,7 @@ def test_apply_config__missing_area( def test_apply_config__duplicate_storage( self, recent_study: FileStudy, command_context: CommandContext - ) -> None: + ): # First, prepare a new Area create_area = CreateArea( area_name="Area FR", @@ -300,7 +289,7 @@ def test_apply_config__duplicate_storage( def test_apply_config__nominal_case( self, recent_study: FileStudy, command_context: CommandContext - ) -> None: + ): # First, prepare a new Area create_area = CreateArea( area_name="Area FR", @@ -324,42 +313,10 @@ def test_apply_config__nominal_case( flags=re.IGNORECASE, ) - def test_apply_config__without_groups( - self, recent_study: FileStudy, command_context: CommandContext - ) -> None: - # First, prepare a new Area - create_area = CreateArea( - area_name="Area FR", - command_context=command_context, - ) - create_area.apply(recent_study) - - # Remove the group from the nominal case parameters - parameters_wo_groups = dict(PARAMETERS) - parameters_wo_groups.pop("group") - - # Then, apply the config for a new ST Storage - create_st_storage = CreateSTStorage( - command_context=command_context, - area_id=transform_name_to_id(create_area.area_name), - parameters=STStorageConfig(**parameters_wo_groups), - ) - command_output = create_st_storage.apply_config(recent_study.config) - assert command_output.status is True - assert re.search( - rf"'{re.escape(create_st_storage.storage_name)}'.*added", - command_output.message, - flags=re.IGNORECASE, - ) - # assert that the default group value is Other1 - area_fr = recent_study.config.areas["area fr"] - storage = area_fr.st_storages[0] - assert storage.group == STStorageGroup.OTHER1 - # noinspection SpellCheckingInspection def test_apply__nominal_case( self, recent_study: FileStudy, command_context: CommandContext - ) -> None: + ): # First, prepare a new Area create_area = CreateArea( area_name="Area FR", @@ -388,7 +345,7 @@ def test_apply__nominal_case( "storage1": { "efficiency": 0.94, "group": "Battery", - "initiallevel": 0, + # "initiallevel": 0, # default value is 0 "initialleveloptim": True, "injectionnominalcapacity": 1500, "name": "Storage1", @@ -419,7 +376,7 @@ def test_apply__nominal_case( def test_apply__invalid_apply_config( self, empty_study: FileStudy, command_context: CommandContext - ) -> None: + ): # First, prepare a new Area create_area = CreateArea( area_name="Area FR", command_context=command_context @@ -436,7 +393,7 @@ def test_apply__invalid_apply_config( assert not command_output.status # invalid study (too old) # noinspection SpellCheckingInspection - def test_to_dto(self, command_context: CommandContext) -> None: + def test_to_dto(self, command_context: CommandContext): cmd = CreateSTStorage( command_context=command_context, area_id="area_fr", @@ -473,7 +430,7 @@ def test_to_dto(self, command_context: CommandContext) -> None: }, ) - def test_match_signature(self, command_context: CommandContext) -> None: + def test_match_signature(self, command_context: CommandContext): cmd = CreateSTStorage( command_context=command_context, area_id="area_fr", @@ -486,9 +443,9 @@ def test_match_signature(self, command_context: CommandContext) -> None: def test_match( self, command_context: CommandContext, - area_id: str, - parameters: Dict[str, Union[str, float]], - ) -> None: + area_id, + parameters, + ): cmd1 = CreateSTStorage( command_context=command_context, area_id="area_fr", @@ -506,21 +463,17 @@ def test_match( deep_equal = area_id == cmd1.area_id and parameters == PARAMETERS assert cmd1.match(cmd2, equal=True) == deep_equal - def test_match__unknown_type( - self, command_context: CommandContext - ) -> None: + def test_match__unknown_type(self, command_context: CommandContext): cmd1 = CreateSTStorage( command_context=command_context, area_id="area_fr", parameters=STStorageConfig(**PARAMETERS), ) # Always `False` when compared to another object type - assert cmd1.match(Mock(spec=ICommand), equal=False) is False - assert cmd1.match(Mock(spec=ICommand), equal=True) is False + assert cmd1.match(..., equal=False) is False + assert cmd1.match(..., equal=True) is False - def test_create_diff__not_equals( - self, command_context: CommandContext - ) -> None: + def test_create_diff__not_equals(self, command_context: CommandContext): cmd = CreateSTStorage( command_context=command_context, area_id="area_fr", @@ -555,9 +508,7 @@ def test_create_diff__not_equals( ] assert actual == expected - def test_create_diff__equals( - self, command_context: CommandContext - ) -> None: + def test_create_diff__equals(self, command_context: CommandContext): cmd = CreateSTStorage( command_context=command_context, area_id="area_fr", @@ -566,7 +517,7 @@ def test_create_diff__equals( actual = cmd.create_diff(cmd) assert not actual - def test_get_inner_matrices(self, command_context: CommandContext) -> None: + def test_get_inner_matrices(self, command_context: CommandContext): cmd = CreateSTStorage( command_context=command_context, area_id="area_fr",