diff --git a/antarest/study/business/areas/renewable_management.py b/antarest/study/business/areas/renewable_management.py index 67ffa9dd1a..5cc7aa0264 100644 --- a/antarest/study/business/areas/renewable_management.py +++ b/antarest/study/business/areas/renewable_management.py @@ -1,9 +1,6 @@ -from enum import Enum from pathlib import PurePosixPath from typing import Any, Dict, List, Optional -from pydantic import Field - from antarest.study.business.enum_ignore_case import EnumIgnoreCase from antarest.study.business.utils import ( FieldInfo, @@ -15,6 +12,7 @@ from antarest.study.storage.variantstudy.model.command.update_config import ( UpdateConfig, ) +from pydantic import Field class TimeSeriesInterpretation(EnumIgnoreCase): diff --git a/antarest/study/storage/rawstudy/model/filesystem/ini_file_node.py b/antarest/study/storage/rawstudy/model/filesystem/ini_file_node.py index d86fc9c235..bf65180824 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/ini_file_node.py +++ b/antarest/study/storage/rawstudy/model/filesystem/ini_file_node.py @@ -1,12 +1,14 @@ import contextlib +import functools import io +import logging import os import json import tempfile import zipfile from json import JSONDecodeError from pathlib import Path -from typing import List, Optional, cast, Dict, Any, Union +from typing import List, Optional, cast, Dict, Any, Union, Callable from filelock import FileLock @@ -27,6 +29,44 @@ ) +class IniFileNodeWarning(UserWarning): + """ + Custom User Warning subclass for INI file-related warnings. + + This warning class is designed to provide more informative warning messages for INI file errors. + + Args: + config: The configuration associated with the INI file. + message: The specific warning message. + """ + + def __init__(self, config: FileStudyTreeConfig, message: str) -> None: + relpath = config.path.relative_to(config.study_path).as_posix() + super().__init__(f"INI File error '{relpath}': {message}") + + +def log_warning(f: Callable[..., Any]) -> Callable[..., Any]: + """ + Decorator to suppress `UserWarning` exceptions by logging them as warnings. + + Args: + f: The function or method to be decorated. + + Returns: + Callable[..., Any]: The decorated function. + """ + + @functools.wraps(f) + def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return f(*args, **kwargs) + except UserWarning as w: + # noinspection PyUnresolvedReferences + logging.getLogger(f.__module__).warning(str(w)) + + return wrapper + + class IniFileNode(INode[SUB_JSON, SUB_JSON, JSON]): def __init__( self, @@ -125,27 +165,69 @@ def save(self, data: SUB_JSON, url: Optional[List[str]] = None) -> None: info = cast(JSON, obj) self.writer.write(info, self.path) + @log_warning def delete(self, url: Optional[List[str]] = None) -> None: - url = url or [] - if len(url) == 0: - if self.config.path.exists(): - self.config.path.unlink() - elif len(url) > 0: - data = self.reader.read(self.path) if self.path.exists() else {} + """ + Deletes the specified section or key from the INI file, + or the entire INI file if no URL is provided. + + Args: + url: A list containing the URL components [section_name, key_name]. + + Raises: + IniFileNodeWarning: + If the specified section or key cannot be deleted due to errors such as + missing configuration file, non-resolved URL, or non-existent section/key. + """ + if not self.path.exists(): + raise IniFileNodeWarning( + self.config, + "fCannot delete item {url!r}: Config file not found", + ) + + if not url: + self.config.path.unlink() + return + + url_len = len(url) + if url_len > 2: + raise IniFileNodeWarning( + self.config, + f"Cannot delete item {url!r}: URL should be fully resolved", + ) + + data = self.reader.read(self.path) + + if url_len == 1: section_name = url[0] - if len(url) == 1: - with contextlib.suppress(KeyError): - del data[section_name] - elif len(url) == 2: - # remove dict key - key_name = url[1] - with contextlib.suppress(KeyError): - del data[section_name][key_name] + try: + del data[section_name] + except KeyError: + raise IniFileNodeWarning( + self.config, + f"Cannot delete section: Section [{section_name}] not found", + ) from None + + elif url_len == 2: + section_name, key_name = url + try: + section = data[section_name] + except KeyError: + raise IniFileNodeWarning( + self.config, + f"Cannot delete key: Section [{section_name}] not found", + ) from None else: - raise ValueError( - f"url should be fully resolved when arrives on {self.__class__.__name__}" - ) - self.writer.write(data, self.path) + try: + del section[key_name] + except KeyError: + raise IniFileNodeWarning( + self.config, + f"Cannot delete key: Key '{key_name}'" + f" not found in section [{section_name}]", + ) from None + + self.writer.write(data, self.path) def check_errors( self, diff --git a/antarest/study/storage/variantstudy/model/command/create_area.py b/antarest/study/storage/variantstudy/model/command/create_area.py index 44e187c18a..0a435f7d3e 100644 --- a/antarest/study/storage/variantstudy/model/command/create_area.py +++ b/antarest/study/storage/variantstudy/model/command/create_area.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Tuple from antarest.core.model import JSON from antarest.study.common.default_values import ( @@ -9,6 +9,7 @@ Area, FileStudyTreeConfig, transform_name_to_id, + ENR_MODELLING, ) from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy from antarest.study.storage.variantstudy.model.command.common import ( @@ -22,6 +23,22 @@ from antarest.study.storage.variantstudy.model.model import CommandDTO +# noinspection SpellCheckingInspection +def _generate_new_thermal_areas_ini( + file_study: FileStudy, + area_id: str, + *, + unserverdenergycost: float, + spilledenergycost: float, +) -> JSON: + new_areas: JSON = file_study.tree.get(["input", "thermal", "areas"]) + if unserverdenergycost is not None: + new_areas["unserverdenergycost"][area_id] = unserverdenergycost + if spilledenergycost is not None: + new_areas["spilledenergycost"][area_id] = spilledenergycost + return new_areas + + class CreateArea(ICommand): area_name: str @@ -32,23 +49,6 @@ def __init__(self, **data: Any) -> None: **data, ) - def _generate_new_thermal_areas_ini( - self, - file_study: FileStudy, - area_id: str, - unserverdenergycost: Optional[float] = None, - spilledenergycost: Optional[float] = None, - ) -> JSON: - new_areas: JSON = file_study.tree.get( - url=["input", "thermal", "areas"] - ) - if unserverdenergycost is not None: - new_areas["unserverdenergycost"][area_id] = unserverdenergycost - if spilledenergycost is not None: - new_areas["spilledenergycost"][area_id] = spilledenergycost - - return new_areas - def _apply_config( self, study_data: FileStudyTreeConfig ) -> Tuple[CommandOutput, Dict[str, Any]]: @@ -198,7 +198,7 @@ def _apply(self, study_data: FileStudy) -> CommandOutput: }, "thermal": { "clusters": {area_id: {"list": {}}}, - "areas": self._generate_new_thermal_areas_ini( + "areas": _generate_new_thermal_areas_ini( study_data, area_id, unserverdenergycost=NodalOptimization.UNSERVERDDENERGYCOST, @@ -247,6 +247,14 @@ def _apply(self, study_data: FileStudy) -> CommandOutput: ) # fmt: on + if ( + version >= 810 + and study_data.config.enr_modelling == ENR_MODELLING.CLUSTERS.value + ): + new_area_data["input"]["renewables"] = { + "clusters": {area_id: {"list": {}}}, + } + if version >= 830: new_area_data["input"]["areas"][area_id]["adequacy_patch"] = { "adequacy-patch": {"adequacy-patch-mode": "outside"} 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 7033cb6833..4d4397d527 100644 --- a/antarest/study/storage/variantstudy/model/command/create_renewables_cluster.py +++ b/antarest/study/storage/variantstudy/model/command/create_renewables_cluster.py @@ -1,24 +1,23 @@ -from typing import Dict, List, Any, cast, Tuple - -from pydantic import validator +from typing import Any, Dict, List, Tuple, cast from antarest.core.model import JSON from antarest.study.storage.rawstudy.model.filesystem.config.model import ( + ENR_MODELLING, Cluster, - transform_name_to_id, FileStudyTreeConfig, - ENR_MODELLING, + transform_name_to_id, ) from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy from antarest.study.storage.variantstudy.model.command.common import ( - CommandOutput, CommandName, + CommandOutput, ) from antarest.study.storage.variantstudy.model.command.icommand import ( - ICommand, MATCH_SIGNATURE_SEPARATOR, + ICommand, ) from antarest.study.storage.variantstudy.model.model import CommandDTO +from pydantic import validator class CreateRenewablesCluster(ICommand): @@ -46,40 +45,31 @@ def _apply_config( self, study_data: FileStudyTreeConfig ) -> Tuple[CommandOutput, Dict[str, Any]]: if study_data.enr_modelling != ENR_MODELLING.CLUSTERS.value: - return ( - CommandOutput( - status=False, - message=f"enr_modelling must be {ENR_MODELLING.CLUSTERS.value}", - ), - dict(), + message = ( + f"Parameter 'renewable-generation-modelling'" + f" must be set to '{ENR_MODELLING.CLUSTERS.value}'" + f" instead of '{study_data.enr_modelling}'" ) + return CommandOutput(status=False, message=message), {} if self.area_id not in study_data.areas: - return ( - CommandOutput( - status=False, - message=f"Area '{self.area_id}' does not exist", - ), - dict(), - ) + message = f"Area '{self.area_id}' does not exist" + return CommandOutput(status=False, message=message), {} + cluster_id = transform_name_to_id(self.cluster_name) for cluster in study_data.areas[self.area_id].renewables: if cluster.id == cluster_id: - return ( - CommandOutput( - status=False, - message=f"Renewable cluster '{self.cluster_name}' already exist", - ), - dict(), + message = ( + f"Renewable cluster '{self.cluster_name}' already exist" ) + return CommandOutput(status=False, message=message), {} + study_data.areas[self.area_id].renewables.append( Cluster(id=cluster_id, name=self.cluster_name) ) + message = f"Renewable cluster '{self.cluster_name}' added to area '{self.area_id}'" return ( - CommandOutput( - status=True, - message=f"Renewable cluster '{self.cluster_name}' added to area '{self.area_id}'", - ), + CommandOutput(status=True, message=message), {"cluster_id": cluster_id}, ) diff --git a/antarest/study/storage/variantstudy/model/command/remove_renewables_cluster.py b/antarest/study/storage/variantstudy/model/command/remove_renewables_cluster.py index e81e7bab81..96e3323c9f 100644 --- a/antarest/study/storage/variantstudy/model/command/remove_renewables_cluster.py +++ b/antarest/study/storage/variantstudy/model/command/remove_renewables_cluster.py @@ -2,6 +2,7 @@ from antarest.study.storage.rawstudy.model.filesystem.config.model import ( FileStudyTreeConfig, + Area, ) from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy from antarest.study.storage.variantstudy.business.utils_binding_constraint import ( @@ -29,116 +30,95 @@ def __init__(self, **data: Any) -> None: **data, ) - def _remove_renewables_cluster( - self, study_data_config: FileStudyTreeConfig - ) -> None: - study_data_config.areas[self.area_id].renewables = [ - cluster - for cluster in study_data_config.areas[self.area_id].renewables - if cluster.id != self.cluster_id.lower() - ] - - remove_area_cluster_from_binding_constraints( - study_data_config, self.area_id, self.cluster_id - ) - def _apply_config( self, study_data: FileStudyTreeConfig ) -> Tuple[CommandOutput, Dict[str, Any]]: - if self.area_id not in study_data.areas: - return ( - CommandOutput( - status=False, - message=f"Area '{self.area_id}' does not exist", - ), - dict(), - ) + """ + Applies configuration changes to the study data: remove the renewable clusters from the storages list. - if ( - len( - [ - cluster - for cluster in study_data.areas[self.area_id].renewables - if cluster.id == self.cluster_id - ] - ) - == 0 - ): - return ( - CommandOutput( - status=False, - message=f"Renewables cluster '{self.cluster_id}' does not exist", - ), - dict(), - ) - self._remove_renewables_cluster(study_data) + Args: + study_data: The study data configuration. - return ( - CommandOutput( - status=True, - message=f"Renewables cluster '{self.cluster_id}' removed from area '{self.area_id}'", + Returns: + A tuple containing the command output and a dictionary of extra data. + On success, the dictionary is empty. + """ + # Search the Area in the configuration + if self.area_id not in study_data.areas: + message = ( + f"Area '{self.area_id}' does not exist" + f" in the study configuration." + ) + return CommandOutput(status=False, message=message), {} + area: Area = study_data.areas[self.area_id] + + # Search the Renewable cluster in the area + renewable = next( + iter( + renewable + for renewable in area.renewables + if renewable.id == self.cluster_id ), - dict(), + None, ) - - def _apply(self, study_data: FileStudy) -> CommandOutput: - if self.area_id not in study_data.config.areas: - return CommandOutput( - status=False, - message=f"Area '{self.area_id}' does not exist", - ) - - if ( - len( - [ - cluster - for cluster in study_data.config.areas[ - self.area_id - ].renewables - if cluster.id == self.cluster_id - ] - ) - == 0 - ): - return CommandOutput( - status=False, - message=f"Renewables cluster '{self.cluster_id}' does not exist", + if renewable is None: + message = ( + f"Renewable cluster '{self.cluster_id}' does not exist" + f" in the area '{self.area_id}'." ) + return CommandOutput(status=False, message=message), {} - if len(study_data.config.areas[self.area_id].renewables) == 1: - study_data.tree.delete( - [ - "input", - "renewables", - ] - ) + for renewable in area.renewables: + if renewable.id == self.cluster_id: + break else: - study_data.tree.delete( - [ - "input", - "renewables", - "clusters", - self.area_id, - "list", - self.cluster_id, - ] - ) - study_data.tree.delete( - [ - "input", - "renewables", - "series", - self.area_id, - self.cluster_id, - ] + message = ( + f"Renewable cluster '{self.cluster_id}' does not exist" + f" in the area '{self.area_id}'." ) + return CommandOutput(status=False, message=message), {} - self._remove_renewables_cluster(study_data.config) + # Remove the Renewable cluster from the configuration + area.renewables.remove(renewable) - return CommandOutput( - status=True, - message=f"Renewables cluster '{self.cluster_id}' removed from area '{self.area_id}'", + remove_area_cluster_from_binding_constraints( + study_data, self.area_id, self.cluster_id + ) + + message = ( + f"Renewable cluster '{self.cluster_id}' removed" + f" from the area '{self.area_id}'." ) + return CommandOutput(status=True, message=message), {} + + def _apply(self, study_data: FileStudy) -> CommandOutput: + """ + Applies the study data to update renewable cluster configurations and saves the changes: + remove corresponding 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 renewable cluster + # BEFORE updating the configuration, as we need the configuration to do so. + # Specifically, deleting the time series uses the list of renewable clusters from the configuration. + # fmt: off + paths = [ + ["input", "renewables", "clusters", self.area_id, "list", self.cluster_id], + ["input", "renewables", "series", self.area_id, self.cluster_id], + ] + area: Area = study_data.config.areas[self.area_id] + if len(area.renewables) == 1: + paths.append(["input", "renewables", "series", self.area_id]) + # fmt: on + for path in paths: + study_data.tree.delete(path) + # Deleting the renewable cluster 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: return CommandDTO( diff --git a/antarest/study/storage/variantstudy/model/command/remove_st_storage.py b/antarest/study/storage/variantstudy/model/command/remove_st_storage.py index 71c3994e2f..1a9eea8af3 100644 --- a/antarest/study/storage/variantstudy/model/command/remove_st_storage.py +++ b/antarest/study/storage/variantstudy/model/command/remove_st_storage.py @@ -127,6 +127,9 @@ def _apply(self, study_data: FileStudy) -> CommandOutput: ["input", "st-storage", "clusters", self.area_id, "list", self.storage_id], ["input", "st-storage", "series", self.area_id, self.storage_id], ] + area: Area = study_data.config.areas[self.area_id] + if len(area.st_storages) == 1: + paths.append(["input", "st-storage", "series", self.area_id]) # fmt: on for path in paths: study_data.tree.delete(path) diff --git a/tests/integration/variant_blueprint/test_renewable_cluster.py b/tests/integration/variant_blueprint/test_renewable_cluster.py new file mode 100644 index 0000000000..d37063d6ef --- /dev/null +++ b/tests/integration/variant_blueprint/test_renewable_cluster.py @@ -0,0 +1,317 @@ +import http + +import numpy as np +import pytest +from antarest.study.storage.rawstudy.model.filesystem.config.model import ( + transform_name_to_id, +) +from starlette.testclient import TestClient + + +# noinspection SpellCheckingInspection +@pytest.mark.integration_test +class TestRenewableCluster: + """ + This unit test is designed to demonstrate the creation, modification of properties and + updating of matrices, and the deletion of one or more renewable cluster. + """ + + def test_lifecycle( + self, + client: TestClient, + user_access_token: str, + study_id: str, + ) -> None: + # sourcery skip: extract-duplicate-method + + # ===================== + # General Data Update + # ===================== + + # The `enr_modelling` value must be set to "clusters" instead of "aggregated" + args = { + "target": "settings/generaldata/other preferences", + "data": {"renewable-generation-modelling": "clusters"}, + } + res = client.post( + f"/v1/studies/{study_id}/commands", + headers={"Authorization": f"Bearer {user_access_token}"}, + json=[{"action": "update_config", "args": args}], + ) + res.raise_for_status() + + # ================================= + # Create Renewable Clusters in FR + # ================================= + + area_fr_id = transform_name_to_id("FR") + + cluster_fr1 = "Oleron" + cluster_fr1_id = transform_name_to_id(cluster_fr1) + args = { + "area_id": area_fr_id, + "cluster_name": cluster_fr1_id, + "parameters": { + "group": "wind offshore", + "name": cluster_fr1, + "ts-interpretation": "power-generation", + "nominalcapacity": 2500, + }, + } + res = client.post( + f"/v1/studies/{study_id}/commands", + headers={"Authorization": f"Bearer {user_access_token}"}, + json=[{"action": "create_renewables_cluster", "args": args}], + ) + res.raise_for_status() + + cluster_fr2 = "La_Rochelle" + cluster_fr2_id = transform_name_to_id(cluster_fr2) + args = { + "area_id": area_fr_id, + "cluster_name": cluster_fr2_id, + "parameters": { + "group": "solar pv", + "name": cluster_fr2, + "ts-interpretation": "power-generation", + "unitcount": 4, + "enabled": False, + "nominalcapacity": 3500, + }, + } + res = client.post( + f"/v1/studies/{study_id}/commands", + headers={"Authorization": f"Bearer {user_access_token}"}, + json=[{"action": "create_renewables_cluster", "args": args}], + ) + res.raise_for_status() + + # Check the properties of the renewable clusters in the "FR" area + res = client.get( + f"/v1/studies/{study_id}/areas/{area_fr_id}/clusters/renewable/{cluster_fr1_id}/form", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + res.raise_for_status() + properties = res.json() + expected = { + "enabled": True, + "group": "wind offshore", + "name": cluster_fr1_id, # known bug: should be `cluster_fr1` + "nominalCapacity": 2500.0, + "tsInterpretation": "power-generation", + "unitCount": 1, + } + assert properties == expected + + res = client.get( + f"/v1/studies/{study_id}/areas/{area_fr_id}/clusters/renewable/{cluster_fr2_id}/form", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + res.raise_for_status() + properties = res.json() + expected = { + "enabled": False, + "group": "solar pv", + "name": cluster_fr2_id, # known bug: should be `cluster_fr2` + "nominalCapacity": 3500.0, + "tsInterpretation": "power-generation", + "unitCount": 4, + } + assert properties == expected + + # ====================================== + # Renewable Cluster Time Series Update + # ====================================== + + # Then, it is possible to update a time series. + values_fr1 = np.random.randint(0, 1001, size=(8760, 1)) + args_fr1 = { + "target": f"input/renewables/series/{area_fr_id}/{cluster_fr1_id}/series", + "matrix": values_fr1.tolist(), + } + values_fr2 = np.random.randint(0, 1001, size=(8760, 1)) + args_fr2 = { + "target": f"input/renewables/series/{area_fr_id}/{cluster_fr2_id}/series", + "matrix": values_fr2.tolist(), + } + res = client.post( + f"/v1/studies/{study_id}/commands", + headers={"Authorization": f"Bearer {user_access_token}"}, + json=[ + {"action": "replace_matrix", "args": args_fr1}, + {"action": "replace_matrix", "args": args_fr2}, + ], + ) + res.raise_for_status() + + # Check the matrices of the renewable clusters in the "FR" area + res = client.get( + f"/v1/studies/{study_id}/raw?path=input/renewables/series/{area_fr_id}/{cluster_fr1_id}/series", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + res.raise_for_status() + matrix_fr1 = res.json() + assert ( + np.array(matrix_fr1["data"], dtype=np.float64).all() + == values_fr1.all() + ) + + res = client.get( + f"/v1/studies/{study_id}/raw?path=input/renewables/series/{area_fr_id}/{cluster_fr2_id}/series", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + res.raise_for_status() + matrix_fr2 = res.json() + assert ( + np.array(matrix_fr2["data"], dtype=np.float64).all() + == values_fr2.all() + ) + + # ================================= + # Create Renewable Clusters in IT + # ================================= + + area_it_id = transform_name_to_id("IT") + + cluster_it1 = "Oléron" + cluster_it1_id = transform_name_to_id(cluster_it1) + args = { + "area_id": area_it_id, + "cluster_name": cluster_it1_id, + "parameters": { + "group": "wind offshore", + "name": cluster_it1, + "unitcount": 1, + "nominalcapacity": 1000, + "ts-interpretation": "production-factor", + }, + } + res = client.post( + f"/v1/studies/{study_id}/commands", + headers={"Authorization": f"Bearer {user_access_token}"}, + json=[{"action": "create_renewables_cluster", "args": args}], + ) + res.raise_for_status() + + res = client.get( + f"/v1/studies/{study_id}/areas/{area_it_id}/clusters/renewable/{cluster_it1_id}/form", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + res.raise_for_status() + properties = res.json() + expected = { + "enabled": True, + "group": "wind offshore", + "name": cluster_it1_id, # known bug: should be `cluster_it1` + "nominalCapacity": 1000.0, + "tsInterpretation": "production-factor", + "unitCount": 1, + } + assert properties == expected + + # Check the matrices of the renewable clusters in the "IT" area + res = client.get( + f"/v1/studies/{study_id}/raw?path=input/renewables/series/{area_it_id}/{cluster_it1_id}/series", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + res.raise_for_status() + matrix_it1 = res.json() + assert ( + np.array(matrix_it1["data"]).all() + == np.zeros(shape=(8760, 1)).all() + ) + + # =========================== + # Renewable Cluster Removal + # =========================== + + # The `remove_renewables_cluster` command allows you to delete a Renewable Cluster. + args = {"area_id": area_fr_id, "cluster_id": cluster_fr2_id} + res = client.post( + f"/v1/studies/{study_id}/commands", + headers={"Authorization": f"Bearer {user_access_token}"}, + json=[{"action": "remove_renewables_cluster", "args": args}], + ) + res.raise_for_status() + + # Check the properties of all renewable clusters + res = client.get( + f"/v1/studies/{study_id}/raw?path=input/renewables/clusters&depth=4", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + res.raise_for_status() + properties = res.json() + assert properties == { + "de": {"list": {}}, + "es": {"list": {}}, + "fr": { + "list": { + cluster_fr1_id: { + "group": "wind offshore", + "name": cluster_fr1_id, + "nominalcapacity": 2500, + "ts-interpretation": "power-generation", + }, + } + }, + "it": { + "list": { + cluster_it1_id: { + "group": "wind offshore", + "name": cluster_it1_id, + "nominalcapacity": 1000, + "ts-interpretation": "production-factor", + "unitcount": 1, + } + } + }, + } + + # The `remove_renewables_cluster` command allows you to delete a Renewable Cluster. + args = {"area_id": area_fr_id, "cluster_id": cluster_fr1_id} + res = client.post( + f"/v1/studies/{study_id}/commands", + headers={"Authorization": f"Bearer {user_access_token}"}, + json=[{"action": "remove_renewables_cluster", "args": args}], + ) + res.raise_for_status() + + # Check the properties of all renewable clusters + res = client.get( + f"/v1/studies/{study_id}/raw?path=input/renewables/clusters&depth=4", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + res.raise_for_status() + properties = res.json() + assert properties == { + "de": {"list": {}}, + "es": {"list": {}}, + "fr": {"list": {}}, + "it": { + "list": { + cluster_it1_id: { + "group": "wind offshore", + "name": cluster_it1_id, + "nominalcapacity": 1000, + "ts-interpretation": "production-factor", + "unitcount": 1, + } + } + }, + } + + # If you try to delete a non-existent thermal cluster, + # you should receive an HTTP 404 Not Found error. However, + # this behavior is not yet implemented, so you will encounter a 500 error. + args = {"area_id": area_fr_id, "cluster_id": cluster_fr2_id} + res = client.post( + f"/v1/studies/{study_id}/commands", + headers={"Authorization": f"Bearer {user_access_token}"}, + json=[{"action": "remove_renewables_cluster", "args": args}], + ) + assert res.status_code == http.HTTPStatus.INTERNAL_SERVER_ERROR + result = res.json() + assert ( + "'la_rochelle' not a child of ClusteredRenewableClusterSeries" + in result["description"] + ) diff --git a/tests/variantstudy/model/command/test_create_area.py b/tests/variantstudy/model/command/test_create_area.py index 4008d154ce..58fecacbdf 100644 --- a/tests/variantstudy/model/command/test_create_area.py +++ b/tests/variantstudy/model/command/test_create_area.py @@ -1,7 +1,11 @@ import configparser +from unittest.mock import Mock + +import pytest from antarest.study.storage.rawstudy.model.filesystem.config.model import ( transform_name_to_id, + ENR_MODELLING, ) from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy from antarest.study.storage.variantstudy.business.command_reverter import ( @@ -20,13 +24,19 @@ class TestCreateArea: - def test_validation(self, empty_study: FileStudy): - pass - + @pytest.mark.parametrize("version", [600, 650, 810, 830, 860]) + @pytest.mark.parametrize("enr_modelling", list(ENR_MODELLING)) def test_apply( - self, empty_study: FileStudy, command_context: CommandContext + self, + empty_study: FileStudy, + command_context: CommandContext, + # pytest parameters + version, + enr_modelling, ): - version = empty_study.config.version + empty_study.config.enr_modelling = enr_modelling.value + empty_study.config.version = version + study_path = empty_study.config.study_path area_name = "Area" area_id = transform_name_to_id(area_name) @@ -37,10 +47,9 @@ def test_apply( "command_context": command_context, } ) - output = create_area_command.apply( - study_data=empty_study, - ) + output = create_area_command.apply(study_data=empty_study) + # fmt: off # Areas assert area_id in empty_study.config.areas @@ -49,9 +58,7 @@ def test_apply( assert area_name in area_list assert (study_path / "input" / "areas" / area_id).is_dir() - assert ( - study_path / "input" / "areas" / area_id / "optimization.ini" - ).exists() + assert (study_path / "input" / "areas" / area_id / "optimization.ini").exists() assert (study_path / "input" / "areas" / area_id / "ui.ini").exists() # Hydro @@ -60,13 +67,14 @@ def test_apply( assert int(hydro["inter-daily-breakdown"][area_id]) == 1 assert int(hydro["intra-daily-modulation"][area_id]) == 24 assert int(hydro["inter-monthly-breakdown"][area_id]) == 1 + + # sourcery skip: no-conditionals-in-tests if version > 650: assert int(hydro["initialize reservoir date"][area_id]) == 0 assert int(hydro["leeway low"][area_id]) == 1 assert int(hydro["leeway up"][area_id]) == 1 assert int(hydro["pumping efficiency"][area_id]) == 1 - # fmt: off correlation = configparser.ConfigParser() correlation.read(study_path / "input" / "hydro" / "prepro" / "correlation.ini") correlation_dict = {k: v for k, v in correlation.items() if k != "DEFAULT"} @@ -74,263 +82,75 @@ def test_apply( "general": {"mode": "annual"}, "annual": {}, } - # fmt: on # Allocation - assert ( - study_path / "input" / "hydro" / "allocation" / f"{area_id}.ini" - ).exists() + assert (study_path / "input" / "hydro" / "allocation" / f"{area_id}.ini").exists() allocation = configparser.ConfigParser() - allocation.read( - study_path / "input" / "hydro" / "allocation" / f"{area_id}.ini" - ) + allocation.read(study_path / "input" / "hydro" / "allocation" / f"{area_id}.ini") assert int(allocation["[allocation"][area_id]) == 1 # Capacity - assert ( - study_path - / "input" - / "hydro" - / "common" - / "capacity" - / f"maxpower_{area_id}.txt.link" - ).exists() - assert ( - study_path - / "input" - / "hydro" - / "common" - / "capacity" - / f"reservoir_{area_id}.txt.link" - ).exists() + assert (study_path / "input" / "hydro" / "common" / "capacity" / f"maxpower_{area_id}.txt.link").exists() + assert (study_path / "input" / "hydro" / "common" / "capacity" / f"reservoir_{area_id}.txt.link").exists() + + # sourcery skip: no-conditionals-in-tests if version > 650: assert ( - study_path - / "input" - / "hydro" - / "common" - / "capacity" - / f"creditmodulations_{area_id}.txt.link" - ).exists() - assert ( - study_path - / "input" - / "hydro" - / "common" - / "capacity" - / f"inflowPattern_{area_id}.txt.link" + study_path / "input" / "hydro" / "common" / "capacity" / f"creditmodulations_{area_id}.txt.link" ).exists() assert ( - study_path - / "input" - / "hydro" - / "common" - / "capacity" - / f"waterValues_{area_id}.txt.link" + study_path / "input" / "hydro" / "common" / "capacity" / f"inflowPattern_{area_id}.txt.link" ).exists() + assert (study_path / "input" / "hydro" / "common" / "capacity" / f"waterValues_{area_id}.txt.link").exists() # Prepro assert (study_path / "input" / "hydro" / "prepro" / area_id).is_dir() - assert ( - study_path - / "input" - / "hydro" - / "prepro" - / area_id - / "energy.txt.link" - ).exists() + assert (study_path / "input" / "hydro" / "prepro" / area_id / "energy.txt.link").exists() allocation = configparser.ConfigParser() - allocation.read( - study_path / "input" / "hydro" / "prepro" / area_id / "prepro.ini" - ) + allocation.read(study_path / "input" / "hydro" / "prepro" / area_id / "prepro.ini") assert float(allocation["prepro"]["intermonthly-correlation"]) == 0.5 # Series assert (study_path / "input" / "hydro" / "series" / area_id).is_dir() - assert ( - study_path - / "input" - / "hydro" - / "series" - / area_id - / "mod.txt.link" - ).exists() - assert ( - study_path - / "input" - / "hydro" - / "series" - / area_id - / "ror.txt.link" - ).exists() + assert (study_path / "input" / "hydro" / "series" / area_id / "mod.txt.link").exists() + assert (study_path / "input" / "hydro" / "series" / area_id / "ror.txt.link").exists() # Links assert (study_path / "input" / "links" / area_id).is_dir() - assert ( - study_path / "input" / "links" / area_id / "properties.ini" - ).exists() - - # Load - # Prepro - assert (study_path / "input" / "load" / "prepro" / area_id).is_dir() - assert ( - study_path - / "input" - / "load" - / "prepro" - / area_id - / "conversion.txt.link" - ).exists() - assert ( - study_path - / "input" - / "load" - / "prepro" - / area_id - / "data.txt.link" - ).exists() - assert ( - study_path / "input" / "load" / "prepro" / area_id / "k.txt.link" - ).exists() - assert ( - study_path / "input" / "load" / "prepro" / area_id / "settings.ini" - ).exists() - assert ( - study_path - / "input" - / "load" - / "prepro" - / area_id - / "translation.txt.link" - ).exists() - - # Series - assert ( - study_path - / "input" - / "load" - / "series" - / f"load_{area_id}.txt.link" - ).exists() + assert (study_path / "input" / "links" / area_id / "properties.ini").exists() + + # Load / Solar / Wind + # sourcery skip: no-loop-in-tests + for gen_type in ("load", "solar", "wind"): + # Prepro + assert (study_path / "input" / gen_type / "prepro" / area_id).is_dir() + assert (study_path / "input" / gen_type / "prepro" / area_id / "conversion.txt.link").exists() + assert (study_path / "input" / gen_type / "prepro" / area_id / "data.txt.link").exists() + assert (study_path / "input" / gen_type / "prepro" / area_id / "k.txt.link").exists() + assert (study_path / "input" / gen_type / "prepro" / area_id / "settings.ini").exists() + assert (study_path / "input" / gen_type / "prepro" / area_id / "translation.txt.link").exists() + # Series + assert (study_path / "input" / gen_type / "series" / f"{gen_type}_{area_id}.txt.link").exists() # Misc-gen - assert ( - study_path / "input" / "misc-gen" / f"miscgen-{area_id}.txt.link" - ).exists() + assert (study_path / "input" / "misc-gen" / f"miscgen-{area_id}.txt.link").exists() # Reserves - assert ( - study_path / "input" / "reserves" / f"{area_id}.txt.link" - ).exists() + assert (study_path / "input" / "reserves" / f"{area_id}.txt.link").exists() - # Solar - # Prepro - assert (study_path / "input" / "solar" / "prepro" / area_id).is_dir() - assert ( - study_path - / "input" - / "solar" - / "prepro" - / area_id - / "conversion.txt.link" - ).exists() - assert ( - study_path - / "input" - / "solar" - / "prepro" - / area_id - / "data.txt.link" - ).exists() - assert ( - study_path / "input" / "solar" / "prepro" / area_id / "k.txt.link" - ).exists() - assert ( - study_path - / "input" - / "solar" - / "prepro" - / area_id - / "settings.ini" - ).exists() - assert ( - study_path - / "input" - / "solar" - / "prepro" - / area_id - / "translation.txt.link" - ).exists() + # Thermal Clusters + assert (study_path / "input" / "thermal" / "clusters" / area_id).is_dir() + assert (study_path / "input" / "thermal" / "clusters" / area_id / "list.ini").exists() - # Series - assert ( - study_path - / "input" - / "solar" - / "series" - / f"solar_{area_id}.txt.link" - ).exists() - - # Thermal - assert ( - study_path / "input" / "thermal" / "clusters" / area_id - ).is_dir() - - assert ( - study_path - / "input" - / "thermal" - / "clusters" - / area_id - / "list.ini" - ).exists() + # Renewable Clusters + if version >= 810 and empty_study.config.enr_modelling == ENR_MODELLING.CLUSTERS.value: + assert (study_path / "input" / "renewables" / "clusters" / area_id).is_dir() + assert (study_path / "input" / "renewables" / "clusters" / area_id / "list.ini").exists() # thermal/areas ini file assert (study_path / "input" / "thermal" / "areas.ini").exists() - - # Wind - # Prepro - assert (study_path / "input" / "wind" / "prepro" / area_id).is_dir() - assert ( - study_path - / "input" - / "wind" - / "prepro" - / area_id - / "conversion.txt.link" - ).exists() - assert ( - study_path - / "input" - / "wind" - / "prepro" - / area_id - / "data.txt.link" - ).exists() - assert ( - study_path / "input" / "wind" / "prepro" / area_id / "k.txt.link" - ).exists() - assert ( - study_path / "input" / "wind" / "prepro" / area_id / "settings.ini" - ).exists() - assert ( - study_path - / "input" - / "wind" - / "prepro" - / area_id - / "translation.txt.link" - ).exists() - - # Series - assert ( - study_path - / "input" - / "wind" - / "series" - / f"wind_{area_id}.txt.link" - ).exists() + # fmt: on assert output.status @@ -346,12 +166,12 @@ def test_apply( def test_match(command_context: CommandContext): + # fmt: off base = CreateArea(area_name="foo", command_context=command_context) other_match = CreateArea(area_name="foo", command_context=command_context) - other_not_match = CreateArea( - area_name="bar", command_context=command_context - ) + other_not_match = CreateArea(area_name="bar", command_context=command_context) other_other = RemoveArea(id="id", command_context=command_context) + # fmt: on assert base.match(other_match) assert not base.match(other_not_match) assert not base.match(other_other) @@ -361,9 +181,8 @@ def test_match(command_context: CommandContext): def test_revert(command_context: CommandContext): base = CreateArea(area_name="foo", command_context=command_context) - assert CommandReverter().revert(base, [], None) == [ - RemoveArea(id="foo", command_context=command_context) - ] + actual = CommandReverter().revert(base, [], Mock(spec=FileStudy)) + assert actual == [RemoveArea(id="foo", command_context=command_context)] def test_create_diff(command_context: CommandContext): diff --git a/tests/variantstudy/model/command/test_remove_renewables_cluster.py b/tests/variantstudy/model/command/test_remove_renewables_cluster.py index f934f0988d..562c6d72ad 100644 --- a/tests/variantstudy/model/command/test_remove_renewables_cluster.py +++ b/tests/variantstudy/model/command/test_remove_renewables_cluster.py @@ -23,13 +23,11 @@ class TestRemoveRenewablesCluster: - def test_validation(self, empty_study: FileStudy): - pass - def test_apply( self, empty_study: FileStudy, command_context: CommandContext ): empty_study.config.enr_modelling = ENR_MODELLING.CLUSTERS.value + empty_study.config.version = 810 area_name = "Area_name" area_id = transform_name_to_id(area_name) cluster_name = "cluster_name"