From 4e416ba6375539402408a249fbe1d8cd769c05ce Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE Date: Mon, 3 Jul 2023 19:49:28 +0200 Subject: [PATCH] feat(storage): improve the `CreateSTStorage` and `RemoveSTStorage` commands --- .../rawstudy/model/filesystem/config/files.py | 8 +- .../rawstudy/model/filesystem/config/model.py | 13 +- .../model/filesystem/config/st_storage.py | 111 ++++++++++++++++ .../variantstudy/business/command_reverter.py | 2 +- .../model/command/create_st_storage.py | 125 +++--------------- .../filesystem/config/test_config_files.py | 75 ++++++++--- .../model/command/test_create_st_storage.py | 14 +- .../model/command/test_remove_st_storage.py | 2 +- tests/variantstudy/test_command_factory.py | 14 +- 9 files changed, 222 insertions(+), 142 deletions(-) create mode 100644 antarest/study/storage/rawstudy/model/filesystem/config/st_storage.py diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/files.py b/antarest/study/storage/rawstudy/model/filesystem/config/files.py index 1a0911f766..d21862be34 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/config/files.py +++ b/antarest/study/storage/rawstudy/model/filesystem/config/files.py @@ -25,9 +25,11 @@ FileStudyTreeConfig, Link, Simulation, - STStorage, transform_name_to_id, ) +from antarest.study.storage.rawstudy.model.filesystem.config.st_storage import ( + STStorageConfig, +) from antarest.study.storage.rawstudy.model.filesystem.root.settings.generaldata import ( DUPLICATE_KEYS, ) @@ -382,7 +384,7 @@ def _parse_thermal(root: Path, area: str) -> List[Cluster]: ] -def _parse_st_storage(root: Path, area: str) -> List[STStorage]: +def _parse_st_storage(root: Path, area: str) -> List[STStorageConfig]: """ Parse the short-term storage INI file, return an empty list if missing. """ @@ -392,7 +394,7 @@ def _parse_st_storage(root: Path, area: str) -> List[STStorage]: file_type=FileType.SIMPLE_INI, ) return [ - STStorage(id=storage_id, name=values["name"]) + STStorageConfig(**dict(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 8c5a94eafb..31374e44ee 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/config/model.py +++ b/antarest/study/storage/rawstudy/model/filesystem/config/model.py @@ -9,6 +9,8 @@ from antarest.core.utils.utils import DTO from pydantic.main import BaseModel +from .st_storage import STStorageConfig + class ENR_MODELLING(Enum): AGGREGATED = "aggregated" @@ -25,15 +27,6 @@ class Cluster(BaseModel): enabled: bool = True -class STStorage(BaseModel): - """ - Short-term storage model used in Area creation - """ - - id: str - name: str - - class Link(BaseModel): """ Object linked to /input/links//properties.ini information @@ -71,7 +64,7 @@ class Config: filters_synthesis: List[str] filters_year: List[str] # since v8.6 - st_storages: List[STStorage] = [] + st_storages: List[STStorageConfig] = [] class DistrictSet(BaseModel): diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/st_storage.py b/antarest/study/storage/rawstudy/model/filesystem/config/st_storage.py new file mode 100644 index 0000000000..e587dde39c --- /dev/null +++ b/antarest/study/storage/rawstudy/model/filesystem/config/st_storage.py @@ -0,0 +1,111 @@ +from typing import Dict, Any + +from pydantic import BaseModel, Extra, Field, root_validator + +from antarest.study.business.enum_ignore_case import EnumIgnoreCase + + +class STStorageGroup(EnumIgnoreCase): + """ + This class defines the specific energy storage systems. + + Enum values: + + - PSP_OPEN: Represents an open pumped storage plant. + - PSP_CLOSED: Represents a closed pumped storage plant. + - PONDAGE: Represents a pondage storage system (reservoir storage system). + - BATTERY: Represents a battery storage system. + - OTHER: Represents other energy storage systems. + """ + + PSP_OPEN = "PSP_open" + PSP_CLOSED = "PSP_closed" + PONDAGE = "Pondage" + BATTERY = "Battery" + OTHER = "Other" + + +class STStorageConfig(BaseModel): + """ + Manage the configuration files in the context of Short-Term Storage. + It provides a convenient way to read and write configuration data from/to an INI file format. + """ + + class Config: + extra = Extra.forbid + allow_population_by_field_name = True + + id: str = Field( + description="Short-term storage ID", + regex=r"[a-z0-9_(),& -]+", + ) + name: str = Field( + description="Short-term storage name", + regex=r"[a-zA-Z0-9_(),& -]+", + ) + group: STStorageGroup = Field( + ..., + description="Energy storage system group (mandatory)", + ) + injection_nominal_capacity: float = Field( + 0, + description="Injection nominal capacity (MW)", + ge=0, + alias="injectionnominalcapacity", + ) + withdrawal_nominal_capacity: float = Field( + 0, + description="Withdrawal nominal capacity (MW)", + ge=0, + alias="withdrawalnominalcapacity", + ) + reservoir_capacity: float = Field( + 0, + description="Reservoir capacity (MWh)", + ge=0, + alias="reservoircapacity", + ) + efficiency: float = Field( + 1, + description="Efficiency of the storage system", + ge=0, + le=1, + ) + initial_level: float = Field( + 0, + description="Initial level of the storage system", + ge=0, + alias="initiallevel", + ) + initial_level_optim: bool = Field( + False, + description="Flag indicating if the initial level is optimized", + alias="initialleveloptim", + ) + + @root_validator(pre=True) + def calculate_storage_id(cls, values: Dict[str, Any]) -> Dict[str, Any]: + """ + Calculate the short-term storage ID based on the storage name, if not provided. + + Args: + values: values used to construct the object. + + Returns: + The updated values. + """ + # Avoid circular imports + from antarest.study.storage.rawstudy.model.filesystem.config.model import ( + transform_name_to_id, + ) + + if values.get("id") or not values.get("name"): + return values + storage_name = values["name"] + if storage_id := transform_name_to_id(storage_name): + values["id"] = storage_id + else: + raise ValueError( + f"Invalid short term storage name '{storage_name}'." + ) + return values diff --git a/antarest/study/storage/variantstudy/business/command_reverter.py b/antarest/study/storage/variantstudy/business/command_reverter.py index f0a9ff1176..bd96ae6376 100644 --- a/antarest/study/storage/variantstudy/business/command_reverter.py +++ b/antarest/study/storage/variantstudy/business/command_reverter.py @@ -279,7 +279,7 @@ def _revert_create_st_storage( history: List["ICommand"], base: FileStudy, ) -> List[ICommand]: - storage_id = base_command.storage_id + storage_id = base_command.parameters.id return [ RemoveSTStorage( area_id=base_command.area_id, 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 325f5e7652..5d4ecd3976 100644 --- a/antarest/study/storage/variantstudy/model/command/create_st_storage.py +++ b/antarest/study/storage/variantstudy/model/command/create_st_storage.py @@ -4,12 +4,12 @@ import numpy as np from antarest.core.model import JSON from antarest.matrixstore.model import MatrixData -from antarest.study.business.enum_ignore_case import EnumIgnoreCase from antarest.study.storage.rawstudy.model.filesystem.config.model import ( Area, FileStudyTreeConfig, - STStorage, - transform_name_to_id, +) +from antarest.study.storage.rawstudy.model.filesystem.config.st_storage import ( + STStorageConfig, ) from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy from antarest.study.storage.variantstudy.business.matrix_constants_generator import ( @@ -28,7 +28,7 @@ ICommand, ) from antarest.study.storage.variantstudy.model.model import CommandDTO -from pydantic import BaseModel, Extra, Field, root_validator, validator +from pydantic import Field, root_validator, validator from pydantic.fields import ModelField # minimum required version. @@ -37,82 +37,6 @@ MatrixType = List[List[MatrixData]] -class STStorageGroup(EnumIgnoreCase): - """ - This class defines the specific energy storage systems. - - Enum values: - PSP_OPEN: Represents an open pumped storage plant. - PSP_CLOSED: Represents a closed pumped storage plant. - PONDAGE: Represents a pondage storage system (reservoir storage system). - BATTERY: Represents a battery storage system. - OTHER: Represents other energy storage systems. - """ - - PSP_OPEN = "PSP_open" - PSP_CLOSED = "PSP_closed" - PONDAGE = "Pondage" - BATTERY = "Battery" - OTHER = "Other" - - -# noinspection SpellCheckingInspection -class STStorageConfig(BaseModel): - """ - Manage the configuration files in the context of Short-Term Storage. - It provides a convenient way to read and write configuration data from/to an INI file format. - """ - - class Config: - extra = Extra.forbid - allow_population_by_field_name = True - - name: str = Field( - ..., - description="Short-term storage name (mandatory)", - regex=r"\w+", - ) - group: STStorageGroup = Field( - ..., - description="Energy storage system group (mandatory)", - ) - injection_nominal_capacity: float = Field( - 0, - description="Injection nominal capacity (MW)", - ge=0, - alias="injectionnominalcapacity", - ) - withdrawal_nominal_capacity: float = Field( - 0, - description="Withdrawal nominal capacity (MW)", - ge=0, - alias="withdrawalnominalcapacity", - ) - reservoir_capacity: float = Field( - 0, - description="Reservoir capacity (MWh)", - ge=0, - alias="reservoircapacity", - ) - efficiency: float = Field( - 1, - description="Efficiency of the storage system", - ge=0, - le=1, - ) - initial_level: float = Field( - 0, - description="Initial level of the storage system", - ge=0, - alias="initiallevel", - ) - initial_level_optim: bool = Field( - False, - description="Flag indicating if the initial level is optimized", - alias="initialleveloptim", - ) - - # noinspection SpellCheckingInspection class CreateSTStorage(ICommand): """ @@ -146,9 +70,6 @@ class CreateSTStorage(ICommand): description="Inflows (MW)", ) - # `storage_id` is computed - storage_id: str - def __init__(self, **data: Any) -> None: super().__init__( command_name=CommandName.CREATE_ST_STORAGE, version=1, **data @@ -159,12 +80,6 @@ def validate_parameters(cls, values: Dict[str, Any]) -> Dict[str, Any]: storage_name = values.get("storage_name") if not storage_name: return values - storage_id = transform_name_to_id(storage_name) - if not storage_id: - raise ValueError( - f"Invalid short term storage name '{storage_name}'." - ) - values["storage_id"] = storage_id # The short-term storage name must be added to the parameters, # because the INI section name is the short-term storage ID, not the name. if parameters := values.get("parameters"): @@ -289,7 +204,7 @@ def _apply_config( area: Area = study_data.areas[self.area_id] # Check if the short-term storage already exists in the area - if any(s.id == self.storage_id for s in area.st_storages): + if any(s.id == self.parameters.id for s in area.st_storages): return ( CommandOutput( status=False, @@ -302,8 +217,7 @@ def _apply_config( ) # Create a new short-term storage and add it to the area - st_storage = STStorage(id=self.storage_id, name=self.storage_name) - area.st_storages.append(st_storage) + area.st_storages.append(self.parameters) return ( CommandOutput( @@ -313,7 +227,7 @@ def _apply_config( f" to area '{self.area_id}'." ), ), - {"storage_id": self.storage_id}, + {"storage_id": self.parameters.id}, ) def _apply(self, study_data: FileStudy) -> CommandOutput: @@ -336,9 +250,9 @@ def _apply(self, study_data: FileStudy) -> CommandOutput: config = study_data.tree.get( ["input", "st-storage", "clusters", self.area_id, "list"] ) - config[self.storage_id] = json.loads( - self.parameters.json(by_alias=True) - ) + data: Dict[str, Any] = json.loads(self.parameters.json(by_alias=True)) + data.pop("id") # Not stored in the config + config[self.parameters.id] = data new_data: JSON = { "input": { @@ -346,7 +260,7 @@ def _apply(self, study_data: FileStudy) -> CommandOutput: "clusters": {self.area_id: {"list": config}}, "series": { self.area_id: { - self.storage_id: { + self.parameters.id: { "PMAX-injection": self.pmax_injection, "PMAX-withdrawal": self.pmax_withdrawal, "lower-rule-curve": self.lower_rule_curve, @@ -371,7 +285,7 @@ def to_dto(self) -> CommandDTO: The DTO object representing the current command. """ # fmt: off - parameters = json.loads(self.parameters.json(by_alias=True, exclude_defaults=True)) + parameters = json.loads(self.parameters.json(by_alias=True)) return CommandDTO( action=self.command_name.value, args={ @@ -394,7 +308,7 @@ def match_signature(self) -> str: + MATCH_SIGNATURE_SEPARATOR + self.area_id + MATCH_SIGNATURE_SEPARATOR - + self.storage_id + + self.parameters.id ) def match(self, other: "ICommand", equal: bool = False) -> bool: @@ -416,7 +330,7 @@ def match(self, other: "ICommand", equal: bool = False) -> bool: else: return ( self.area_id == other.area_id - and self.storage_id == other.storage_id + and self.parameters.id == other.parameters.id ) def _create_diff(self, other: "ICommand") -> List["ICommand"]: @@ -448,7 +362,7 @@ def _create_diff(self, other: "ICommand") -> List["ICommand"]: } commands: List[ICommand] = [ ReplaceMatrix( - target=f"input/st-storage/series/{self.area_id}/{self.storage_id}/{ini_name}", + target=f"input/st-storage/series/{self.area_id}/{self.parameters.id}/{ini_name}", matrix=strip_matrix_protocol(getattr(other, attr)), command_context=self.command_context, ) @@ -456,10 +370,15 @@ def _create_diff(self, other: "ICommand") -> List["ICommand"]: if getattr(self, attr) != getattr(other, attr) ] if self.parameters != other.parameters: + # Exclude the `id` because it is read-only, and they can't be modified (calculated) + data: Dict[str, Any] = json.loads( + other.parameters.json(by_alias=True) + ) + data.pop("id") commands.append( UpdateConfig( - target=f"input/st-storage/clusters/{self.area_id}/list/{self.storage_id}", - data=json.loads(other.parameters.json(by_alias=True)), + target=f"input/st-storage/clusters/{self.area_id}/list/{self.parameters.id}", + data=data, command_context=self.command_context, ) ) diff --git a/tests/storage/repository/filesystem/config/test_config_files.py b/tests/storage/repository/filesystem/config/test_config_files.py index 47e2233e47..dd67da3c86 100644 --- a/tests/storage/repository/filesystem/config/test_config_files.py +++ b/tests/storage/repository/filesystem/config/test_config_files.py @@ -20,7 +20,10 @@ DistrictSet, Cluster, BindingConstraintDTO, - STStorage, +) +from antarest.study.storage.rawstudy.model.filesystem.config.st_storage import ( + STStorageConfig, + STStorageGroup, ) from tests.storage.business.assets import ASSETS_DIR @@ -283,29 +286,59 @@ def test_parse_thermal(tmp_path: Path) -> None: ] -def test_parse_st_storage(tmp_path: Path) -> None: - study_path = build_empty_files(tmp_path) - study_path.joinpath("input", "st-storage", "clusters", "fr").mkdir( - parents=True - ) - content = """ - [t1] - name = t1 - - [t2] - name = t2 +# noinspection SpellCheckingInspection +ST_STORAGE_LIST_INI = """\ +[siemens battery] +name = Siemens Battery +group = Battery +injectionnominalcapacity = 150.0 +withdrawalnominalcapacity = 150.0 +reservoircapacity = 600.0 +efficiency = 0.94 +initiallevel = 0 +initialleveloptim = True + +[grand maison] +name = Grand'Maison +group = PSP_closed +injectionnominalcapacity = 1500.0 +withdrawalnominalcapacity = 1800.0 +reservoircapacity = 20000.0 +efficiency = 0.78 +initiallevel = 10000.0 +initialleveloptim = False +""" - [t3] - name = t3 - """ - study_path.joinpath( - "input", "st-storage", "clusters", "fr", "list.ini" - ).write_text(content) +def test_parse_st_storage(tmp_path: Path) -> None: + study_path = build_empty_files(tmp_path) + config_dir = study_path.joinpath("input", "st-storage", "clusters", "fr") + config_dir.mkdir(parents=True) + config_dir.joinpath("list.ini").write_text(ST_STORAGE_LIST_INI) + # noinspection SpellCheckingInspection assert _parse_st_storage(study_path, "fr") == [ - STStorage(id="t1", name="t1"), - STStorage(id="t2", name="t2"), - STStorage(id="t3", name="t3"), + STStorageConfig( + id="siemens battery", + name="Siemens Battery", + group=STStorageGroup.BATTERY, + injection_nominal_capacity=150.0, + withdrawal_nominal_capacity=150.0, + reservoir_capacity=600.0, + efficiency=0.94, + initial_level=0.0, + initial_level_optim=True, + ), + STStorageConfig( + id="grand maison", + name="Grand'Maison", + group=STStorageGroup.PSP_CLOSED, + injection_nominal_capacity=1500.0, + withdrawal_nominal_capacity=1800.0, + reservoir_capacity=20000.0, + efficiency=0.78, + initial_level=10000.0, + initial_level_optim=False, + ), ] diff --git a/tests/variantstudy/model/command/test_create_st_storage.py b/tests/variantstudy/model/command/test_create_st_storage.py index ca09c13800..cba63415cd 100644 --- a/tests/variantstudy/model/command/test_create_st_storage.py +++ b/tests/variantstudy/model/command/test_create_st_storage.py @@ -119,12 +119,19 @@ def test_init__invalid_storage_name( storage_name="?%$$", # bad name parameters=PARAMETERS, ) + # We get 2 errors because the `storage_name` is duplicated in the `parameters`: assert ctx.value.errors() == [ { - "loc": ("__root__",), + "ctx": {"pattern": "[a-zA-Z0-9_(),& -]+"}, + "loc": ("storage_name",), + "msg": 'string does not match regex "[a-zA-Z0-9_(),& -]+"', + "type": "value_error.str.regex", + }, + { + "loc": ("parameters", "__root__"), "msg": "Invalid short term storage name '?%$$'.", "type": "value_error", - } + }, ] # noinspection SpellCheckingInspection @@ -327,7 +334,10 @@ def test_to_dto(self, command_context: CommandContext): actual = cmd.to_dto() expected_parameters = PARAMETERS.copy() + expected_parameters["id"] = transform_name_to_id(cmd.storage_name) expected_parameters["name"] = cmd.storage_name + # `initiallevel` = 0 because `initialleveloptim` is True + expected_parameters["initiallevel"] = 0 constants = command_context.generator_matrix_constants assert actual == CommandDTO( diff --git a/tests/variantstudy/model/command/test_remove_st_storage.py b/tests/variantstudy/model/command/test_remove_st_storage.py index 1a29243c3c..d8a0cc44f3 100644 --- a/tests/variantstudy/model/command/test_remove_st_storage.py +++ b/tests/variantstudy/model/command/test_remove_st_storage.py @@ -179,7 +179,7 @@ def test_apply_config__nominal_case( remove_st_storage = RemoveSTStorage( command_context=command_context, area_id=transform_name_to_id(create_area.area_name), - storage_id=create_st_storage.storage_id, + storage_id=create_st_storage.parameters.id, ) command_output = remove_st_storage.apply_config(recent_study.config) diff --git a/tests/variantstudy/test_command_factory.py b/tests/variantstudy/test_command_factory.py index 4ddfccc8b9..0e51b4f2cf 100644 --- a/tests/variantstudy/test_command_factory.py +++ b/tests/variantstudy/test_command_factory.py @@ -325,7 +325,17 @@ def setup_class(self): args={ "area_id": "area 1", "storage_name": "Storage 1", - "parameters": {"name": "Storage 1", "group": "Battery"}, + "parameters": { + "id": "storage 1", + "name": "Storage 1", + "group": "Battery", + "injectionnominalcapacity": 0, + "withdrawalnominalcapacity": 0, + "reservoircapacity": 0, + "efficiency": 1, + "initiallevel": 0, + "initialleveloptim": False, + }, "pmax_injection": "matrix://59ea6c83-6348-466d-9530-c35c51ca4c37", "pmax_withdrawal": "matrix://5f988548-dadc-4bbb-8ce8-87a544dbf756", "lower_rule_curve": "matrix://8ce4fcea-cc97-4d2c-b641-a27a53454612", @@ -340,6 +350,7 @@ def setup_class(self): "area_id": "area 1", "storage_name": "Storage 1", "parameters": { + "id": "storage 1", "name": "Storage 1", "group": "Battery", }, @@ -353,6 +364,7 @@ def setup_class(self): "area_id": "area 1", "storage_name": "Storage 2", "parameters": { + "id": "storage 2", "name": "Storage 2", "group": "Battery", "efficiency": 0.94,