From e88d4ee29607232d7cf1aa7b8a709f26c105e818 Mon Sep 17 00:00:00 2001 From: Laurent LAPORTE <43534797+laurent-laporte-pro@users.noreply.github.com> Date: Tue, 18 Jul 2023 14:59:14 +0200 Subject: [PATCH] feat(commands): add ST-Storage commands (#1630) --- .../rawstudy/model/filesystem/config/files.py | 48 +- .../rawstudy/model/filesystem/config/model.py | 33 +- .../model/filesystem/config/st_storage.py | 115 ++++ .../series/area/st_storage/st_storage.py | 8 +- .../variantstudy/business/command_reverter.py | 31 + .../business/matrix_constants/__init__.py | 2 +- .../matrix_constants/st_storage/__init__.py | 1 + .../business/matrix_constants_generator.py | 50 +- .../storage/variantstudy/command_factory.py | 10 +- .../variantstudy/model/command/common.py | 2 + .../model/command/create_cluster.py | 2 + .../command/create_renewables_cluster.py | 2 + .../model/command/create_st_storage.py | 381 +++++++++++++ .../model/command/remove_st_storage.py | 169 ++++++ docs/user-guide/2-variant_manager.md | 315 ++++++++++- .../integration/variant_blueprint/__init__.py | 0 .../variant_blueprint/test_st_storage.py | 240 ++++++++ .../filesystem/config/test_config_files.py | 75 ++- tests/study/storage/variantstudy/__init__.py | 0 .../storage/variantstudy/business/__init__.py | 0 .../test_matrix_constants_generator.py | 54 ++ .../model/command/test_create_st_storage.py | 534 ++++++++++++++++++ .../model/command/test_remove_st_storage.py | 259 +++++++++ tests/variantstudy/test_command_factory.py | 109 +++- 24 files changed, 2327 insertions(+), 113 deletions(-) create mode 100644 antarest/study/storage/rawstudy/model/filesystem/config/st_storage.py create mode 100644 antarest/study/storage/variantstudy/model/command/create_st_storage.py create mode 100644 antarest/study/storage/variantstudy/model/command/remove_st_storage.py create mode 100644 tests/integration/variant_blueprint/__init__.py create mode 100644 tests/integration/variant_blueprint/test_st_storage.py create mode 100644 tests/study/storage/variantstudy/__init__.py create mode 100644 tests/study/storage/variantstudy/business/__init__.py create mode 100644 tests/study/storage/variantstudy/business/test_matrix_constants_generator.py create mode 100644 tests/variantstudy/model/command/test_create_st_storage.py create mode 100644 tests/variantstudy/model/command/test_remove_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 789b23a3bb..d21862be34 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/config/files.py +++ b/antarest/study/storage/rawstudy/model/filesystem/config/files.py @@ -26,7 +26,9 @@ Link, Simulation, transform_name_to_id, - Storage, +) +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, @@ -49,35 +51,38 @@ def build( study_path: Path, study_id: str, output_path: Optional[Path] = None ) -> "FileStudyTreeConfig": """ - Extract data from filesystem to build config study. - Args: - study_path: study_path with files inside. - study_id: uuid of the study - output_path: output_path if not in study_path/output + Extracts data from the filesystem to build a study config. - Returns: study config fill with data + Args: + study_path: Path to the study directory or ZIP file containing the study. + study_id: UUID of the study. + output_path: Optional path for the output directory. + If not provided, it will be set to `{study_path}/output`. + Returns: + An instance of `FileStudyTreeConfig` filled with the study data. """ - (sns, asi, enr_modelling) = _parse_parameters(study_path) + is_zip_file = study_path.suffix.lower() == ".zip" - study_path_without_zip_extension = study_path.parent / ( - study_path.stem if study_path.suffix == ".zip" else study_path.name - ) + # Study directory to use if the study is compressed + study_dir = study_path.with_suffix("") if is_zip_file else study_path + (sns, asi, enr_modelling) = _parse_parameters(study_path) + outputs_dir: Path = output_path or study_path / "output" return FileStudyTreeConfig( study_path=study_path, - output_path=output_path or study_path / "output", - path=study_path_without_zip_extension, + output_path=outputs_dir, + path=study_dir, study_id=study_id, version=_parse_version(study_path), areas=_parse_areas(study_path), sets=_parse_sets(study_path), - outputs=_parse_outputs(output_path or study_path / "output"), + outputs=_parse_outputs(outputs_dir), bindings=_parse_bindings(study_path), store_new_set=sns, archive_input_series=asi, enr_modelling=enr_modelling, - zip_path=study_path if study_path.suffix == ".zip" else None, + zip_path=study_path if is_zip_file else None, ) @@ -359,7 +364,7 @@ def parse_area(root: Path, area: str) -> "Area": renewables=_parse_renewables(root, area_id), filters_synthesis=_parse_filters_synthesis(root, area_id), filters_year=_parse_filters_year(root, area_id), - st_storage=_parse_st_storage(root, area_id), + st_storages=_parse_st_storage(root, area_id), ) @@ -379,21 +384,18 @@ def _parse_thermal(root: Path, area: str) -> List[Cluster]: ] -def _parse_st_storage(root: Path, area: str) -> List[Storage]: +def _parse_st_storage(root: Path, area: str) -> List[STStorageConfig]: """ Parse the short-term storage INI file, return an empty list if missing. """ - list_ini: Dict[str, Any] = _extract_data_from_file( + config_dict: Dict[str, Any] = _extract_data_from_file( root=root, inside_root_path=Path(f"input/st-storage/clusters/{area}/list.ini"), file_type=FileType.SIMPLE_INI, ) return [ - Storage( - id=transform_name_to_id(key), - name=values.get("name", key), - ) - for key, values in list_ini.items() + 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 507f884c50..31374e44ee 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/config/model.py +++ b/antarest/study/storage/rawstudy/model/filesystem/config/model.py @@ -3,10 +3,14 @@ from pathlib import Path from typing import Dict, List, Optional, Set +from pydantic import Extra + from antarest.core.model import JSON from antarest.core.utils.utils import DTO from pydantic.main import BaseModel +from .st_storage import STStorageConfig + class ENR_MODELLING(Enum): AGGREGATED = "aggregated" @@ -23,15 +27,6 @@ class Cluster(BaseModel): enabled: bool = True -class Storage(BaseModel): - """ - Short-term storage model used in Area creation - """ - - id: str - name: str - - class Link(BaseModel): """ Object linked to /input/links//properties.ini information @@ -59,6 +54,9 @@ class Area(BaseModel): Object linked to /input//optimization.ini information """ + class Config: + extra = Extra.forbid + name: str links: Dict[str, Link] thermals: List[Cluster] @@ -66,7 +64,7 @@ class Area(BaseModel): filters_synthesis: List[str] filters_year: List[str] # since v8.6 - storages: List[Storage] = [] + st_storages: List[STStorageConfig] = [] class DistrictSet(BaseModel): @@ -143,14 +141,14 @@ def __init__( self.study_id = study_id self.version = version self.output_path = output_path - self.areas = areas or dict() - self.sets = sets or dict() - self.outputs = outputs or dict() - self.bindings = bindings or list() + self.areas = areas or {} + self.sets = sets or {} + self.outputs = outputs or {} + self.bindings = bindings or [] self.store_new_set = store_new_set - self.archive_input_series = archive_input_series or list() + self.archive_input_series = archive_input_series or [] self.enr_modelling = enr_modelling - self.cache = cache or dict() + self.cache = cache or {} self.zip_path = zip_path def next_file( @@ -218,8 +216,7 @@ def get_thermal_names( def get_st_storage_names(self, area: str) -> List[str]: return self.cache.get( - f"%st-storage%{area}", - [storage.id for storage in self.areas[area].storages], + f"%st-storage%{area}", [s.id for s in self.areas[area].st_storages] ) def get_renewable_names( 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..3b26233764 --- /dev/null +++ b/antarest/study/storage/rawstudy/model/filesystem/config/st_storage.py @@ -0,0 +1,115 @@ +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" + + +# 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 + + # The `id` field is a calculated field that is excluded when converting + # the model to a dictionary or JSON format (`model_dump`). + id: str = Field( + description="Short-term storage ID", + regex=r"[a-zA-Z0-9_(),& -]+", + exclude=True, + ) + 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/rawstudy/model/filesystem/root/input/st_storage/series/area/st_storage/st_storage.py b/antarest/study/storage/rawstudy/model/filesystem/root/input/st_storage/series/area/st_storage/st_storage.py index ebc017b6a6..cc9980f70e 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/root/input/st_storage/series/area/st_storage/st_storage.py +++ b/antarest/study/storage/rawstudy/model/filesystem/root/input/st_storage/series/area/st_storage/st_storage.py @@ -13,12 +13,12 @@ class InputSTStorageAreaStorage(FolderNode): def build(self) -> TREE: children: TREE = { - "PMAX-injection": InputSeriesMatrix( + "pmax_injection": InputSeriesMatrix( self.context, self.config.next_file("PMAX-injection.txt"), default_empty=series.pmax_injection, ), - "PMAX-withdrawal": InputSeriesMatrix( + "pmax_withdrawal": InputSeriesMatrix( self.context, self.config.next_file("PMAX-withdrawal.txt"), default_empty=series.pmax_withdrawal, @@ -28,12 +28,12 @@ def build(self) -> TREE: self.config.next_file("inflows.txt"), default_empty=series.inflows, ), - "lower-rule-curve": InputSeriesMatrix( + "lower_rule_curve": InputSeriesMatrix( self.context, self.config.next_file("lower-rule-curve.txt"), default_empty=series.lower_rule_curve, ), - "upper-rule-curve": InputSeriesMatrix( + "upper_rule_curve": InputSeriesMatrix( self.context, self.config.next_file("upper-rule-curve.txt"), default_empty=series.upper_rule_curve, diff --git a/antarest/study/storage/variantstudy/business/command_reverter.py b/antarest/study/storage/variantstudy/business/command_reverter.py index fa75f6666a..bd96ae6376 100644 --- a/antarest/study/storage/variantstudy/business/command_reverter.py +++ b/antarest/study/storage/variantstudy/business/command_reverter.py @@ -33,6 +33,9 @@ from antarest.study.storage.variantstudy.model.command.create_renewables_cluster import ( CreateRenewablesCluster, ) +from antarest.study.storage.variantstudy.model.command.create_st_storage import ( + CreateSTStorage, +) from antarest.study.storage.variantstudy.model.command.icommand import ICommand from antarest.study.storage.variantstudy.model.command.remove_area import ( RemoveArea, @@ -52,6 +55,9 @@ from antarest.study.storage.variantstudy.model.command.remove_renewables_cluster import ( RemoveRenewablesCluster, ) +from antarest.study.storage.variantstudy.model.command.remove_st_storage import ( + RemoveSTStorage, +) from antarest.study.storage.variantstudy.model.command.replace_matrix import ( ReplaceMatrix, ) @@ -267,6 +273,31 @@ def _revert_remove_renewables_cluster( "The revert function for RemoveRenewablesCluster is not available" ) + @staticmethod + def _revert_create_st_storage( + base_command: CreateSTStorage, + history: List["ICommand"], + base: FileStudy, + ) -> List[ICommand]: + storage_id = base_command.parameters.id + return [ + RemoveSTStorage( + area_id=base_command.area_id, + storage_id=storage_id, + command_context=base_command.command_context, + ) + ] + + @staticmethod + def _revert_remove_st_storage( + base_command: RemoveSTStorage, + history: List["ICommand"], + base: FileStudy, + ) -> List[ICommand]: + raise NotImplementedError( + "The revert function for RemoveSTStorage is not available" + ) + @staticmethod def _revert_replace_matrix( base_command: ReplaceMatrix, history: List["ICommand"], base: FileStudy diff --git a/antarest/study/storage/variantstudy/business/matrix_constants/__init__.py b/antarest/study/storage/variantstudy/business/matrix_constants/__init__.py index dcc3e8d615..d5a9f5e10b 100644 --- a/antarest/study/storage/variantstudy/business/matrix_constants/__init__.py +++ b/antarest/study/storage/variantstudy/business/matrix_constants/__init__.py @@ -1 +1 @@ -from . import hydro, prepro, thermals, link +from . import hydro, prepro, thermals, link, st_storage diff --git a/antarest/study/storage/variantstudy/business/matrix_constants/st_storage/__init__.py b/antarest/study/storage/variantstudy/business/matrix_constants/st_storage/__init__.py index e69de29bb2..0a1b9046e5 100644 --- a/antarest/study/storage/variantstudy/business/matrix_constants/st_storage/__init__.py +++ b/antarest/study/storage/variantstudy/business/matrix_constants/st_storage/__init__.py @@ -0,0 +1 @@ +from . import series diff --git a/antarest/study/storage/variantstudy/business/matrix_constants_generator.py b/antarest/study/storage/variantstudy/business/matrix_constants_generator.py index dc0f7734d2..cd000f53ed 100644 --- a/antarest/study/storage/variantstudy/business/matrix_constants_generator.py +++ b/antarest/study/storage/variantstudy/business/matrix_constants_generator.py @@ -2,25 +2,23 @@ from pathlib import Path from typing import Dict -from filelock import FileLock - from antarest.matrixstore.service import ISimpleMatrixService from antarest.study.storage.variantstudy.business import matrix_constants from antarest.study.storage.variantstudy.business.matrix_constants.common import ( - NULL_MATRIX, - NULL_SCENARIO_MATRIX, FIXED_4_COLUMNS, FIXED_8_COLUMNS, + NULL_MATRIX, + NULL_SCENARIO_MATRIX, ) +from filelock import FileLock # TODO: put index into variable +# fmt: off HYDRO_COMMON_CAPACITY_MAX_POWER_V7 = "hydro/common/capacity/max_power/v7" HYDRO_COMMON_CAPACITY_RESERVOIR_V7 = "hydro/common/capacity/reservoir/v7" HYDRO_COMMON_CAPACITY_RESERVOIR_V6 = "hydro/common/capacity/reservoir/v6" HYDRO_COMMON_CAPACITY_INFLOW_PATTERN = "hydro/common/capacity/inflow_pattern" -HYDRO_COMMON_CAPACITY_CREDIT_MODULATION = ( - "hydro/common/capacity/credit_modulations" -) +HYDRO_COMMON_CAPACITY_CREDIT_MODULATION = "hydro/common/capacity/credit_modulations" RESERVES_TS = "reserves" MISCGEN_TS = "miscgen" PREPRO_CONVERSION = "prepro/conversion" @@ -33,9 +31,20 @@ LINK_INDIRECT = "link_indirect" NULL_MATRIX_NAME = "null_matrix" EMPTY_SCENARIO_MATRIX = "empty_scenario_matrix" +ONES_SCENARIO_MATRIX = "ones_scenario_matrix" +# fmt: on + +# Short-term storage aliases +ST_STORAGE_PMAX_INJECTION = ONES_SCENARIO_MATRIX +ST_STORAGE_PMAX_WITHDRAWAL = ONES_SCENARIO_MATRIX +ST_STORAGE_LOWER_RULE_CURVE = EMPTY_SCENARIO_MATRIX +ST_STORAGE_UPPER_RULE_CURVE = ONES_SCENARIO_MATRIX +ST_STORAGE_INFLOWS = EMPTY_SCENARIO_MATRIX + MATRIX_PROTOCOL_PREFIX = "matrix://" +# noinspection SpellCheckingInspection class GeneratorMatrixConstants: def __init__(self, matrix_service: ISimpleMatrixService) -> None: self.hashes: Dict[str, str] = {} @@ -98,6 +107,11 @@ def _init(self) -> None: self.hashes[RESERVES_TS] = self.matrix_service.create(FIXED_4_COLUMNS) self.hashes[MISCGEN_TS] = self.matrix_service.create(FIXED_8_COLUMNS) + # Some short-term storage matrices use np.ones((8760, 1)) + self.hashes[ONES_SCENARIO_MATRIX] = self.matrix_service.create( + matrix_constants.st_storage.series.pmax_injection + ) + def get_hydro_max_power(self, version: int) -> str: if version > 650: return ( @@ -164,3 +178,25 @@ def get_default_reserves(self) -> str: def get_default_miscgen(self) -> str: return MATRIX_PROTOCOL_PREFIX + self.hashes[MISCGEN_TS] + + # fmt: off + def get_st_storage_pmax_injection(self) -> str: + """2D-matrix of shape (8760, 1), filled-in with ones.""" + return MATRIX_PROTOCOL_PREFIX + self.hashes[ST_STORAGE_PMAX_INJECTION] + + def get_st_storage_pmax_withdrawal(self) -> str: + """2D-matrix of shape (8760, 1), filled-in with ones.""" + return MATRIX_PROTOCOL_PREFIX + self.hashes[ST_STORAGE_PMAX_WITHDRAWAL] + + def get_st_storage_lower_rule_curve(self) -> str: + """2D-matrix of shape (8760, 1), filled-in with zeros.""" + return MATRIX_PROTOCOL_PREFIX + self.hashes[ST_STORAGE_LOWER_RULE_CURVE] + + def get_st_storage_upper_rule_curve(self) -> str: + """2D-matrix of shape (8760, 1), filled-in with ones.""" + return MATRIX_PROTOCOL_PREFIX + self.hashes[ST_STORAGE_UPPER_RULE_CURVE] + + def get_st_storage_inflows(self) -> str: + """2D-matrix of shape (8760, 1), filled-in with zeros.""" + return MATRIX_PROTOCOL_PREFIX + self.hashes[ST_STORAGE_INFLOWS] + # fmt: on diff --git a/antarest/study/storage/variantstudy/command_factory.py b/antarest/study/storage/variantstudy/command_factory.py index 5555b517b0..5468323b57 100644 --- a/antarest/study/storage/variantstudy/command_factory.py +++ b/antarest/study/storage/variantstudy/command_factory.py @@ -27,6 +27,9 @@ from antarest.study.storage.variantstudy.model.command.create_renewables_cluster import ( CreateRenewablesCluster, ) +from antarest.study.storage.variantstudy.model.command.create_st_storage import ( + CreateSTStorage, +) from antarest.study.storage.variantstudy.model.command.icommand import ICommand from antarest.study.storage.variantstudy.model.command.remove_area import ( RemoveArea, @@ -46,6 +49,9 @@ from antarest.study.storage.variantstudy.model.command.remove_renewables_cluster import ( RemoveRenewablesCluster, ) +from antarest.study.storage.variantstudy.model.command.remove_st_storage import ( + RemoveSTStorage, +) from antarest.study.storage.variantstudy.model.command.replace_matrix import ( ReplaceMatrix, ) @@ -89,6 +95,8 @@ CommandName.REMOVE_THERMAL_CLUSTER.value: RemoveCluster, CommandName.CREATE_RENEWABLES_CLUSTER.value: CreateRenewablesCluster, CommandName.REMOVE_RENEWABLES_CLUSTER.value: RemoveRenewablesCluster, + CommandName.CREATE_ST_STORAGE.value: CreateSTStorage, + CommandName.REMOVE_ST_STORAGE.value: RemoveSTStorage, CommandName.REPLACE_MATRIX.value: ReplaceMatrix, CommandName.UPDATE_CONFIG.value: UpdateConfig, CommandName.UPDATE_COMMENTS.value: UpdateComments, @@ -154,7 +162,7 @@ def to_commands(self, cmd_dto_list: List[CommandDTO]) -> List[ICommand]: Convert a list of CommandDTO to a list of ICommand. Args: - cmd_dto_list: The CommandDTO objetcs to convert. + cmd_dto_list: The CommandDTO objects to convert. Returns: List: A list of ICommand instances. diff --git a/antarest/study/storage/variantstudy/model/command/common.py b/antarest/study/storage/variantstudy/model/command/common.py index 6de5b63e86..34c41402d6 100644 --- a/antarest/study/storage/variantstudy/model/command/common.py +++ b/antarest/study/storage/variantstudy/model/command/common.py @@ -40,6 +40,8 @@ class CommandName(Enum): REMOVE_THERMAL_CLUSTER = "remove_cluster" CREATE_RENEWABLES_CLUSTER = "create_renewables_cluster" REMOVE_RENEWABLES_CLUSTER = "remove_renewables_cluster" + CREATE_ST_STORAGE = "create_st_storage" + REMOVE_ST_STORAGE = "remove_st_storage" REPLACE_MATRIX = "replace_matrix" UPDATE_CONFIG = "update_config" UPDATE_COMMENTS = "update_comments" diff --git a/antarest/study/storage/variantstudy/model/command/create_cluster.py b/antarest/study/storage/variantstudy/model/command/create_cluster.py index e407ecb21b..75065341e8 100644 --- a/antarest/study/storage/variantstudy/model/command/create_cluster.py +++ b/antarest/study/storage/variantstudy/model/command/create_cluster.py @@ -115,6 +115,8 @@ def _apply(self, study_data: FileStudy) -> CommandOutput: cluster_list_config = study_data.tree.get( ["input", "thermal", "clusters", self.area_id, "list"] ) + # fixme: rigorously, the section name in the INI file is the cluster ID, not the cluster name + # cluster_list_config[transform_name_to_id(self.cluster_name)] = self.parameters cluster_list_config[self.cluster_name] = self.parameters self.parameters["name"] = self.cluster_name diff --git a/antarest/study/storage/variantstudy/model/command/create_renewables_cluster.py b/antarest/study/storage/variantstudy/model/command/create_renewables_cluster.py index d3ff859268..7033cb6833 100644 --- a/antarest/study/storage/variantstudy/model/command/create_renewables_cluster.py +++ b/antarest/study/storage/variantstudy/model/command/create_renewables_cluster.py @@ -96,6 +96,8 @@ def _apply(self, study_data: FileStudy) -> CommandOutput: # default values if "ts-interpretation" not in self.parameters: self.parameters["ts-interpretation"] = "power-generation" + # fixme: rigorously, the section name in the INI file is the cluster ID, not the cluster name + # cluster_list_config[transform_name_to_id(self.cluster_name)] = self.parameters cluster_list_config[self.cluster_name] = self.parameters self.parameters["name"] = self.cluster_name diff --git a/antarest/study/storage/variantstudy/model/command/create_st_storage.py b/antarest/study/storage/variantstudy/model/command/create_st_storage.py new file mode 100644 index 0000000000..b029336fab --- /dev/null +++ b/antarest/study/storage/variantstudy/model/command/create_st_storage.py @@ -0,0 +1,381 @@ +import json +from typing import Any, Dict, List, Optional, Tuple, Union, cast + +import numpy as np +from antarest.core.model import JSON +from antarest.matrixstore.model import MatrixData +from antarest.study.storage.rawstudy.model.filesystem.config.model import ( + Area, + FileStudyTreeConfig, +) +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 ( + GeneratorMatrixConstants, +) +from antarest.study.storage.variantstudy.business.utils import ( + strip_matrix_protocol, + validate_matrix, +) +from antarest.study.storage.variantstudy.model.command.common import ( + CommandName, + CommandOutput, +) +from antarest.study.storage.variantstudy.model.command.icommand import ( + MATCH_SIGNATURE_SEPARATOR, + ICommand, +) +from antarest.study.storage.variantstudy.model.model import CommandDTO +from pydantic import Field, validator, Extra +from pydantic.fields import ModelField + +# noinspection SpellCheckingInspection +_MATRIX_NAMES = ( + "pmax_injection", + "pmax_withdrawal", + "lower_rule_curve", + "upper_rule_curve", + "inflows", +) + +# Minimum required version. +REQUIRED_VERSION = 860 + +MatrixType = List[List[MatrixData]] + + +# noinspection SpellCheckingInspection +class CreateSTStorage(ICommand): + """ + Command used to create a short-terme storage in an area. + """ + + class Config: + extra = Extra.forbid + + # Overloaded parameters + # ===================== + + command_name = CommandName.CREATE_ST_STORAGE + version = 1 + + # Command parameters + # ================== + + area_id: str = Field(description="Area ID", regex=r"[a-z0-9_(),& -]+") + parameters: STStorageConfig + pmax_injection: Optional[Union[MatrixType, str]] = Field( + None, + description="Charge capacity (modulation)", + ) + pmax_withdrawal: Optional[Union[MatrixType, str]] = Field( + None, + description="Discharge capacity (modulation)", + ) + lower_rule_curve: Optional[Union[MatrixType, str]] = Field( + None, + description="Lower rule curve (coefficient)", + ) + upper_rule_curve: Optional[Union[MatrixType, str]] = Field( + None, + description="Upper rule curve (coefficient)", + ) + inflows: Optional[Union[MatrixType, str]] = Field( + None, + description="Inflows (MW)", + ) + + @property + def storage_id(self) -> str: + """The normalized version of the storage's name used as the ID.""" + return self.parameters.id + + @property + def storage_name(self) -> str: + """The label representing the name of the storage for the user.""" + return self.parameters.name + + @validator(*_MATRIX_NAMES, always=True) + def register_matrix( + cls, + v: Optional[Union[MatrixType, str]], + values: Dict[str, Any], + field: ModelField, + ) -> Optional[Union[MatrixType, str]]: + """ + Validates a matrix array or link, and store the matrix array in the matrix repository. + + This method is used to validate the matrix array or link provided as input. + + - If the input is `None`, it retrieves a default matrix from the + generator matrix constants. + - If the input is a string, it validates the matrix link. + - If the input is a list of lists, it validates the matrix values + and creates the corresponding matrix link. + + Args: + v: The matrix array or link to be validated and registered. + values: A dictionary containing additional values used for validation. + field: The field being validated. + + Returns: + The ID of the validated and stored matrix prefixed by "matrix://". + + Raises: + ValueError: If the matrix has an invalid shape, contains NaN values, + or violates specific constraints. + TypeError: If the input datatype is not supported. + """ + if v is None: + # use an already-registered default matrix + constants: GeneratorMatrixConstants + constants = values["command_context"].generator_matrix_constants + # Directly access the methods instead of using `getattr` for maintainability + methods = { + "pmax_injection": constants.get_st_storage_pmax_injection, + "pmax_withdrawal": constants.get_st_storage_pmax_withdrawal, + "lower_rule_curve": constants.get_st_storage_lower_rule_curve, + "upper_rule_curve": constants.get_st_storage_upper_rule_curve, + "inflows": constants.get_st_storage_inflows, + } + method = methods[field.name] + return method() + if isinstance(v, str): + # Check the matrix link + return validate_matrix(v, values) + if isinstance(v, list): + # Check the matrix values and create the corresponding matrix link + array = np.array(v, dtype=np.float64) + if array.shape != (8760, 1): + raise ValueError( + f"Invalid matrix shape {array.shape}, expected (8760, 1)" + ) + if np.isnan(array).any(): + raise ValueError("Matrix values cannot contain NaN") + # All matrices except "inflows" are constrained between 0 and 1 + constrained = set(_MATRIX_NAMES) - {"inflows"} + if field.name in constrained and ( + np.any(array < 0) or np.any(array > 1) + ): + raise ValueError("Matrix values should be between 0 and 1") + v = cast(MatrixType, array.tolist()) + return validate_matrix(v, values) + # Invalid datatype + # pragma: no cover + raise TypeError(repr(v)) + + def _apply_config( + self, study_data: FileStudyTreeConfig + ) -> Tuple[CommandOutput, Dict[str, Any]]: + """ + Applies configuration changes to the study data: add the short-term storage in the storages list. + + Args: + study_data: The study data configuration. + + Returns: + A tuple containing the command output and a dictionary of extra data. + On success, the dictionary of extra data is `{"storage_id": storage_id}`. + """ + + # Check if the study version is above the minimum required version. + version = study_data.version + if version < REQUIRED_VERSION: + return ( + CommandOutput( + status=False, + message=( + f"Invalid study version {version}," + f" at least version {REQUIRED_VERSION} is required." + ), + ), + {}, + ) + + # Search the Area in the configuration + if self.area_id not in study_data.areas: + return ( + CommandOutput( + status=False, + message=f"Area '{self.area_id}' does not exist in the study configuration.", + ), + {}, + ) + 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): + return ( + CommandOutput( + status=False, + message=( + f"Short-term storage '{self.storage_name}' already exists" + f" in the area '{self.area_id}'." + ), + ), + {}, + ) + + # Create a new short-term storage and add it to the area + area.st_storages.append(self.parameters) + + return ( + CommandOutput( + status=True, + message=( + f"Short-term st_storage '{self.storage_name}' successfully added" + f" to area '{self.area_id}'." + ), + ), + {"storage_id": self.storage_id}, + ) + + def _apply(self, study_data: FileStudy) -> CommandOutput: + """ + Applies the study data to update storage configurations and saves the changes. + + Saves the changes made to the storage configurations. + + Args: + study_data: The study data to be applied. + + Returns: + The output of the command execution. + """ + output, data = self._apply_config(study_data.config) + if not output.status: + return output + + # Fill-in the "list.ini" file with the parameters + 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) + ) + + new_data: JSON = { + "input": { + "st-storage": { + "clusters": {self.area_id: {"list": config}}, + "series": { + self.area_id: { + self.storage_id: { + attr: getattr(self, attr) + for attr in _MATRIX_NAMES + } + } + }, + } + } + } + study_data.tree.save(new_data) + + return output + + def to_dto(self) -> CommandDTO: + """ + Converts the current object to a Data Transfer Object (DTO) + which is stored in the `CommandBlock` in the database. + + Returns: + The DTO object representing the current command. + """ + parameters = json.loads(self.parameters.json(by_alias=True)) + return CommandDTO( + action=self.command_name.value, + args={ + "area_id": self.area_id, + "parameters": parameters, + **{ + attr: strip_matrix_protocol(getattr(self, attr)) + for attr in _MATRIX_NAMES + }, + }, + ) + + def match_signature(self) -> str: + """Returns the command signature.""" + return str( + self.command_name.value + + MATCH_SIGNATURE_SEPARATOR + + self.area_id + + MATCH_SIGNATURE_SEPARATOR + + self.storage_id + ) + + def match(self, other: "ICommand", equal: bool = False) -> bool: + """ + Checks if the current instance matches another `ICommand` object. + + Args: + other: Another `ICommand` object to compare against. + equal: Flag indicating whether to perform a deep comparison. + + Returns: + bool: `True` if the current instance matches the other object, `False` otherwise. + """ + if not isinstance(other, CreateSTStorage): + return False + if equal: + # Deep comparison + return self.__eq__(other) + else: + return ( + self.area_id == other.area_id + and self.storage_id == other.storage_id + ) + + def _create_diff(self, other: "ICommand") -> List["ICommand"]: + """ + Creates a list of commands representing the differences between + the current instance and another `ICommand` object. + + Args: + other: Another ICommand object to compare against. + + Returns: + A list of commands representing the differences between + the two `ICommand` objects. + """ + from antarest.study.storage.variantstudy.model.command.replace_matrix import ( + ReplaceMatrix, + ) + from antarest.study.storage.variantstudy.model.command.update_config import ( + UpdateConfig, + ) + + other = cast(CreateSTStorage, other) + commands: List[ICommand] = [ + ReplaceMatrix( + target=f"input/st-storage/series/{self.area_id}/{self.storage_id}/{attr}", + matrix=strip_matrix_protocol(getattr(other, attr)), + command_context=self.command_context, + ) + for attr in _MATRIX_NAMES + if getattr(self, attr) != getattr(other, attr) + ] + if self.parameters != other.parameters: + data: Dict[str, Any] = json.loads( + other.parameters.json(by_alias=True) + ) + commands.append( + UpdateConfig( + target=f"input/st-storage/clusters/{self.area_id}/list/{self.storage_id}", + data=data, + command_context=self.command_context, + ) + ) + return commands + + def get_inner_matrices(self) -> List[str]: + """ + Retrieves the list of matrix IDs. + """ + matrices: List[str] = [ + strip_matrix_protocol(getattr(self, attr)) + for attr in _MATRIX_NAMES + ] + return matrices diff --git a/antarest/study/storage/variantstudy/model/command/remove_st_storage.py b/antarest/study/storage/variantstudy/model/command/remove_st_storage.py new file mode 100644 index 0000000000..71c3994e2f --- /dev/null +++ b/antarest/study/storage/variantstudy/model/command/remove_st_storage.py @@ -0,0 +1,169 @@ +from typing import Any, Dict, Tuple, List + +from antarest.study.storage.rawstudy.model.filesystem.config.model import ( + Area, + FileStudyTreeConfig, +) +from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy +from antarest.study.storage.variantstudy.model.command.common import ( + CommandName, + CommandOutput, +) +from antarest.study.storage.variantstudy.model.command.icommand import ( + ICommand, + MATCH_SIGNATURE_SEPARATOR, +) +from pydantic import Field + +from antarest.study.storage.variantstudy.model.model import CommandDTO + +# minimum required version. +REQUIRED_VERSION = 860 + + +class RemoveSTStorage(ICommand): + """ + Command used to remove a short-terme storage from an area. + """ + + area_id: str = Field(description="Area ID", regex=r"[a-z0-9_(),& -]+") + storage_id: str = Field( + description="Short term storage ID", + regex=r"[a-z0-9_(),& -]+", + ) + + def __init__(self, **data: Any) -> None: + super().__init__( + command_name=CommandName.REMOVE_ST_STORAGE, version=1, **data + ) + + def _apply_config( + self, study_data: FileStudyTreeConfig + ) -> Tuple[CommandOutput, Dict[str, Any]]: + """ + Applies configuration changes to the study data: remove the storage from the storages list. + + Args: + study_data: The study data configuration. + + Returns: + A tuple containing the command output and a dictionary of extra data. + On success, the dictionary is empty. + """ + # Check if the study version is above the minimum required version. + version = study_data.version + if version < REQUIRED_VERSION: + return ( + CommandOutput( + status=False, + message=( + f"Invalid study version {version}," + f" at least version {REQUIRED_VERSION} is required." + ), + ), + {}, + ) + + # Search the Area in the configuration + if self.area_id not in study_data.areas: + return ( + CommandOutput( + status=False, + message=( + f"Area '{self.area_id}' does not exist" + f" in the study configuration." + ), + ), + {}, + ) + area: Area = study_data.areas[self.area_id] + + # Search the Short term storage in the area + for st_storage in area.st_storages: + if st_storage.id == self.storage_id: + break + else: + return ( + CommandOutput( + status=False, + message=( + f"Short term storage '{self.storage_id}' does not exist" + f" in the area '{self.area_id}'." + ), + ), + {}, + ) + + # Remove the Short term storage from the configuration + area.st_storages.remove(st_storage) + + return ( + CommandOutput( + status=True, + message=( + f"Short term storage '{self.storage_id}' removed" + f" from the area '{self.area_id}'." + ), + ), + {}, + ) + + def _apply(self, study_data: FileStudy) -> CommandOutput: + """ + Applies the study data to update storage configurations and saves the changes: + remove the storage from the configuration and remove the attached time series. + + Args: + study_data: The study data to be applied. + + Returns: + The output of the command execution. + """ + # It is required to delete the files and folders that correspond to the short-term storage + # BEFORE updating the configuration, as we need the configuration to do so. + # Specifically, deleting the time series uses the list of short-term storages from the configuration. + # fmt: off + paths = [ + ["input", "st-storage", "clusters", self.area_id, "list", self.storage_id], + ["input", "st-storage", "series", self.area_id, self.storage_id], + ] + # fmt: on + for path in paths: + study_data.tree.delete(path) + # Deleting the short-term storage in the configuration must be done AFTER + # deleting the files and folders. + return self._apply_config(study_data.config)[0] + + def to_dto(self) -> CommandDTO: + """ + Converts the current object to a Data Transfer Object (DTO) + which is stored in the `CommandBlock` in the database. + + Returns: + The DTO object representing the current command. + """ + return CommandDTO( + action=self.command_name.value, + args={"area_id": self.area_id, "storage_id": self.storage_id}, + ) + + def match_signature(self) -> str: + """Returns the command signature.""" + return str( + self.command_name.value + + MATCH_SIGNATURE_SEPARATOR + + self.area_id + + MATCH_SIGNATURE_SEPARATOR + + self.storage_id + ) + + def match(self, other: "ICommand", equal: bool = False) -> bool: + # always perform a deep comparison, as there are no parameters + # or matrices, so that shallow and deep comparisons are identical. + return self.__eq__(other) + + def _create_diff(self, other: "ICommand") -> List["ICommand"]: + return [] + + def get_inner_matrices(self) -> List[str]: + return [] diff --git a/docs/user-guide/2-variant_manager.md b/docs/user-guide/2-variant_manager.md index 892a57b524..6762ee4c86 100644 --- a/docs/user-guide/2-variant_manager.md +++ b/docs/user-guide/2-variant_manager.md @@ -53,31 +53,9 @@ The example provided bellow demonstrates the creation of two areas and a link be ## Command list -### Base commands - -| Action Name | Arguments | Description | -|---------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------| -| update_config |
{| Update arbitrary config | -| replace_matrix |
target: <INI_TARGET>
data: <INI_MODEL>
}
{| Replace arbitrary matrix | -| create_area |
target: <INPUT_SERIES_MATRIX>
matrix: <MATRIX>
}
{| Create a new area | -| remove_area |
area_name: <STRING>
}
{| Remove an existing area | -| create_cluster |
id: <AREA_ID>
}
{| Create a new thermal cluster | -| remove_cluster |
area_id: <AREA_ID>
cluster_name: <STRING>
prepro?: <STRING>
modulation?: <MATRIX>
parameters?: <INI_MODEL>
}
{| Remove an existing thermal cluster | -| create_renewables_cluster |
area_id: <AREA_ID>
cluster_id: <CLUSTER_ID>
}
{| Create a new renewable cluster | -| remove_renewables_cluster |
area_id: <AREA_ID>
cluster_name: <STRING>
parameters?: <INI_MODEL>
}
{| Remove an existing renewable cluster | -| create_link |
area_id: <AREA_ID>
cluster_id: <CLUSTER_ID>
}
{| Create a new link | -| remove_link |
area1: <AREA_ID>
area2: <AREA_ID>
parameters?: <INI_MODEL>
series?: <MATRIX>
}
{| Remove an existing link | -| create_district |
area1: <AREA_ID>
area2: <AREA_ID>
}
{| Create a new district (set of areas) | -| remove_district |
name: <STRING>
base_filter?: "add-all" | "remove-all"
filter_items?: <LIST[AREA_ID]>
output?: <BOOLEAN> (default: True)
comments?: <STRING>
}
{| Remove an existing district | -| create_binding_constraint |
id: <DISTRICT_ID>
}
{
name: <STRING>
enabled?: <BOOLEAN> (default: True)
time_step: "hourly" | "weekly" | "daily"
operator: "equal" | "both" | "greater" | "less"
coeffs: <LIST[CONSTRAINT_COEFF]>
values?: <MATRIX>
comments?: <STRING>
}
{| Create a new binding constraint | -| update_binding_constraint |
type: <"cluster" | "link" (choosing one or the other imply filling the right corresponding parameter below)>
link: <AREA_ID>%<AREA_ID> (link)
cluster: <AREA_ID>.<CLUSTER_ID>
coeff: <NUMBER>
offset?: <NUMBER>
}
{| Update an existing binding constraint | -| remove_binding_constraint |
id: <BINDING_CONSTRAINT_ID>
enabled?: <BOOLEAN> (default: True)
time_step: "hourly" | "weekly" | "daily"
operator: "equal" | "both" | "greater" | "less"
coeffs: <LIST[CONSTRAINT_COEFF]>
values?: <MATRIX>
comments?: <STRING>
}
{| Remove an existing binding constraint | -| update_playlist |
id: <BINDING_CONSTRAINT_ID>
}
{| Update the playlist with provided active (or inactive) years (starting from year 1) | -| update_scenario_builder |
active: <BOOLEAN> (default: True)
reverse: <BOOLEAN> (default: False)
items: <LIST[NUMBER]> (default: None)
}
{| Update scenario builder with partial configuration | -| update_district |
data: <RULESETS_MODEL>
}
{| Update a district (set of areas) | -| update_raw_file |
id: <STRING>
base_filter?: "add-all" | "remove-all"
filter_items?: <LIST[AREA_ID]>
output?: <BOOLEAN> (default: True)
comments?: <STRING>
}
{| Replace arbitrary data file (must not be a matrix or ini target) with a base64 encoded data | - -#### Base types +### Base types + +The following table describes the data types used in the commands: | Type | Description | |----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| @@ -89,11 +67,288 @@ The example provided bellow demonstrates the creation of two areas and a link be | RULESETS_MODEL | like `INI_MODEL` with some specifications: an empty string allows to remove a key (ruleset or cell value) and a ruleset "A" with for value the name of an another ruleset "B" allows to clone the content of "B" in "A" | | INPUT_RAW_FILE_TARGET | a valid antares raw data file relative path (without extension). The path can be found when browsing the study in detailed view | | INPUT_SERIES_MATRIX_TARGET | a valid antares matrix data file relative path (without extension). The path can be found when browsing the study in detailed view | -| MATRIX | a matrix id or a list of list of values (eg. [[0,1,2],[4,5,6]] where each sub list is a row of the matrix). Matrix id can be found in the Matrix Data manager tab. | -| AREA_ID | the id of an area (same as name, but lower cased and only with the following characters: [a-z],[0-9]_,(,),-,&,",". Other characters will be transformed into a single space.) | -| CLUSTER_ID | the id of a cluster (same as name, but lower cased and only with the following characters: [a-z],[0-9]_,(,),-,&,",". Other characters will be transformed into a single space.) | -| DISTRICT_ID | the id of a district (same as name, but lower cased and only with the following characters: [a-z],[0-9]_,(,),-,&,",". Other characters will be transformed into a single space.) | -| BINDING_CONSTRAINT_ID | the id of a binding constraint (same as name, but lower cased and only with the following characters: [a-z],[0-9]_,(,),-,&,",". Other characters will be transformed into a single space.) | +| MATRIX | a matrix id or a list of list of values (eg. [[0,1,2],[4,5,6]] where each sub list is a row of the matrix). Matrix ID can be found in the Matrix Data manager tab. | +| AREA_ID | the ID of an area (same as name, but lower cased and only with the following characters: [a-z],[0-9]_,(,),-,&,",". Other characters will be transformed into a single space.) | +| CLUSTER_ID | the ID of a cluster (same as name, but lower cased and only with the following characters: [a-z],[0-9]_,(,),-,&,",". Other characters will be transformed into a single space.) | +| STORAGE_ID | the ID of a short-term storage (same as name, but lower cased and only with the following characters: [a-z],[0-9]_,(,),-,&,",". Other characters will be transformed into a single space.) | +| DISTRICT_ID | the ID of a district (same as name, but lower cased and only with the following characters: [a-z],[0-9]_,(,),-,&,",". Other characters will be transformed into a single space.) | +| BINDING_CONSTRAINT_ID | the ID of a binding constraint (same as name, but lower cased and only with the following characters: [a-z],[0-9]_,(,),-,&,",". Other characters will be transformed into a single space.) | + +### `update_config` + +Update arbitrary config + +```json +{ + "target": "
target: <INPUT_RAW_FILE_TARGET>
b64Data: <STRING>
}