diff --git a/antarest/study/business/allocation_management.py b/antarest/study/business/allocation_management.py index eafec7f479..e1901b0b75 100644 --- a/antarest/study/business/allocation_management.py +++ b/antarest/study/business/allocation_management.py @@ -19,7 +19,7 @@ from typing_extensions import Annotated from antarest.core.exceptions import AllocationDataNotFound, AreaNotFound -from antarest.study.business.area_management import AreaInfoDTO +from antarest.study.business.model.area_model import AreaInfoDTO from antarest.study.business.utils import FormFieldsBaseModel, execute_or_add_commands from antarest.study.model import Study from antarest.study.storage.storage_service import StudyStorageService diff --git a/antarest/study/business/area_management.py b/antarest/study/business/area_management.py index c607be980d..484e0827d5 100644 --- a/antarest/study/business/area_management.py +++ b/antarest/study/business/area_management.py @@ -10,25 +10,27 @@ # # This file is part of the Antares project. -import enum import logging import re import typing as t -from pydantic import Field - from antarest.core.exceptions import ConfigFileNotFound, DuplicateAreaName, LayerNotAllowedToBeDeleted, LayerNotFound from antarest.core.model import JSON -from antarest.core.serialization import AntaresBaseModel -from antarest.study.business.all_optional_meta import all_optional_model, camel_case_model +from antarest.study.business.model.area_model import ( + AreaCreationDTO, + AreaInfoDTO, + AreaOutput, + AreaType, + ClusterInfoDTO, + LayerInfoDTO, + UpdateAreaUi, +) from antarest.study.business.utils import execute_or_add_commands from antarest.study.model import Patch, PatchArea, PatchCluster, RawStudy, Study from antarest.study.repository import StudyMetadataRepository from antarest.study.storage.patch_service import PatchService from antarest.study.storage.rawstudy.model.filesystem.config.area import ( - AdequacyPathProperties, AreaFolder, - OptimizationProperties, ThermalAreasProperties, UIProperties, ) @@ -37,97 +39,15 @@ from antarest.study.storage.storage_service import StudyStorageService from antarest.study.storage.variantstudy.model.command.create_area import CreateArea from antarest.study.storage.variantstudy.model.command.icommand import ICommand +from antarest.study.storage.variantstudy.model.command.move_area import MoveArea from antarest.study.storage.variantstudy.model.command.remove_area import RemoveArea from antarest.study.storage.variantstudy.model.command.update_config import UpdateConfig logger = logging.getLogger(__name__) -class AreaType(enum.Enum): - AREA = "AREA" - DISTRICT = "DISTRICT" - - -class AreaCreationDTO(AntaresBaseModel): - name: str - type: AreaType - metadata: t.Optional[PatchArea] = None - set: t.Optional[t.List[str]] = None - - -# review: is this class necessary? -class ClusterInfoDTO(PatchCluster): - id: str - name: str - enabled: bool = True - unitcount: int = 0 - nominalcapacity: float = 0 - group: t.Optional[str] = None - min_stable_power: t.Optional[float] = None - min_up_time: t.Optional[int] = None - min_down_time: t.Optional[int] = None - spinning: t.Optional[float] = None - marginal_cost: t.Optional[float] = None - spread_cost: t.Optional[float] = None - market_bid_cost: t.Optional[float] = None - - -class AreaInfoDTO(AreaCreationDTO): - id: str - thermals: t.Optional[t.List[ClusterInfoDTO]] = None - - -class LayerInfoDTO(AntaresBaseModel): - id: str - name: str - areas: t.List[str] - - -class UpdateAreaUi(AntaresBaseModel, extra="forbid", populate_by_name=True): - """ - DTO for updating area UI - - Usage: - - >>> from antarest.study.business.area_management import UpdateAreaUi - >>> from pprint import pprint - - >>> obj = { - ... "x": -673.75, - ... "y": 301.5, - ... "color_rgb": [230, 108, 44], - ... "layerX": {"0": -230, "4": -230, "6": -95, "7": -230, "8": -230}, - ... "layerY": {"0": 136, "4": 136, "6": 39, "7": 136, "8": 136}, - ... "layerColor": { - ... "0": "230, 108, 44", - ... "4": "230, 108, 44", - ... "6": "230, 108, 44", - ... "7": "230, 108, 44", - ... "8": "230, 108, 44", - ... }, - ... } - - >>> model = UpdateAreaUi(**obj) - >>> pprint(model.model_dump(by_alias=True), width=80) - {'colorRgb': [230, 108, 44], - 'layerColor': {0: '230, 108, 44', - 4: '230, 108, 44', - 6: '230, 108, 44', - 7: '230, 108, 44', - 8: '230, 108, 44'}, - 'layerX': {0: -230, 4: -230, 6: -95, 7: -230, 8: -230}, - 'layerY': {0: 136, 4: 136, 6: 39, 7: 136, 8: 136}, - 'x': -673, - 'y': 301} - - """ - - x: int = Field(title="X position") - y: int = Field(title="Y position") - color_rgb: t.Sequence[int] = Field(title="RGB color", alias="colorRgb") - layer_x: t.Mapping[int, int] = Field(default_factory=dict, title="X position of each layer", alias="layerX") - layer_y: t.Mapping[int, int] = Field(default_factory=dict, title="Y position of each layer", alias="layerY") - layer_color: t.Mapping[int, str] = Field(default_factory=dict, title="Color of each layer", alias="layerColor") +_ALL_AREAS_PATH = "input/areas" +_THERMAL_AREAS_PATH = "input/thermal/areas" def _get_ui_info_map(file_study: FileStudy, area_ids: t.Sequence[str]) -> t.Dict[str, t.Any]: @@ -171,101 +91,6 @@ def _get_area_layers(area_uis: t.Dict[str, t.Any], area: str) -> t.List[str]: return [] -_ALL_AREAS_PATH = "input/areas" -_THERMAL_AREAS_PATH = "input/thermal/areas" - - -# noinspection SpellCheckingInspection -class _BaseAreaDTO( - OptimizationProperties.FilteringSection, - OptimizationProperties.ModalOptimizationSection, - AdequacyPathProperties.AdequacyPathSection, - extra="forbid", - validate_assignment=True, - populate_by_name=True, -): - """ - Represents an area output. - - Aggregates the fields of the `OptimizationProperties` and `AdequacyPathProperties` classes, - but without the `UIProperties` fields. - - Add the fields extracted from the `/input/thermal/areas.ini` information: - - - `average_unsupplied_energy_cost` is extracted from `unserverd_energy_cost`, - - `average_spilled_energy_cost` is extracted from `spilled_energy_cost`. - """ - - average_unsupplied_energy_cost: float = Field(0.0, description="average unserverd energy cost (€/MWh)") - average_spilled_energy_cost: float = Field(0.0, description="average spilled energy cost (€/MWh)") - - -# noinspection SpellCheckingInspection -@all_optional_model -@camel_case_model -class AreaOutput(_BaseAreaDTO): - """ - DTO object use to get the area information using a flat structure. - """ - - @classmethod - def from_model( - cls, - area_folder: AreaFolder, - *, - average_unsupplied_energy_cost: float, - average_spilled_energy_cost: float, - ) -> "AreaOutput": - """ - Creates a `GetAreaDTO` object from configuration data. - - Args: - area_folder: Configuration data read from the `/input/areas/` information. - average_unsupplied_energy_cost: Unserverd energy cost (€/MWh). - average_spilled_energy_cost: Spilled energy cost (€/MWh). - Returns: - The `GetAreaDTO` object. - """ - obj = { - "average_unsupplied_energy_cost": average_unsupplied_energy_cost, - "average_spilled_energy_cost": average_spilled_energy_cost, - **area_folder.optimization.filtering.model_dump(mode="json", by_alias=False), - **area_folder.optimization.nodal_optimization.model_dump(mode="json", by_alias=False), - # adequacy_patch is only available if study version >= 830. - **( - area_folder.adequacy_patch.adequacy_patch.model_dump(mode="json", by_alias=False) - if area_folder.adequacy_patch - else {} - ), - } - return cls(**obj) - - def _to_optimization(self) -> OptimizationProperties: - obj = {name: getattr(self, name) for name in OptimizationProperties.FilteringSection.model_fields} - filtering_section = OptimizationProperties.FilteringSection(**obj) - obj = {name: getattr(self, name) for name in OptimizationProperties.ModalOptimizationSection.model_fields} - nodal_optimization_section = OptimizationProperties.ModalOptimizationSection(**obj) - args = {"filtering": filtering_section, "nodal_optimization": nodal_optimization_section} - return OptimizationProperties.model_validate(args) - - def _to_adequacy_patch(self) -> t.Optional[AdequacyPathProperties]: - obj = {name: getattr(self, name) for name in AdequacyPathProperties.AdequacyPathSection.model_fields} - # If all fields are `None`, the object is empty. - if all(value is None for value in obj.values()): - return None - adequacy_path_section = AdequacyPathProperties.AdequacyPathSection(**obj) - return AdequacyPathProperties.model_validate({"adequacy_patch": adequacy_path_section}) - - @property - def area_folder(self) -> AreaFolder: - area_folder = AreaFolder( - optimization=self._to_optimization(), - adequacy_patch=self._to_adequacy_patch(), - # UI properties are not configurable in Table Mode - ) - return area_folder - - class AreaManager: """ Manages operations related to areas in a study, including retrieval, creation, and updates. @@ -414,7 +239,7 @@ def update_areas_props( @staticmethod def get_table_schema() -> JSON: - return AreaOutput.schema() + return AreaOutput.model_json_schema() def get_all_areas(self, study: RawStudy, area_type: t.Optional[AreaType] = None) -> t.List[AreaInfoDTO]: """ @@ -680,73 +505,17 @@ def update_area_metadata( ) def update_area_ui(self, study: Study, area_id: str, area_ui: UpdateAreaUi, layer: str = "0") -> None: - obj = { - "x": area_ui.x, - "y": area_ui.y, - "color_r": area_ui.color_rgb[0], - "color_g": area_ui.color_rgb[1], - "color_b": area_ui.color_rgb[2], - } file_study = self.storage_service.get_storage(study).get_raw(study) - commands = ( - [ - UpdateConfig( - target=f"input/areas/{area_id}/ui/ui/x", - data=obj["x"], - command_context=self.storage_service.variant_study_service.command_factory.command_context, - study_version=file_study.config.version, - ), - UpdateConfig( - target=f"input/areas/{area_id}/ui/ui/y", - data=obj["y"], - command_context=self.storage_service.variant_study_service.command_factory.command_context, - study_version=file_study.config.version, - ), - UpdateConfig( - target=f"input/areas/{area_id}/ui/ui/color_r", - data=obj["color_r"], - command_context=self.storage_service.variant_study_service.command_factory.command_context, - study_version=file_study.config.version, - ), - UpdateConfig( - target=f"input/areas/{area_id}/ui/ui/color_g", - data=obj["color_g"], - command_context=self.storage_service.variant_study_service.command_factory.command_context, - study_version=file_study.config.version, - ), - UpdateConfig( - target=f"input/areas/{area_id}/ui/ui/color_b", - data=obj["color_b"], - command_context=self.storage_service.variant_study_service.command_factory.command_context, - study_version=file_study.config.version, - ), - ] - if layer == "0" - else [] - ) - commands.extend( - [ - UpdateConfig( - target=f"input/areas/{area_id}/ui/layerX/{layer}", - data=obj["x"], - command_context=self.storage_service.variant_study_service.command_factory.command_context, - study_version=file_study.config.version, - ), - UpdateConfig( - target=f"input/areas/{area_id}/ui/layerY/{layer}", - data=obj["y"], - command_context=self.storage_service.variant_study_service.command_factory.command_context, - study_version=file_study.config.version, - ), - UpdateConfig( - target=f"input/areas/{area_id}/ui/layerColor/{layer}", - data=f"{obj['color_r']},{obj['color_g']},{obj['color_b']}", - command_context=self.storage_service.variant_study_service.command_factory.command_context, - study_version=file_study.config.version, - ), - ] + + command = MoveArea( + area_id=area_id, + area_ui=area_ui, + layer=layer, + command_context=self.storage_service.variant_study_service.command_factory.command_context, + study_version=file_study.config.version, ) - execute_or_add_commands(study, file_study, commands, self.storage_service) + + execute_or_add_commands(study, file_study, [command], self.storage_service) def update_thermal_cluster_metadata( self, diff --git a/antarest/study/business/correlation_management.py b/antarest/study/business/correlation_management.py index 6aab755ab8..76a940fa70 100644 --- a/antarest/study/business/correlation_management.py +++ b/antarest/study/business/correlation_management.py @@ -22,7 +22,7 @@ from pydantic import ValidationInfo, field_validator from antarest.core.exceptions import AreaNotFound -from antarest.study.business.area_management import AreaInfoDTO +from antarest.study.business.model.area_model import AreaInfoDTO from antarest.study.business.utils import FormFieldsBaseModel, execute_or_add_commands from antarest.study.model import Study from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy diff --git a/antarest/study/business/model/area_model.py b/antarest/study/business/model/area_model.py new file mode 100644 index 0000000000..c67e0fe739 --- /dev/null +++ b/antarest/study/business/model/area_model.py @@ -0,0 +1,165 @@ +# Copyright (c) 2025, RTE (https://www.rte-france.com) +# +# See AUTHORS.txt +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# SPDX-License-Identifier: MPL-2.0 +# +# This file is part of the Antares project. + +import enum +import typing as t + +from pydantic import Field + +from antarest.core.serialization import AntaresBaseModel +from antarest.study.business.all_optional_meta import all_optional_model, camel_case_model +from antarest.study.model import PatchArea, PatchCluster +from antarest.study.storage.rawstudy.model.filesystem.config.area import ( + AdequacyPathProperties, + AreaFolder, + OptimizationProperties, +) + + +class AreaType(enum.Enum): + AREA = "AREA" + DISTRICT = "DISTRICT" + + +class AreaCreationDTO(AntaresBaseModel): + name: str + type: AreaType + metadata: t.Optional[PatchArea] = None + set: t.Optional[t.List[str]] = None + + +# review: is this class necessary? +class ClusterInfoDTO(PatchCluster): + id: str + name: str + enabled: bool = True + unitcount: int = 0 + nominalcapacity: float = 0 + group: t.Optional[str] = None + min_stable_power: t.Optional[float] = None + min_up_time: t.Optional[int] = None + min_down_time: t.Optional[int] = None + spinning: t.Optional[float] = None + marginal_cost: t.Optional[float] = None + spread_cost: t.Optional[float] = None + market_bid_cost: t.Optional[float] = None + + +class AreaInfoDTO(AreaCreationDTO): + id: str + thermals: t.Optional[t.List[ClusterInfoDTO]] = None + + +class LayerInfoDTO(AntaresBaseModel): + id: str + name: str + areas: t.List[str] + + +class UpdateAreaUi(AntaresBaseModel, extra="forbid", populate_by_name=True): + x: int = Field(title="X position") + y: int = Field(title="Y position") + color_rgb: t.Sequence[int] = Field(title="RGB color", alias="colorRgb") + layer_x: t.Mapping[int, int] = Field(default_factory=dict, title="X position of each layer", alias="layerX") + layer_y: t.Mapping[int, int] = Field(default_factory=dict, title="Y position of each layer", alias="layerY") + layer_color: t.Mapping[int, str] = Field(default_factory=dict, title="Color of each layer", alias="layerColor") + + +# noinspection SpellCheckingInspection +class _BaseAreaDTO( + OptimizationProperties.FilteringSection, + OptimizationProperties.ModalOptimizationSection, + AdequacyPathProperties.AdequacyPathSection, + extra="forbid", + validate_assignment=True, + populate_by_name=True, +): + """ + Represents an area output. + + Aggregates the fields of the `OptimizationProperties` and `AdequacyPathProperties` classes, + but without the `UIProperties` fields. + + Add the fields extracted from the `/input/thermal/areas.ini` information: + + - `average_unsupplied_energy_cost` is extracted from `unserverd_energy_cost`, + - `average_spilled_energy_cost` is extracted from `spilled_energy_cost`. + """ + + average_unsupplied_energy_cost: float = Field(0.0, description="average unserverd energy cost (€/MWh)") + average_spilled_energy_cost: float = Field(0.0, description="average spilled energy cost (€/MWh)") + + +# noinspection SpellCheckingInspection +@all_optional_model +@camel_case_model +class AreaOutput(_BaseAreaDTO): + """ + DTO object use to get the area information using a flat structure. + """ + + @classmethod + def from_model( + cls, + area_folder: AreaFolder, + *, + average_unsupplied_energy_cost: float, + average_spilled_energy_cost: float, + ) -> "AreaOutput": + """ + Creates a `GetAreaDTO` object from configuration data. + + Args: + area_folder: Configuration data read from the `/input/areas/` information. + average_unsupplied_energy_cost: Unserverd energy cost (€/MWh). + average_spilled_energy_cost: Spilled energy cost (€/MWh). + Returns: + The `GetAreaDTO` object. + """ + obj = { + "average_unsupplied_energy_cost": average_unsupplied_energy_cost, + "average_spilled_energy_cost": average_spilled_energy_cost, + **area_folder.optimization.filtering.model_dump(mode="json", by_alias=False), + **area_folder.optimization.nodal_optimization.model_dump(mode="json", by_alias=False), + # adequacy_patch is only available if study version >= 830. + **( + area_folder.adequacy_patch.adequacy_patch.model_dump(mode="json", by_alias=False) + if area_folder.adequacy_patch + else {} + ), + } + return cls(**obj) + + def _to_optimization(self) -> OptimizationProperties: + obj = {name: getattr(self, name) for name in OptimizationProperties.FilteringSection.model_fields} + filtering_section = OptimizationProperties.FilteringSection(**obj) + obj = {name: getattr(self, name) for name in OptimizationProperties.ModalOptimizationSection.model_fields} + nodal_optimization_section = OptimizationProperties.ModalOptimizationSection(**obj) + args = {"filtering": filtering_section, "nodal_optimization": nodal_optimization_section} + return OptimizationProperties.model_validate(args) + + def _to_adequacy_patch(self) -> t.Optional[AdequacyPathProperties]: + obj = {name: getattr(self, name) for name in AdequacyPathProperties.AdequacyPathSection.model_fields} + # If all fields are `None`, the object is empty. + if all(value is None for value in obj.values()): + return None + adequacy_path_section = AdequacyPathProperties.AdequacyPathSection(**obj) + return AdequacyPathProperties.model_validate({"adequacy_patch": adequacy_path_section}) + + @property + def area_folder(self) -> AreaFolder: + area_folder = AreaFolder( + optimization=self._to_optimization(), + adequacy_patch=self._to_adequacy_patch(), + # UI properties are not configurable in Table Mode + ) + return area_folder diff --git a/antarest/study/business/table_mode_management.py b/antarest/study/business/table_mode_management.py index efaeeae5c7..2ede027bc3 100644 --- a/antarest/study/business/table_mode_management.py +++ b/antarest/study/business/table_mode_management.py @@ -20,13 +20,14 @@ from antarest.core.exceptions import ChildNotFoundError from antarest.core.model import JSON -from antarest.study.business.area_management import AreaManager, AreaOutput +from antarest.study.business.area_management import AreaManager from antarest.study.business.areas.renewable_management import RenewableClusterInput, RenewableManager from antarest.study.business.areas.st_storage_management import STStorageInput, STStorageManager from antarest.study.business.areas.thermal_management import ThermalClusterInput, ThermalManager from antarest.study.business.binding_constraint_management import BindingConstraintManager, ConstraintInput from antarest.study.business.enum_ignore_case import EnumIgnoreCase from antarest.study.business.link_management import LinkManager +from antarest.study.business.model.area_model import AreaOutput from antarest.study.business.model.link_model import LinkBaseDTO from antarest.study.model import STUDY_VERSION_8_2, RawStudy diff --git a/antarest/study/service.py b/antarest/study/service.py index 31bb80fa1f..c9ee791f54 100644 --- a/antarest/study/service.py +++ b/antarest/study/service.py @@ -79,7 +79,7 @@ MCIndLinksQueryFile, ) from antarest.study.business.allocation_management import AllocationManager -from antarest.study.business.area_management import AreaCreationDTO, AreaInfoDTO, AreaManager, AreaType, UpdateAreaUi +from antarest.study.business.area_management import AreaManager from antarest.study.business.areas.hydro_management import HydroManager from antarest.study.business.areas.properties_management import PropertiesManager from antarest.study.business.areas.renewable_management import RenewableManager @@ -92,6 +92,7 @@ from antarest.study.business.general_management import GeneralManager from antarest.study.business.link_management import LinkManager from antarest.study.business.matrix_management import MatrixManager, MatrixManagerError +from antarest.study.business.model.area_model import AreaCreationDTO, AreaInfoDTO, AreaType, UpdateAreaUi from antarest.study.business.model.link_model import LinkBaseDTO, LinkDTO from antarest.study.business.optimization_management import OptimizationManager from antarest.study.business.playlist_management import PlaylistManager @@ -403,7 +404,7 @@ def __init__( self.event_bus = event_bus self.file_transfer_manager = file_transfer_manager self.task_service = task_service - self.areas = AreaManager(self.storage_service, self.repository) + self.area_manager = AreaManager(self.storage_service, self.repository) self.district_manager = DistrictManager(self.storage_service) self.links_manager = LinkManager(self.storage_service) self.config_manager = ConfigManager(self.storage_service) @@ -426,7 +427,7 @@ def __init__( self.binding_constraint_manager = BindingConstraintManager(self.storage_service) self.correlation_manager = CorrelationManager(self.storage_service) self.table_mode_manager = TableModeManager( - self.areas, + self.area_manager, self.links_manager, self.thermal_manager, self.renewable_manager, @@ -1899,7 +1900,9 @@ def get_all_areas( ) -> t.Union[t.List[AreaInfoDTO], t.Dict[str, t.Any]]: study = self.get_study(uuid) assert_permission(params.user, study, StudyPermissionType.READ) - return self.areas.get_all_areas_ui_info(study) if ui else self.areas.get_all_areas(study, area_type) + return ( + self.area_manager.get_all_areas_ui_info(study) if ui else self.area_manager.get_all_areas(study, area_type) + ) def get_all_links( self, @@ -1919,7 +1922,7 @@ def create_area( study = self.get_study(uuid) assert_permission(params.user, study, StudyPermissionType.WRITE) self._assert_study_unarchived(study) - new_area = self.areas.create_area(study, area_creation_dto) + new_area = self.area_manager.create_area(study, area_creation_dto) self.event_bus.push( Event( type=EventType.STUDY_DATA_EDITED, @@ -1979,7 +1982,7 @@ def update_area( study = self.get_study(uuid) assert_permission(params.user, study, StudyPermissionType.WRITE) self._assert_study_unarchived(study) - updated_area = self.areas.update_area_metadata(study, area_id, area_patch_dto) + updated_area = self.area_manager.update_area_metadata(study, area_id, area_patch_dto) self.event_bus.push( Event( type=EventType.STUDY_DATA_EDITED, @@ -2000,7 +2003,7 @@ def update_area_ui( study = self.get_study(uuid) assert_permission(params.user, study, StudyPermissionType.WRITE) self._assert_study_unarchived(study) - return self.areas.update_area_ui(study, area_id, area_ui, layer) + return self.area_manager.update_area_ui(study, area_id, area_ui, layer) def update_thermal_cluster_metadata( self, @@ -2012,7 +2015,7 @@ def update_thermal_cluster_metadata( study = self.get_study(uuid) assert_permission(params.user, study, StudyPermissionType.WRITE) self._assert_study_unarchived(study) - return self.areas.update_thermal_cluster_metadata(study, area_id, clusters_metadata) + return self.area_manager.update_thermal_cluster_metadata(study, area_id, clusters_metadata) def delete_area(self, uuid: str, area_id: str, params: RequestParameters) -> None: """ @@ -2036,7 +2039,7 @@ def delete_area(self, uuid: str, area_id: str, params: RequestParameters) -> Non if referencing_binding_constraints: binding_ids = [bc.id for bc in referencing_binding_constraints] raise ReferencedObjectDeletionNotAllowed(area_id, binding_ids, object_type="Area") - self.areas.delete_area(study, area_id) + self.area_manager.delete_area(study, area_id) self.event_bus.push( Event( type=EventType.STUDY_DATA_EDITED, diff --git a/antarest/study/storage/variantstudy/business/command_reverter.py b/antarest/study/storage/variantstudy/business/command_reverter.py index 4b74d2a0ba..20243375e0 100644 --- a/antarest/study/storage/variantstudy/business/command_reverter.py +++ b/antarest/study/storage/variantstudy/business/command_reverter.py @@ -33,6 +33,7 @@ GenerateThermalClusterTimeSeries, ) from antarest.study.storage.variantstudy.model.command.icommand import ICommand +from antarest.study.storage.variantstudy.model.command.move_area import MoveArea from antarest.study.storage.variantstudy.model.command.remove_area import RemoveArea from antarest.study.storage.variantstudy.model.command.remove_binding_constraint import RemoveBindingConstraint from antarest.study.storage.variantstudy.model.command.remove_cluster import RemoveCluster @@ -72,6 +73,10 @@ def _revert_create_area(base_command: CreateArea, history: t.List["ICommand"], b def _revert_remove_area(base_command: RemoveArea, history: t.List["ICommand"], base: FileStudy) -> t.List[ICommand]: raise NotImplementedError("The revert function for RemoveArea is not available") + @staticmethod + def _revert_move_area(base_command: MoveArea, history: t.List["ICommand"], base: FileStudy) -> t.List[ICommand]: + raise NotImplementedError("The revert function for MoveArea is not available") + @staticmethod def _revert_create_district( base_command: CreateDistrict, diff --git a/antarest/study/storage/variantstudy/command_factory.py b/antarest/study/storage/variantstudy/command_factory.py index f20f0ef58d..8696f0f547 100644 --- a/antarest/study/storage/variantstudy/command_factory.py +++ b/antarest/study/storage/variantstudy/command_factory.py @@ -32,6 +32,7 @@ GenerateThermalClusterTimeSeries, ) from antarest.study.storage.variantstudy.model.command.icommand import ICommand +from antarest.study.storage.variantstudy.model.command.move_area import MoveArea from antarest.study.storage.variantstudy.model.command.remove_area import RemoveArea from antarest.study.storage.variantstudy.model.command.remove_binding_constraint import RemoveBindingConstraint from antarest.study.storage.variantstudy.model.command.remove_cluster import RemoveCluster @@ -57,6 +58,7 @@ COMMAND_MAPPING = { CommandName.CREATE_AREA.value: CreateArea, + CommandName.MOVE_AREA.value: MoveArea, CommandName.REMOVE_AREA.value: RemoveArea, CommandName.CREATE_DISTRICT.value: CreateDistrict, CommandName.REMOVE_DISTRICT.value: RemoveDistrict, diff --git a/antarest/study/storage/variantstudy/model/command/common.py b/antarest/study/storage/variantstudy/model/command/common.py index e91a0e2e58..c75bee0d47 100644 --- a/antarest/study/storage/variantstudy/model/command/common.py +++ b/antarest/study/storage/variantstudy/model/command/common.py @@ -30,6 +30,7 @@ class FilteringOptions: class CommandName(Enum): CREATE_AREA = "create_area" + MOVE_AREA = "move_area" REMOVE_AREA = "remove_area" CREATE_DISTRICT = "create_district" REMOVE_DISTRICT = "remove_district" diff --git a/antarest/study/storage/variantstudy/model/command/move_area.py b/antarest/study/storage/variantstudy/model/command/move_area.py new file mode 100644 index 0000000000..41c1aced21 --- /dev/null +++ b/antarest/study/storage/variantstudy/model/command/move_area.py @@ -0,0 +1,91 @@ +# Copyright (c) 2025, RTE (https://www.rte-france.com) +# +# See AUTHORS.txt +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# SPDX-License-Identifier: MPL-2.0 +# +# This file is part of the Antares project. + +import typing as t + +from typing_extensions import override + +from antarest.study.business.model.area_model import UpdateAreaUi +from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfig +from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy +from antarest.study.storage.variantstudy.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.command_listener.command_listener import ICommandListener +from antarest.study.storage.variantstudy.model.model import CommandDTO + + +class MoveArea(ICommand): + """ + Command used to move an area inside the map and to update its UI. + """ + + # Overloaded metadata + # =================== + + command_name: CommandName = CommandName.MOVE_AREA + version: int = 1 + + # Command parameters + # ================== + + area_id: str + area_ui: UpdateAreaUi + layer: str + + @override + def _apply_config(self, study_data: FileStudyTreeConfig) -> t.Tuple[CommandOutput, t.Dict[str, t.Any]]: + return CommandOutput(status=True, message=f"area '{self.area_id}' UI updated"), {} + + @override + def _apply(self, study_data: FileStudy, listener: t.Optional[ICommandListener] = None) -> CommandOutput: + current_area = study_data.tree.get(["input", "areas", self.area_id, "ui"]) + + if self.layer == "0": + ui = current_area["ui"] + ui["x"] = self.area_ui.x + ui["y"] = self.area_ui.y + ui["color_r"], ui["color_g"], ui["color_b"] = self.area_ui.color_rgb + current_area["layerX"][self.layer] = self.area_ui.x + current_area["layerY"][self.layer] = self.area_ui.y + current_area["layerColor"][self.layer] = ",".join(map(str, self.area_ui.color_rgb)) + + study_data.tree.save(current_area, ["input", "areas", self.area_id, "ui"]) + + output, _ = self._apply_config(study_data.config) + + return output + + @override + def to_dto(self) -> CommandDTO: + return CommandDTO( + action=CommandName.MOVE_AREA.value, + args={"area_id": self.area_id, "area_ui": self.area_ui, "layer": self.layer}, + study_version=self.study_version, + ) + + @override + def match_signature(self) -> str: + return str(self.command_name.value + MATCH_SIGNATURE_SEPARATOR + self.area_id) + + @override + def match(self, other: ICommand, equal: bool = False) -> bool: + if not isinstance(other, MoveArea): + return False + return self.area_id == other.area_id + + @override + def _create_diff(self, other: "ICommand") -> t.List["ICommand"]: + return [] + + @override + def get_inner_matrices(self) -> t.List[str]: + return [] diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index 95659ecd7f..9a33246ec4 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -30,7 +30,6 @@ from antarest.study.business.adequacy_patch_management import AdequacyPatchFormFields from antarest.study.business.advanced_parameters_management import AdvancedParamsFormFields from antarest.study.business.allocation_management import AllocationField, AllocationFormFields, AllocationMatrix -from antarest.study.business.area_management import AreaCreationDTO, AreaInfoDTO, AreaType, LayerInfoDTO, UpdateAreaUi from antarest.study.business.areas.hydro_management import InflowStructure, ManagementOptionsFormFields from antarest.study.business.areas.properties_management import PropertiesFormFields from antarest.study.business.areas.renewable_management import ( @@ -68,6 +67,7 @@ ) from antarest.study.business.district_manager import DistrictCreationDTO, DistrictInfoDTO, DistrictUpdateDTO from antarest.study.business.general_management import GeneralFormFields +from antarest.study.business.model.area_model import AreaCreationDTO, AreaInfoDTO, AreaType, LayerInfoDTO, UpdateAreaUi from antarest.study.business.model.link_model import LinkBaseDTO, LinkDTO from antarest.study.business.optimization_management import OptimizationFormFields from antarest.study.business.playlist_management import PlaylistColumns @@ -323,7 +323,7 @@ def get_layers( ) params = RequestParameters(user=current_user) study = study_service.check_study_access(uuid, StudyPermissionType.READ, params) - return study_service.areas.get_layers(study) + return study_service.area_manager.get_layers(study) @bp.post( "/studies/{uuid}/layers", @@ -342,7 +342,7 @@ def create_layer( ) params = RequestParameters(user=current_user) study = study_service.check_study_access(uuid, StudyPermissionType.WRITE, params) - return study_service.areas.create_layer(study, name) + return study_service.area_manager.create_layer(study, name) @bp.put( "/studies/{uuid}/layers/{layer_id}", @@ -363,9 +363,9 @@ def update_layer( params = RequestParameters(user=current_user) study = study_service.check_study_access(uuid, StudyPermissionType.WRITE, params) if name: - study_service.areas.update_layer_name(study, layer_id, name) + study_service.area_manager.update_layer_name(study, layer_id, name) if areas: - study_service.areas.update_layer_areas(study, layer_id, areas) + study_service.area_manager.update_layer_areas(study, layer_id, areas) @bp.delete( "/studies/{uuid}/layers/{layer_id}", @@ -385,7 +385,7 @@ def remove_layer( ) params = RequestParameters(user=current_user) study = study_service.check_study_access(uuid, StudyPermissionType.READ, params) - study_service.areas.remove_layer(study, layer_id) + study_service.area_manager.remove_layer(study, layer_id) @bp.get( "/studies/{uuid}/districts", diff --git a/tests/integration/study_data_blueprint/test_area.py b/tests/integration/study_data_blueprint/test_area.py new file mode 100644 index 0000000000..e1e81a35ee --- /dev/null +++ b/tests/integration/study_data_blueprint/test_area.py @@ -0,0 +1,79 @@ +# Copyright (c) 2025, RTE (https://www.rte-france.com) +# +# See AUTHORS.txt +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# SPDX-License-Identifier: MPL-2.0 +# +# This file is part of the Antares project. + +import pytest +from starlette.testclient import TestClient + +from tests.integration.prepare_proxy import PreparerProxy + + +@pytest.mark.unit_test +class TestArea: + @pytest.mark.parametrize("study_type", ["raw", "variant"]) + def test_area(self, client: TestClient, user_access_token: str, study_type: str) -> None: + client.headers = {"Authorization": f"Bearer {user_access_token}"} # type: ignore + + preparer = PreparerProxy(client, user_access_token) + study_id = preparer.create_study("foo", version=820) + if study_type == "variant": + study_id = preparer.create_variant(study_id, name="Variant 1") + + client.post(f"/v1/studies/{study_id}/areas", json={"name": "area1", "type": "AREA"}) + client.post(f"/v1/studies/{study_id}/areas", json={"name": "area2", "type": "AREA"}) + + res = client.get(f"/v1/studies/{study_id}/areas?ui=true") + assert res.status_code == 200 + expected = { + "area1": { + "layerColor": {"0": "230, 108, 44"}, + "layerX": {"0": 0}, + "layerY": {"0": 0}, + "ui": {"color_b": 44, "color_g": 108, "color_r": 230, "layers": "0", "x": 0, "y": 0}, + }, + "area2": { + "layerColor": {"0": "230, 108, 44"}, + "layerX": {"0": 0}, + "layerY": {"0": 0}, + "ui": {"color_b": 44, "color_g": 108, "color_r": 230, "layers": "0", "x": 0, "y": 0}, + }, + } + assert res.json() == expected + + client.put( + f"/v1/studies/{study_id}/areas/area1/ui", + json={ + "x": 10, + "y": 10, + "layerColor": {"0": "100, 100, 100"}, + "layerX": {"0": 10}, + "layerY": {"0": 10}, + "color_rgb": (100, 100, 100), + }, + ) + + res = client.get(f"/v1/studies/{study_id}/areas?ui=true") + assert res.status_code == 200 + expected = { + "area1": { + "layerColor": {"0": "100, 100, 100"}, + "layerX": {"0": 10}, + "layerY": {"0": 10}, + "ui": {"color_b": 100, "color_g": 100, "color_r": 100, "layers": "0", "x": 10, "y": 10}, + }, + "area2": { + "layerColor": {"0": "230, 108, 44"}, + "layerX": {"0": 0}, + "layerY": {"0": 0}, + "ui": {"color_b": 44, "color_g": 108, "color_r": 230, "layers": "0", "x": 0, "y": 0}, + }, + } + assert res.json() == expected diff --git a/tests/storage/business/test_arealink_manager.py b/tests/storage/business/test_arealink_manager.py index 6f20f5873f..bef6dc151c 100644 --- a/tests/storage/business/test_arealink_manager.py +++ b/tests/storage/business/test_arealink_manager.py @@ -183,41 +183,14 @@ def test_area_crud(empty_study: FileStudy, matrix_service: SimpleMatrixService): [ CommandDTO( id=None, - action=CommandName.UPDATE_CONFIG.value, - args=[ - { - "target": "input/areas/test/ui/ui/x", - "data": 100, - }, - { - "target": "input/areas/test/ui/ui/y", - "data": 200, - }, - { - "target": "input/areas/test/ui/ui/color_r", - "data": 255, - }, - { - "target": "input/areas/test/ui/ui/color_g", - "data": 0, - }, - { - "target": "input/areas/test/ui/ui/color_b", - "data": 100, - }, - { - "target": "input/areas/test/ui/layerX/0", - "data": 100, - }, - { - "target": "input/areas/test/ui/layerY/0", - "data": 200, - }, - { - "target": "input/areas/test/ui/layerColor/0", - "data": "255,0,100", - }, - ], + action=CommandName.MOVE_AREA.value, + args={ + "area_id": "test", + "area_ui": UpdateAreaUi( + x=100, y=200, color_rgb=(255, 0, 100), layer_x={}, layer_y={}, layer_color={} + ), + "layer": "0", + }, study_version=study_version, ), ], diff --git a/tests/variantstudy/test_command_factory.py b/tests/variantstudy/test_command_factory.py index a2c392785a..91ff146298 100644 --- a/tests/variantstudy/test_command_factory.py +++ b/tests/variantstudy/test_command_factory.py @@ -19,6 +19,7 @@ import pytest from antarest.matrixstore.service import MatrixService +from antarest.study.business.model.area_model import UpdateAreaUi from antarest.study.model import STUDY_VERSION_8_8 from antarest.study.storage.patch_service import PatchService from antarest.study.storage.variantstudy.business.matrix_constants_generator import GeneratorMatrixConstants @@ -39,6 +40,15 @@ ), CommandDTO(action=CommandName.REMOVE_AREA.value, args={"id": "id"}, study_version=STUDY_VERSION_8_8), CommandDTO(action=CommandName.REMOVE_AREA.value, args=[{"id": "id"}], study_version=STUDY_VERSION_8_8), + CommandDTO( + action=CommandName.MOVE_AREA.value, + args={ + "area_id": "id", + "area_ui": UpdateAreaUi(x=100, y=100, color_rgb=(100, 100, 100), layer_x={}, layer_y={}, layer_color={}), + "layer": "0", + }, + study_version=STUDY_VERSION_8_8, + ), CommandDTO( action=CommandName.CREATE_DISTRICT.value, args={