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={