diff --git a/antarest/study/business/area_management.py b/antarest/study/business/area_management.py index 9c4967ca66..9e6e21314a 100644 --- a/antarest/study/business/area_management.py +++ b/antarest/study/business/area_management.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, Extra, Field from antarest.core.exceptions import ConfigFileNotFound, DuplicateAreaName, LayerNotAllowedToBeDeleted, LayerNotFound +from antarest.core.model import JSON from antarest.study.business.utils import AllOptionalMetaclass, camel_case_model, execute_or_add_commands from antarest.study.model import Patch, PatchArea, PatchCluster, RawStudy, Study from antarest.study.repository import StudyMetadataRepository @@ -16,6 +17,7 @@ AreaUI, OptimizationProperties, ThermalAreasProperties, + UIProperties, ) from antarest.study.storage.rawstudy.model.filesystem.config.model import Area, DistrictSet, transform_name_to_id from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy @@ -40,6 +42,7 @@ class AreaCreationDTO(BaseModel): set: t.Optional[t.List[str]] +# review: is this class necessary? class ClusterInfoDTO(PatchCluster): id: str name: str @@ -85,7 +88,9 @@ def _get_ui_info_map(file_study: FileStudy, area_ids: t.Sequence[str]) -> t.Dict # instead of raising an obscure exception. if not area_ids: return {} + ui_info_map = file_study.tree.get(["input", "areas", ",".join(area_ids), "ui"]) + # If there is only one ID in the `area_ids`, the result returned from # the `file_study.tree.get` call will be a single UI object. # On the other hand, if there are multiple values in `area_ids`, @@ -93,6 +98,10 @@ def _get_ui_info_map(file_study: FileStudy, area_ids: t.Sequence[str]) -> t.Dict # and the values are the corresponding UI objects. if len(area_ids) == 1: ui_info_map = {area_ids[0]: ui_info_map} + + # Convert to UIProperties to ensure that the UI object is valid. + ui_info_map = {area_id: UIProperties(**ui_info).to_config() for area_id, ui_info in ui_info_map.items()} + return ui_info_map @@ -133,7 +142,7 @@ class _BaseAreaDTO( # noinspection SpellCheckingInspection @camel_case_model -class GetAreaDTO(_BaseAreaDTO, metaclass=AllOptionalMetaclass): +class AreaOutput(_BaseAreaDTO, metaclass=AllOptionalMetaclass): """ DTO object use to get the area information using a flat structure. """ @@ -145,7 +154,7 @@ def create_area_dto( *, average_unsupplied_energy_cost: float, average_spilled_energy_cost: float, - ) -> "GetAreaDTO": + ) -> "AreaOutput": """ Creates a `GetAreaDTO` object from configuration data. @@ -194,7 +203,7 @@ def __init__( self.patch_service = PatchService(repository=repository) # noinspection SpellCheckingInspection - def get_all_area_props(self, study: RawStudy) -> t.Mapping[str, GetAreaDTO]: + def get_all_area_props(self, study: RawStudy) -> t.Mapping[str, AreaOutput]: """ Retrieves all areas of a study. @@ -232,7 +241,7 @@ def get_all_area_props(self, study: RawStudy) -> t.Mapping[str, GetAreaDTO]: area_map = {} for area_id, area_cfg in areas_cfg.items(): area_folder = AreaFolder(**area_cfg) - area_map[area_id] = GetAreaDTO.create_area_dto( + area_map[area_id] = AreaOutput.create_area_dto( area_folder, average_unsupplied_energy_cost=thermal_areas.unserverd_energy_cost.get(area_id, 0.0), average_spilled_energy_cost=thermal_areas.spilled_energy_cost.get(area_id, 0.0), @@ -240,6 +249,10 @@ def get_all_area_props(self, study: RawStudy) -> t.Mapping[str, GetAreaDTO]: return area_map + @staticmethod + def get_table_schema() -> JSON: + return AreaOutput.schema() + def get_all_areas(self, study: RawStudy, area_type: t.Optional[AreaType] = None) -> t.List[AreaInfoDTO]: """ Retrieves all areas and districts of a raw study based on the area type. @@ -496,32 +509,33 @@ def update_area_metadata( ) def update_area_ui(self, study: Study, area_id: str, area_ui: AreaUI, layer: str = "0") -> None: + obj = area_ui.to_config() file_study = self.storage_service.get_storage(study).get_raw(study) commands = ( [ UpdateConfig( target=f"input/areas/{area_id}/ui/ui/x", - data=area_ui.x, + data=obj["x"], command_context=self.storage_service.variant_study_service.command_factory.command_context, ), UpdateConfig( target=f"input/areas/{area_id}/ui/ui/y", - data=area_ui.y, + data=obj["y"], command_context=self.storage_service.variant_study_service.command_factory.command_context, ), UpdateConfig( target=f"input/areas/{area_id}/ui/ui/color_r", - data=area_ui.color_rgb[0], + data=obj["color_r"], command_context=self.storage_service.variant_study_service.command_factory.command_context, ), UpdateConfig( target=f"input/areas/{area_id}/ui/ui/color_g", - data=area_ui.color_rgb[1], + data=obj["color_g"], command_context=self.storage_service.variant_study_service.command_factory.command_context, ), UpdateConfig( target=f"input/areas/{area_id}/ui/ui/color_b", - data=area_ui.color_rgb[2], + data=obj["color_b"], command_context=self.storage_service.variant_study_service.command_factory.command_context, ), ] @@ -532,17 +546,17 @@ def update_area_ui(self, study: Study, area_id: str, area_ui: AreaUI, layer: str [ UpdateConfig( target=f"input/areas/{area_id}/ui/layerX/{layer}", - data=area_ui.x, + data=obj["x"], command_context=self.storage_service.variant_study_service.command_factory.command_context, ), UpdateConfig( target=f"input/areas/{area_id}/ui/layerY/{layer}", - data=area_ui.y, + data=obj["y"], command_context=self.storage_service.variant_study_service.command_factory.command_context, ), UpdateConfig( target=f"input/areas/{area_id}/ui/layerColor/{layer}", - data=f"{str(area_ui.color_rgb[0])} , {str(area_ui.color_rgb[1])} , {str(area_ui.color_rgb[2])}", + data=f"{obj['color_r']},{obj['color_g']},{obj['color_b']}", command_context=self.storage_service.variant_study_service.command_factory.command_context, ), ] @@ -593,11 +607,8 @@ def _update_with_cluster_metadata( def _get_clusters(file_study: FileStudy, area: str, metadata_patch: Patch) -> t.List[ClusterInfoDTO]: thermal_clusters_data = file_study.tree.get(["input", "thermal", "clusters", area, "list"]) cluster_patch = metadata_patch.thermal_clusters or {} - return [ - AreaManager._update_with_cluster_metadata( - area, - ClusterInfoDTO.parse_obj({**thermal_clusters_data[tid], "id": tid}), - cluster_patch, - ) - for tid in thermal_clusters_data + result = [ + AreaManager._update_with_cluster_metadata(area, ClusterInfoDTO(id=tid, **obj), cluster_patch) + for tid, obj in thermal_clusters_data.items() ] + return result diff --git a/antarest/study/business/areas/renewable_management.py b/antarest/study/business/areas/renewable_management.py index ac24297dae..8c23aa3f04 100644 --- a/antarest/study/business/areas/renewable_management.py +++ b/antarest/study/business/areas/renewable_management.py @@ -1,9 +1,11 @@ +import collections import json import typing as t from pydantic import validator from antarest.core.exceptions import DuplicateRenewableCluster, RenewableClusterConfigNotFound, RenewableClusterNotFound +from antarest.core.model import JSON from antarest.study.business.enum_ignore_case import EnumIgnoreCase from antarest.study.business.utils import AllOptionalMetaclass, camel_case_model, execute_or_add_commands from antarest.study.model import Study @@ -21,14 +23,6 @@ from antarest.study.storage.variantstudy.model.command.replace_matrix import ReplaceMatrix from antarest.study.storage.variantstudy.model.command.update_config import UpdateConfig -__all__ = ( - "RenewableClusterInput", - "RenewableClusterCreation", - "RenewableClusterOutput", - "RenewableManager", - "TimeSeriesInterpretation", -) - _CLUSTER_PATH = "input/renewables/clusters/{area_id}/list/{cluster_id}" _CLUSTERS_PATH = "input/renewables/clusters/{area_id}/list" _ALL_CLUSTERS_PATH = "input/renewables/clusters" @@ -148,7 +142,7 @@ def get_clusters(self, study: Study, area_id: str) -> t.Sequence[RenewableCluste def get_all_renewables_props( self, study: Study, - ) -> t.Mapping[str, t.Sequence[RenewableClusterOutput]]: + ) -> t.Mapping[str, t.Mapping[str, RenewableClusterOutput]]: """ Retrieve all renewable clusters from all areas within a study. @@ -156,7 +150,7 @@ def get_all_renewables_props( study: Study from which to retrieve the clusters. Returns: - A mapping of area IDs to lists of renewable clusters within the specified area. + A mapping of area IDs to a mapping of cluster IDs to cluster output. Raises: RenewableClusterConfigNotFound: If no clusters are found in the specified area. @@ -173,14 +167,13 @@ def get_all_renewables_props( raise RenewableClusterConfigNotFound(path) study_version = study.version - all_clusters = { - area_id: [ - create_renewable_output(study_version, cluster_id, cluster) - for cluster_id, cluster in cluster_obj.items() - ] - for area_id, cluster_obj in clusters.items() - } - return all_clusters + renewables_by_areas: t.MutableMapping[str, t.MutableMapping[str, RenewableClusterOutput]] + renewables_by_areas = collections.defaultdict(dict) + for area_id, cluster_obj in clusters.items(): + for cluster_id, cluster in cluster_obj.items(): + renewables_by_areas[area_id][cluster_id] = create_renewable_output(study_version, cluster_id, cluster) + + return renewables_by_areas def create_cluster( self, study: Study, area_id: str, cluster_data: RenewableClusterCreation @@ -365,3 +358,42 @@ def duplicate_cluster( execute_or_add_commands(study, self._get_file_study(study), commands, self.storage_service) return RenewableClusterOutput(**new_config.dict(by_alias=False)) + + def update_renewables_props( + self, + study: Study, + update_renewables_by_areas: t.Mapping[str, t.Mapping[str, RenewableClusterInput]], + ) -> t.Mapping[str, t.Mapping[str, RenewableClusterOutput]]: + old_renewables_by_areas = self.get_all_renewables_props(study) + new_renewables_by_areas = {area_id: dict(clusters) for area_id, clusters in old_renewables_by_areas.items()} + + # Prepare the commands to update the renewable clusters. + commands = [] + for area_id, update_renewables_by_id in update_renewables_by_areas.items(): + old_renewables_by_id = old_renewables_by_areas[area_id] + for renewable_id, update_cluster in update_renewables_by_id.items(): + # Update the renewable cluster properties. + old_cluster = old_renewables_by_id[renewable_id] + new_cluster = old_cluster.copy(update=update_cluster.dict(by_alias=False, exclude_none=True)) + new_renewables_by_areas[area_id][renewable_id] = new_cluster + + # Convert the DTO to a configuration object and update the configuration file. + properties = create_renewable_config( + study.version, **new_cluster.dict(by_alias=False, exclude_none=True) + ) + path = _CLUSTER_PATH.format(area_id=area_id, cluster_id=renewable_id) + cmd = UpdateConfig( + target=path, + data=json.loads(properties.json(by_alias=True, exclude={"id"})), + command_context=self.storage_service.variant_study_service.command_factory.command_context, + ) + commands.append(cmd) + + file_study = self.storage_service.get_storage(study).get_raw(study) + execute_or_add_commands(study, file_study, commands, self.storage_service) + + return new_renewables_by_areas + + @staticmethod + def get_table_schema() -> JSON: + return RenewableClusterOutput.schema() diff --git a/antarest/study/business/areas/st_storage_management.py b/antarest/study/business/areas/st_storage_management.py index 33bddde538..cb2f8cb4d0 100644 --- a/antarest/study/business/areas/st_storage_management.py +++ b/antarest/study/business/areas/st_storage_management.py @@ -1,3 +1,4 @@ +import collections import functools import json import operator @@ -15,6 +16,7 @@ STStorageMatrixNotFound, STStorageNotFound, ) +from antarest.core.model import JSON from antarest.study.business.utils import AllOptionalMetaclass, camel_case_model, execute_or_add_commands from antarest.study.model import Study from antarest.study.storage.rawstudy.model.filesystem.config.model import transform_name_to_id @@ -336,7 +338,7 @@ def get_storages( def get_all_storages_props( self, study: Study, - ) -> t.Mapping[str, t.Sequence[STStorageOutput]]: + ) -> t.Mapping[str, t.Mapping[str, STStorageOutput]]: """ Retrieve all short-term storages from all areas within a study. @@ -344,7 +346,7 @@ def get_all_storages_props( study: Study from which to retrieve the storages. Returns: - A mapping of area IDs to lists of short-term storages within the specified area. + A mapping of area IDs to a mapping of storage IDs to storage configurations. Raises: STStorageConfigNotFound: If no storages are found in the specified area. @@ -360,11 +362,48 @@ def get_all_storages_props( except KeyError: raise STStorageConfigNotFound(path) from None - all_storages = { - area_id: [STStorageOutput.from_config(cluster_id, cluster) for cluster_id, cluster in cluster_obj.items()] - for area_id, cluster_obj in storages.items() - } - return all_storages + storages_by_areas: t.MutableMapping[str, t.MutableMapping[str, STStorageOutput]] + storages_by_areas = collections.defaultdict(dict) + for area_id, cluster_obj in storages.items(): + for cluster_id, cluster in cluster_obj.items(): + storages_by_areas[area_id][cluster_id] = STStorageOutput.from_config(cluster_id, cluster) + + return storages_by_areas + + def update_storages_props( + self, + study: Study, + update_storages_by_areas: t.Mapping[str, t.Mapping[str, STStorageInput]], + ) -> t.Mapping[str, t.Mapping[str, STStorageOutput]]: + old_storages_by_areas = self.get_all_storages_props(study) + new_storages_by_areas = {area_id: dict(clusters) for area_id, clusters in old_storages_by_areas.items()} + + # Prepare the commands to update the storage clusters. + commands = [] + for area_id, update_storages_by_id in update_storages_by_areas.items(): + old_storages_by_id = old_storages_by_areas[area_id] + for storage_id, update_cluster in update_storages_by_id.items(): + # Update the storage cluster properties. + old_cluster = old_storages_by_id[storage_id] + new_cluster = old_cluster.copy(update=update_cluster.dict(by_alias=False, exclude_none=True)) + new_storages_by_areas[area_id][storage_id] = new_cluster + + # Convert the DTO to a configuration object and update the configuration file. + properties = create_st_storage_config( + study.version, **new_cluster.dict(by_alias=False, exclude_none=True) + ) + path = _STORAGE_LIST_PATH.format(area_id=area_id, storage_id=storage_id) + cmd = UpdateConfig( + target=path, + data=json.loads(properties.json(by_alias=True, exclude={"id"})), + command_context=self.storage_service.variant_study_service.command_factory.command_context, + ) + commands.append(cmd) + + file_study = self.storage_service.get_storage(study).get_raw(study) + execute_or_add_commands(study, file_study, commands, self.storage_service) + + return new_storages_by_areas def get_storage( self, @@ -647,3 +686,7 @@ def validate_matrices( # Validation successful return True + + @staticmethod + def get_table_schema() -> JSON: + return STStorageOutput.schema() diff --git a/antarest/study/business/areas/thermal_management.py b/antarest/study/business/areas/thermal_management.py index 1b8167247c..999ea1dd3b 100644 --- a/antarest/study/business/areas/thermal_management.py +++ b/antarest/study/business/areas/thermal_management.py @@ -1,9 +1,11 @@ +import collections import json import typing as t from pydantic import validator from antarest.core.exceptions import DuplicateThermalCluster, ThermalClusterConfigNotFound, ThermalClusterNotFound +from antarest.core.model import JSON from antarest.study.business.utils import AllOptionalMetaclass, camel_case_model, execute_or_add_commands from antarest.study.model import Study from antarest.study.storage.rawstudy.model.filesystem.config.model import transform_name_to_id @@ -182,7 +184,7 @@ def get_clusters( def get_all_thermals_props( self, study: Study, - ) -> t.Mapping[str, t.Sequence[ThermalClusterOutput]]: + ) -> t.Mapping[str, t.Mapping[str, ThermalClusterOutput]]: """ Retrieve all thermal clusters from all areas within a study. @@ -190,7 +192,7 @@ def get_all_thermals_props( study: Study from which to retrieve the clusters. Returns: - A mapping of area IDs to lists of thermal clusters within the specified area. + A mapping of area IDs to a mapping of cluster IDs to thermal cluster configurations. Raises: ThermalClusterConfigNotFound: If no clusters are found in the specified area. @@ -207,36 +209,35 @@ def get_all_thermals_props( raise ThermalClusterConfigNotFound(path) from None study_version = study.version - all_clusters = { - area_id: [ - create_thermal_output(study_version, cluster_id, cluster) for cluster_id, cluster in cluster_obj.items() - ] - for area_id, cluster_obj in clusters.items() - } - return all_clusters + thermals_by_areas: t.MutableMapping[str, t.MutableMapping[str, ThermalClusterOutput]] + thermals_by_areas = collections.defaultdict(dict) + for area_id, cluster_obj in clusters.items(): + for cluster_id, cluster in cluster_obj.items(): + thermals_by_areas[area_id][cluster_id] = create_thermal_output(study_version, cluster_id, cluster) + + return thermals_by_areas def update_thermals_props( self, study: Study, - update_thermals_by_areas: t.Mapping[str, t.Sequence[ThermalClusterOutput]], - ) -> t.Mapping[str, t.Sequence[ThermalClusterOutput]]: + update_thermals_by_areas: t.Mapping[str, t.Mapping[str, ThermalClusterInput]], + ) -> t.Mapping[str, t.Mapping[str, ThermalClusterOutput]]: old_thermals_by_areas = self.get_all_thermals_props(study) - new_thermals_by_names: t.MutableMapping[str, t.MutableSequence[ThermalClusterOutput]] = {} - file_study = self.storage_service.get_storage(study).get_raw(study) + new_thermals_by_areas = {area_id: dict(clusters) for area_id, clusters in old_thermals_by_areas.items()} + + # Prepare the commands to update the thermal clusters. commands = [] - for area_id, update_thermals in update_thermals_by_areas.items(): - old_thermals = old_thermals_by_areas.get(area_id, []) - old_thermals_by_id = {cluster.id: cluster for cluster in old_thermals} - update_thermals_by_id = {cluster.id: cluster for cluster in update_thermals} - for cluster_id, update_cluster in update_thermals_by_id.items(): + for area_id, update_thermals_by_id in update_thermals_by_areas.items(): + old_thermals_by_id = old_thermals_by_areas[area_id] + for thermal_id, update_cluster in update_thermals_by_id.items(): # Update the thermal cluster properties. - old_cluster = old_thermals_by_id[cluster_id] + old_cluster = old_thermals_by_id[thermal_id] new_cluster = old_cluster.copy(update=update_cluster.dict(by_alias=False, exclude_none=True)) - new_thermals_by_names.setdefault(area_id, []).append(new_cluster) + new_thermals_by_areas[area_id][thermal_id] = new_cluster # Convert the DTO to a configuration object and update the configuration file. properties = create_thermal_config(study.version, **new_cluster.dict(by_alias=False, exclude_none=True)) - path = _CLUSTER_PATH.format(area_id=area_id, cluster_id=cluster_id) + path = _CLUSTER_PATH.format(area_id=area_id, cluster_id=thermal_id) cmd = UpdateConfig( target=path, data=json.loads(properties.json(by_alias=True, exclude={"id"})), @@ -244,8 +245,14 @@ def update_thermals_props( ) commands.append(cmd) + file_study = self.storage_service.get_storage(study).get_raw(study) execute_or_add_commands(study, file_study, commands, self.storage_service) - return new_thermals_by_names + + return new_thermals_by_areas + + @staticmethod + def get_table_schema() -> JSON: + return ThermalClusterOutput.schema() def create_cluster(self, study: Study, area_id: str, cluster_data: ThermalClusterCreation) -> ThermalClusterOutput: """ diff --git a/antarest/study/business/binding_constraint_management.py b/antarest/study/business/binding_constraint_management.py index c4c850c323..01762b9cdb 100644 --- a/antarest/study/business/binding_constraint_management.py +++ b/antarest/study/business/binding_constraint_management.py @@ -18,6 +18,7 @@ MissingDataError, NoConstraintError, ) +from antarest.core.model import JSON from antarest.core.utils.string import to_camel_case from antarest.study.business.utils import AllOptionalMetaclass, camel_case_model, execute_or_add_commands from antarest.study.model import Study @@ -488,7 +489,7 @@ def get_binding_constraint(self, study: Study, bc_id: str) -> ConstraintOutput: file_study = storage_service.get_raw(study) config = file_study.tree.get(["input", "bindingconstraints", "bindingconstraints"]) - constraints_by_id: Dict[str, ConstraintOutput] = CaseInsensitiveDict() # type: ignore + constraints_by_id: t.Dict[str, ConstraintOutput] = CaseInsensitiveDict() # type: ignore for constraint in config.values(): constraint_config = self.constraint_model_adapter(constraint, int(study.version)) @@ -497,7 +498,7 @@ def get_binding_constraint(self, study: Study, bc_id: str) -> ConstraintOutput: if bc_id not in constraints_by_id: raise BindingConstraintNotFound(f"Binding constraint '{bc_id}' not found") - return t.cast(ConstraintOutput, constraints_by_id[bc_id]) + return constraints_by_id[bc_id] def get_binding_constraints( self, study: Study, filters: ConstraintFilters = ConstraintFilters() @@ -746,6 +747,29 @@ def update_binding_constraint( return self.constraint_model_adapter(upd_constraint, study_version) + def update_binding_constraints( + self, + study: Study, + bcs_by_ids: t.Mapping[str, ConstraintInput], + ) -> t.Mapping[str, ConstraintOutput]: + """ + Updates multiple binding constraints within a study. + + Args: + study: The study from which to update the constraints. + bcs_by_ids: A mapping of binding constraint IDs to their updated configurations. + + Returns: + A dictionary of the updated binding constraints, indexed by their IDs. + + Raises: + BindingConstraintNotFound: If any of the specified binding constraint IDs are not found. + """ + updated_constraints = {} + for bc_id, data in bcs_by_ids.items(): + updated_constraints[bc_id] = self.update_binding_constraint(study, bc_id, data) + return updated_constraints + def remove_binding_constraint(self, study: Study, binding_constraint_id: str) -> None: """ Removes a binding constraint from a study. @@ -867,6 +891,10 @@ def remove_constraint_term( ) -> None: return self.update_constraint_term(study, binding_constraint_id, term_id) # type: ignore + @staticmethod + def get_table_schema() -> JSON: + return ConstraintOutput870.schema() + def _replace_matrices_according_to_frequency_and_version( data: ConstraintInput, version: int, args: t.Dict[str, t.Any] diff --git a/antarest/study/business/link_management.py b/antarest/study/business/link_management.py index 4d65e51aba..746a998ba6 100644 --- a/antarest/study/business/link_management.py +++ b/antarest/study/business/link_management.py @@ -3,6 +3,7 @@ from pydantic import BaseModel from antarest.core.exceptions import ConfigFileNotFound +from antarest.core.model import JSON from antarest.study.business.utils import AllOptionalMetaclass, camel_case_model, execute_or_add_commands from antarest.study.model import RawStudy from antarest.study.storage.rawstudy.model.filesystem.config.links import LinkProperties @@ -27,7 +28,7 @@ class LinkInfoDTO(BaseModel): @camel_case_model -class GetLinkDTO(LinkProperties, metaclass=AllOptionalMetaclass, use_none=True): +class LinkOutput(LinkProperties, metaclass=AllOptionalMetaclass, use_none=True): """ DTO object use to get the link information. """ @@ -79,7 +80,7 @@ def delete_link(self, study: RawStudy, area1_id: str, area2_id: str) -> None: ) execute_or_add_commands(study, file_study, [command], self.storage_service) - def get_all_links_props(self, study: RawStudy) -> t.Mapping[t.Tuple[str, str], GetLinkDTO]: + def get_all_links_props(self, study: RawStudy) -> t.Mapping[t.Tuple[str, str], LinkOutput]: """ Retrieves all links properties from the study. @@ -107,15 +108,15 @@ def get_all_links_props(self, study: RawStudy) -> t.Mapping[t.Tuple[str, str], G for area2_id, properties_cfg in property_map.items(): area1_id, area2_id = sorted([area1_id, area2_id]) properties = LinkProperties(**properties_cfg) - links_by_ids[(area1_id, area2_id)] = GetLinkDTO(**properties.dict(by_alias=False)) + links_by_ids[(area1_id, area2_id)] = LinkOutput(**properties.dict(by_alias=False)) return links_by_ids def update_links_props( self, study: RawStudy, - update_links_by_ids: t.Mapping[t.Tuple[str, str], GetLinkDTO], - ) -> t.Mapping[t.Tuple[str, str], GetLinkDTO]: + update_links_by_ids: t.Mapping[t.Tuple[str, str], LinkOutput], + ) -> t.Mapping[t.Tuple[str, str], LinkOutput]: old_links_by_ids = self.get_all_links_props(study) new_links_by_ids = {} file_study = self.storage_service.get_storage(study).get_raw(study) @@ -138,3 +139,7 @@ def update_links_props( execute_or_add_commands(study, file_study, commands, self.storage_service) return new_links_by_ids + + @staticmethod + def get_table_schema() -> JSON: + return LinkOutput.schema() diff --git a/antarest/study/business/table_mode_management.py b/antarest/study/business/table_mode_management.py index 9d9f333862..e2c94ab059 100644 --- a/antarest/study/business/table_mode_management.py +++ b/antarest/study/business/table_mode_management.py @@ -2,649 +2,51 @@ import typing as t import pandas as pd -from pydantic import Field +from antarest.core.model import JSON from antarest.study.business.area_management import AreaManager -from antarest.study.business.areas.renewable_management import RenewableManager, TimeSeriesInterpretation -from antarest.study.business.areas.st_storage_management import STStorageManager -from antarest.study.business.areas.thermal_management import ThermalClusterOutput, ThermalManager -from antarest.study.business.binding_constraint_management import BindingConstraintManager +from antarest.study.business.areas.renewable_management import ( + RenewableClusterInput, + RenewableClusterOutput, + RenewableManager, +) +from antarest.study.business.areas.st_storage_management import STStorageInput, STStorageManager, STStorageOutput +from antarest.study.business.areas.thermal_management import ThermalClusterInput, ThermalClusterOutput, 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 GetLinkDTO, LinkManager -from antarest.study.business.utils import AllOptionalMetaclass, FormFieldsBaseModel -from antarest.study.common.default_values import FilteringOptions, LinkProperties, NodalOptimization +from antarest.study.business.link_management import LinkManager, LinkOutput from antarest.study.model import RawStudy -from antarest.study.storage.rawstudy.model.filesystem.config.area import AdequacyPatchMode -from antarest.study.storage.rawstudy.model.filesystem.config.binding_constraint import ( - BindingConstraintFrequency, - BindingConstraintOperator, -) -from antarest.study.storage.rawstudy.model.filesystem.config.links import AssetType, TransmissionCapacity -from antarest.study.storage.rawstudy.model.filesystem.config.thermal import LawOption, LocalTSGenerationBehavior -from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy -AREA_PATH = "input/areas/{area}" -THERMAL_PATH = "input/thermal/areas" -LINK_GLOB_PATH = "input/links/{area1}/properties" -LINK_PATH = f"{LINK_GLOB_PATH}/{{area2}}" -THERMAL_CLUSTER_GLOB_PATH = "input/thermal/clusters/{area}/list" -THERMAL_CLUSTER_PATH = f"{THERMAL_CLUSTER_GLOB_PATH}/{{cluster}}" -RENEWABLE_CLUSTER_GLOB_PATH = "input/renewables/clusters/{area}/list" -RENEWABLE_CLUSTER_PATH = f"{RENEWABLE_CLUSTER_GLOB_PATH}/{{cluster}}" -BINDING_CONSTRAINT_PATH = "input/bindingconstraints/bindingconstraints" +_TableIndex = str # row name +_TableColumn = str # column name +_CellValue = t.Any # cell value (str, int, float, bool, enum, etc.) +TableDataDTO = t.Mapping[_TableIndex, t.Mapping[_TableColumn, _CellValue]] class TableTemplateType(EnumIgnoreCase): + """ + Table template types. + + This enum is used to define the different types of tables that can be created + by the user to leverage the editing capabilities of multiple objects at once. + + Attributes: + AREA: Area table. + LINK: Link table. + THERMAL_CLUSTER: Thermal clusters table. + RENEWABLE_CLUSTER: Renewable clusters table. + ST_STORAGE: Short-Term Storages table. + BINDING_CONSTRAINT: Binding constraints table. + """ + AREA = "areas" LINK = "links" THERMAL_CLUSTER = "thermals" RENEWABLE_CLUSTER = "renewables" - ST_STORAGE = "storages" - BINDING_CONSTRAINT = "constraints" - - -class AreaColumns(FormFieldsBaseModel, metaclass=AllOptionalMetaclass): - # Optimization - Nodal optimization - non_dispatchable_power: bool = Field( - default=NodalOptimization.NON_DISPATCHABLE_POWER, - path=f"{AREA_PATH}/optimization/nodal optimization/non-dispatchable-power", - ) - dispatchable_hydro_power: bool = Field( - default=NodalOptimization.DISPATCHABLE_HYDRO_POWER, - path=f"{AREA_PATH}/optimization/nodal optimization/dispatchable-hydro-power", - ) - other_dispatchable_power: bool = Field( - default=NodalOptimization.OTHER_DISPATCHABLE_POWER, - path=f"{AREA_PATH}/optimization/nodal optimization/other-dispatchable-power", - ) - average_unsupplied_energy_cost: float = Field( - default=NodalOptimization.SPREAD_UNSUPPLIED_ENERGY_COST, - path=f"{THERMAL_PATH}/unserverdenergycost/{{area}}", - ) - spread_unsupplied_energy_cost: float = Field( - default=NodalOptimization.SPREAD_UNSUPPLIED_ENERGY_COST, - path=f"{AREA_PATH}/optimization/nodal optimization/spread-unsupplied-energy-cost", - ) - average_spilled_energy_cost: float = Field( - default=NodalOptimization.SPREAD_SPILLED_ENERGY_COST, - path=f"{THERMAL_PATH}/spilledenergycost/{{area}}", - ) - spread_spilled_energy_cost: float = Field( - default=NodalOptimization.SPREAD_SPILLED_ENERGY_COST, - path=f"{AREA_PATH}/optimization/nodal optimization/spread-spilled-energy-cost", - ) - # Optimization - Filtering - filter_synthesis: str = Field( - default=FilteringOptions.FILTER_SYNTHESIS, - path=f"{AREA_PATH}/optimization/filtering/filter-synthesis", - ) - filter_year_by_year: str = Field( - default=FilteringOptions.FILTER_YEAR_BY_YEAR, - path=f"{AREA_PATH}/optimization/filtering/filter-year-by-year", - ) - # Adequacy patch - adequacy_patch_mode: AdequacyPatchMode = Field( - default=AdequacyPatchMode.OUTSIDE.value, - path=f"{AREA_PATH}/adequacy_patch/adequacy-patch/adequacy-patch-mode", - ) - - -class LinkColumns(FormFieldsBaseModel, metaclass=AllOptionalMetaclass): - hurdles_cost: bool = Field(default=LinkProperties.HURDLES_COST, path=f"{LINK_PATH}/hurdles-cost") - loop_flow: bool = Field(default=LinkProperties.LOOP_FLOW, path=f"{LINK_PATH}/loop-flow") - use_phase_shifter: bool = Field( - default=LinkProperties.USE_PHASE_SHIFTER, - path=f"{LINK_PATH}/use-phase-shifter", - ) - transmission_capacities: TransmissionCapacity = Field( - default=LinkProperties.TRANSMISSION_CAPACITIES, - path=f"{LINK_PATH}/transmission-capacities", - ) - asset_type: AssetType = Field(default=LinkProperties.ASSET_TYPE, path=f"{LINK_PATH}/asset-type") - link_style: str = Field(default=LinkProperties.LINK_STYLE, path=f"{LINK_PATH}/link-style") - link_width: int = Field(default=LinkProperties.LINK_WIDTH, path=f"{LINK_PATH}/link-width") - display_comments: bool = Field( - default=LinkProperties.DISPLAY_COMMENTS, - path=f"{LINK_PATH}/display-comments", - ) - filter_synthesis: str = Field( - default=FilteringOptions.FILTER_SYNTHESIS, - path=f"{LINK_PATH}/filter-synthesis", - ) - filter_year_by_year: str = Field( - default=FilteringOptions.FILTER_YEAR_BY_YEAR, - path=f"{LINK_PATH}/filter-year-by-year", - ) - - -class ThermalClusterColumns(FormFieldsBaseModel, metaclass=AllOptionalMetaclass): - group: str = Field( - default="", - path=f"{THERMAL_CLUSTER_PATH}/group", - ) - enabled: bool = Field( - default=True, - path=f"{THERMAL_CLUSTER_PATH}/enabled", - ) - must_run: bool = Field( - default=False, - path=f"{THERMAL_CLUSTER_PATH}/must-run", - ) - unit_count: int = Field( - default=0, - path=f"{THERMAL_CLUSTER_PATH}/unitcount", - ) - nominal_capacity: int = Field( - default=0, - path=f"{THERMAL_CLUSTER_PATH}/nominalcapacity", - ) - min_stable_power: int = Field( - default=0, - path=f"{THERMAL_CLUSTER_PATH}/min-stable-power", - ) - spinning: int = Field( - default=0, - path=f"{THERMAL_CLUSTER_PATH}/spinning", - ) - min_up_time: int = Field( - default=1, - path=f"{THERMAL_CLUSTER_PATH}/min-up-time", - ) - min_down_time: int = Field( - default=1, - path=f"{THERMAL_CLUSTER_PATH}/min-down-time", - ) - marginal_cost: int = Field( - default=0, - path=f"{THERMAL_CLUSTER_PATH}/marginal-cost", - ) - fixed_cost: int = Field( - default=0, - path=f"{THERMAL_CLUSTER_PATH}/fixed-cost", - ) - startup_cost: int = Field( - default=0, - path=f"{THERMAL_CLUSTER_PATH}/startup-cost", - ) - market_bid_cost: int = Field( - default=0, - path=f"{THERMAL_CLUSTER_PATH}/market-bid-cost", - ) - spread_cost: int = Field( - default=0, - path=f"{THERMAL_CLUSTER_PATH}/spread-cost", - ) - ts_gen: LocalTSGenerationBehavior = Field( - default=LocalTSGenerationBehavior.USE_GLOBAL.value, - path=f"{THERMAL_CLUSTER_PATH}/gen-ts", - ) - volatility_forced: int = Field( - default=0, - path=f"{THERMAL_CLUSTER_PATH}/volatility.forced", - ) - volatility_planned: int = Field( - default=0, - path=f"{THERMAL_CLUSTER_PATH}/volatility.planned", - ) - law_forced: LawOption = Field( - default=LawOption.UNIFORM.value, - path=f"{THERMAL_CLUSTER_PATH}/law.forced", - ) - law_planned: LawOption = Field( - default=LawOption.UNIFORM.value, - path=f"{THERMAL_CLUSTER_PATH}/law.planned", - ) - # Pollutants - co2: float = Field( - default=0.0, - path=f"{THERMAL_CLUSTER_PATH}/co2", - ) - so2: float = Field( - default=0.0, - path=f"{THERMAL_CLUSTER_PATH}/so2", - ) - nh3: float = Field( - default=0.0, - path=f"{THERMAL_CLUSTER_PATH}/nh3", - ) - nox: float = Field( - default=0.0, - path=f"{THERMAL_CLUSTER_PATH}/nox", - ) - nmvoc: float = Field( - default=0.0, - path=f"{THERMAL_CLUSTER_PATH}/nmvoc", - ) - pm25: float = Field( - default=0.0, - path=f"{THERMAL_CLUSTER_PATH}/pm2_5", - ) - pm5: float = Field( - default=0.0, - path=f"{THERMAL_CLUSTER_PATH}/pm5", - ) - pm10: float = Field( - default=0.0, - path=f"{THERMAL_CLUSTER_PATH}/pm10", - ) - op1: float = Field( - default=0.0, - path=f"{THERMAL_CLUSTER_PATH}/op1", - ) - op2: float = Field( - default=0.0, - path=f"{THERMAL_CLUSTER_PATH}/op2", - ) - op3: float = Field( - default=0.0, - path=f"{THERMAL_CLUSTER_PATH}/op3", - ) - op4: float = Field( - default=0.0, - path=f"{THERMAL_CLUSTER_PATH}/op4", - ) - op5: float = Field( - default=0.0, - path=f"{THERMAL_CLUSTER_PATH}/op5", - ) - - -class RenewableClusterColumns(FormFieldsBaseModel, metaclass=AllOptionalMetaclass): - group: str = Field(default="", path=f"{RENEWABLE_CLUSTER_PATH}/group") - ts_interpretation: TimeSeriesInterpretation = Field( - default=TimeSeriesInterpretation.POWER_GENERATION.value, - path=f"{RENEWABLE_CLUSTER_PATH}/ts-interpretation", - ) - enabled: bool = Field(default=True, path=f"{RENEWABLE_CLUSTER_PATH}/enabled") - unit_count: int = Field(default=0, path=f"{RENEWABLE_CLUSTER_PATH}/unitcount") - nominal_capacity: int = Field(default=0, path=f"{RENEWABLE_CLUSTER_PATH}/nominalcapacity") - - -class BindingConstraintColumns(FormFieldsBaseModel, metaclass=AllOptionalMetaclass): - type: BindingConstraintFrequency = Field( - default=BindingConstraintFrequency.HOURLY.value, - path=f"{BINDING_CONSTRAINT_PATH}/type", - ) - operator: BindingConstraintOperator = Field( - default=BindingConstraintOperator.LESS.value, - path=f"{BINDING_CONSTRAINT_PATH}/operator", - ) - enabled: bool = Field( - default=True, - path=f"{BINDING_CONSTRAINT_PATH}/enabled", - ) - group: t.Optional[str] = Field( - default="default", - path=f"{BINDING_CONSTRAINT_PATH}/group", - ) - - -class ColumnInfo(t.TypedDict): - path: str - default_value: t.Any - - -class PathVars(t.TypedDict, total=False): - # Area - id: str - # Link - area1: str - area2: str - # Thermal cluster, Renewable cluster - area: str - cluster: str - - -FIELDS_INFO_BY_TYPE: t.Dict[TableTemplateType, t.Dict[str, ColumnInfo]] = { - TableTemplateType.AREA: { - "non_dispatchable_power": { - "path": f"{AREA_PATH}/optimization/nodal optimization/non-dispatchable-power", - "default_value": NodalOptimization.NON_DISPATCHABLE_POWER, - }, - "dispatchable_hydro_power": { - "path": f"{AREA_PATH}/optimization/nodal optimization/dispatchable-hydro-power", - "default_value": NodalOptimization.DISPATCHABLE_HYDRO_POWER, - }, - "other_dispatchable_power": { - "path": f"{AREA_PATH}/optimization/nodal optimization/other-dispatchable-power", - "default_value": NodalOptimization.OTHER_DISPATCHABLE_POWER, - }, - "average_unsupplied_energy_cost": { - "path": f"{THERMAL_PATH}/unserverdenergycost/{{area}}", - "default_value": NodalOptimization.SPREAD_UNSUPPLIED_ENERGY_COST, - }, - "spread_unsupplied_energy_cost": { - "path": f"{AREA_PATH}/optimization/nodal optimization/spread-unsupplied-energy-cost", - "default_value": NodalOptimization.SPREAD_UNSUPPLIED_ENERGY_COST, - }, - "average_spilled_energy_cost": { - "path": f"{THERMAL_PATH}/spilledenergycost/{{area}}", - "default_value": NodalOptimization.SPREAD_SPILLED_ENERGY_COST, - }, - "spread_spilled_energy_cost": { - "path": f"{AREA_PATH}/optimization/nodal optimization/spread-spilled-energy-cost", - "default_value": NodalOptimization.SPREAD_SPILLED_ENERGY_COST, - }, - "filter_synthesis": { - "path": f"{AREA_PATH}/optimization/filtering/filter-synthesis", - "default_value": FilteringOptions.FILTER_SYNTHESIS, - }, - "filter_year_by_year": { - "path": f"{AREA_PATH}/optimization/filtering/filter-year-by-year", - "default_value": FilteringOptions.FILTER_YEAR_BY_YEAR, - }, - "adequacy_patch_mode": { - "path": f"{AREA_PATH}/adequacy_patch/adequacy-patch/adequacy-patch-mode", - "default_value": AdequacyPatchMode.OUTSIDE.value, - }, - }, - TableTemplateType.LINK: { - "hurdles_cost": { - "path": f"{LINK_PATH}/hurdles-cost", - "default_value": LinkProperties.HURDLES_COST, - }, - "loop_flow": { - "path": f"{LINK_PATH}/loop-flow", - "default_value": LinkProperties.LOOP_FLOW, - }, - "use_phase_shifter": { - "path": f"{LINK_PATH}/use-phase-shifter", - "default_value": LinkProperties.USE_PHASE_SHIFTER, - }, - "transmission_capacities": { - "path": f"{LINK_PATH}/transmission-capacities", - "default_value": LinkProperties.TRANSMISSION_CAPACITIES, - }, - "asset_type": { - "path": f"{LINK_PATH}/asset-type", - "default_value": LinkProperties.ASSET_TYPE, - }, - "link_style": { - "path": f"{LINK_PATH}/link-style", - "default_value": LinkProperties.LINK_STYLE, - }, - "link_width": { - "path": f"{LINK_PATH}/link-width", - "default_value": LinkProperties.LINK_WIDTH, - }, - "display_comments": { - "path": f"{LINK_PATH}/display-comments", - "default_value": LinkProperties.DISPLAY_COMMENTS, - }, - "filter_synthesis": { - "path": f"{LINK_PATH}/filter-synthesis", - "default_value": FilteringOptions.FILTER_SYNTHESIS, - }, - "filter_year_by_year": { - "path": f"{LINK_PATH}/filter-year-by-year", - "default_value": FilteringOptions.FILTER_YEAR_BY_YEAR, - }, - }, - TableTemplateType.THERMAL_CLUSTER: { - "group": { - "path": f"{THERMAL_CLUSTER_PATH}/group", - "default_value": "", - }, - "enabled": { - "path": f"{THERMAL_CLUSTER_PATH}/enabled", - "default_value": True, - }, - "must_run": { - "path": f"{THERMAL_CLUSTER_PATH}/must-run", - "default_value": False, - }, - "unit_count": { - "path": f"{THERMAL_CLUSTER_PATH}/unitcount", - "default_value": 0, - }, - "nominal_capacity": { - "path": f"{THERMAL_CLUSTER_PATH}/nominalcapacity", - "default_value": 0, - }, - "min_stable_power": { - "path": f"{THERMAL_CLUSTER_PATH}/min-stable-power", - "default_value": 0, - }, - "spinning": { - "path": f"{THERMAL_CLUSTER_PATH}/spinning", - "default_value": 0, - }, - "min_up_time": { - "path": f"{THERMAL_CLUSTER_PATH}/min-up-time", - "default_value": 1, - }, - "min_down_time": { - "path": f"{THERMAL_CLUSTER_PATH}/min-down-time", - "default_value": 1, - }, - "co2": { - "path": f"{THERMAL_CLUSTER_PATH}/co2", - "default_value": 0, - }, - "marginal_cost": { - "path": f"{THERMAL_CLUSTER_PATH}/marginal-cost", - "default_value": 0, - }, - "fixed_cost": { - "path": f"{THERMAL_CLUSTER_PATH}/fixed-cost", - "default_value": 0, - }, - "startup_cost": { - "path": f"{THERMAL_CLUSTER_PATH}/startup-cost", - "default_value": 0, - }, - "market_bid_cost": { - "path": f"{THERMAL_CLUSTER_PATH}/market-bid-cost", - "default_value": 0, - }, - "spread_cost": { - "path": f"{THERMAL_CLUSTER_PATH}/spread-cost", - "default_value": 0, - }, - "ts_gen": { - "path": f"{THERMAL_CLUSTER_PATH}/gen-ts", - "default_value": LocalTSGenerationBehavior.USE_GLOBAL.value, - }, - "volatility_forced": { - "path": f"{THERMAL_CLUSTER_PATH}/volatility.forced", - "default_value": 0, - }, - "volatility_planned": { - "path": f"{THERMAL_CLUSTER_PATH}/volatility.planned", - "default_value": 0, - }, - "law_forced": { - "path": f"{THERMAL_CLUSTER_PATH}/law.forced", - "default_value": LawOption.UNIFORM.value, - }, - "law_planned": { - "path": f"{THERMAL_CLUSTER_PATH}/law.planned", - "default_value": LawOption.UNIFORM.value, - }, - }, - TableTemplateType.RENEWABLE_CLUSTER: { - "group": { - "path": f"{RENEWABLE_CLUSTER_PATH}/group", - "default_value": "", - }, - "ts_interpretation": { - "path": f"{RENEWABLE_CLUSTER_PATH}/ts-interpretation", - "default_value": TimeSeriesInterpretation.POWER_GENERATION.value, - }, - "enabled": { - "path": f"{RENEWABLE_CLUSTER_PATH}/enabled", - "default_value": True, - }, - "unit_count": { - "path": f"{RENEWABLE_CLUSTER_PATH}/unitcount", - "default_value": 0, - }, - "nominal_capacity": { - "path": f"{RENEWABLE_CLUSTER_PATH}/nominalcapacity", - "default_value": 0, - }, - }, - TableTemplateType.BINDING_CONSTRAINT: { - "type": { - "path": f"{BINDING_CONSTRAINT_PATH}/type", - "default_value": BindingConstraintFrequency.HOURLY.value, - }, - "operator": { - "path": f"{BINDING_CONSTRAINT_PATH}/operator", - "default_value": BindingConstraintOperator.LESS.value, - }, - "enabled": { - "path": f"{BINDING_CONSTRAINT_PATH}/enabled", - "default_value": True, - }, - "group": { - "path": f"{BINDING_CONSTRAINT_PATH}/group", - "default_value": None, - }, - }, -} - -COLUMNS_MODELS_BY_TYPE = { - TableTemplateType.AREA: AreaColumns, - TableTemplateType.LINK: LinkColumns, - TableTemplateType.THERMAL_CLUSTER: ThermalClusterColumns, - TableTemplateType.RENEWABLE_CLUSTER: RenewableClusterColumns, - TableTemplateType.BINDING_CONSTRAINT: BindingConstraintColumns, -} - -ColumnsModelTypes = t.Union[ - AreaColumns, - LinkColumns, - ThermalClusterColumns, - RenewableClusterColumns, - BindingConstraintColumns, -] - - -def _get_glob_object(file_study: FileStudy, table_type: TableTemplateType) -> t.Dict[str, t.Any]: - """ - Retrieves the fields of an object according to its type (area, link, thermal cluster...). - - Args: - file_study: A file study from which the configuration can be read. - table_type: Type of the object. - - Returns: - Dictionary containing the fields used in Table mode. - - Raises: - ChildNotFoundError: if one of the Area IDs is not found in the configuration. - """ - # sourcery skip: extract-method - if table_type == TableTemplateType.AREA: - info_map: t.Dict[str, t.Any] = file_study.tree.get(url=AREA_PATH.format(area="*").split("/"), depth=3) - area_ids = list(file_study.config.areas) - # If there is only one ID in the `area_ids`, the result returned from - # the `file_study.tree.get` call will be a single object. - # On the other hand, if there are multiple values in `area_ids`, - # the result will be a dictionary where the keys are the IDs, - # and the values are the corresponding objects. - if len(area_ids) == 1: - info_map = {area_ids[0]: info_map} - # Add thermal fields in info_map - thermal_fields = file_study.tree.get(THERMAL_PATH.split("/")) - for field, field_props in thermal_fields.items(): - for area_id, value in field_props.items(): - if area_id in info_map: - info_map[area_id][field] = value - return info_map - - if table_type == TableTemplateType.LINK: - return file_study.tree.get(LINK_GLOB_PATH.format(area1="*").split("/")) - if table_type == TableTemplateType.THERMAL_CLUSTER: - return file_study.tree.get(THERMAL_CLUSTER_GLOB_PATH.format(area="*").split("/")) - if table_type == TableTemplateType.RENEWABLE_CLUSTER: - return file_study.tree.get(RENEWABLE_CLUSTER_GLOB_PATH.format(area="*").split("/")) - if table_type == TableTemplateType.BINDING_CONSTRAINT: - return file_study.tree.get(BINDING_CONSTRAINT_PATH.split("/")) - - return {} - - -def _get_value(path: t.List[str], data: t.Dict[str, t.Any], default_value: t.Any) -> t.Any: - if len(path): - return _get_value(path[1:], data.get(path[0], {}), default_value) - return data if data != {} else default_value - - -def _get_relative_path( - table_type: TableTemplateType, - path: str, -) -> t.List[str]: - base_path = "" - path_arr = path.split("/") - - if table_type == TableTemplateType.AREA: - if path.startswith(THERMAL_PATH): - base_path = THERMAL_PATH - # Remove {area} - path_arr = path_arr[:-1] - else: - base_path = AREA_PATH - elif table_type == TableTemplateType.LINK: - base_path = LINK_PATH - elif table_type == TableTemplateType.THERMAL_CLUSTER: - base_path = THERMAL_CLUSTER_PATH - elif table_type == TableTemplateType.RENEWABLE_CLUSTER: - base_path = RENEWABLE_CLUSTER_PATH - elif table_type == TableTemplateType.BINDING_CONSTRAINT: - base_path = BINDING_CONSTRAINT_PATH - - return path_arr[len(base_path.split("/")) :] - - -def _get_column_path( - table_type: TableTemplateType, - column: str, - path_vars: PathVars, -) -> str: - columns_model = COLUMNS_MODELS_BY_TYPE[table_type] - path = t.cast(str, columns_model.__fields__[column].field_info.extra["path"]) - - if table_type == TableTemplateType.AREA: - return path.format(area=path_vars["id"]) - if table_type == TableTemplateType.LINK: - return path.format(area1=path_vars["area1"], area2=path_vars["area2"]) - if table_type in [ - TableTemplateType.THERMAL_CLUSTER, - TableTemplateType.RENEWABLE_CLUSTER, - ]: - return path.format(area=path_vars["area"], cluster=path_vars["cluster"]) - - return path - - -def _get_path_vars_from_key( - table_type: TableTemplateType, - key: str, -) -> PathVars: - if table_type in [ - TableTemplateType.AREA, - TableTemplateType.BINDING_CONSTRAINT, - ]: - return PathVars(id=key) - if table_type == TableTemplateType.LINK: - area1, area2 = [v.strip() for v in key.split("/")] - return PathVars(area1=area1, area2=area2) - if table_type in [ - TableTemplateType.THERMAL_CLUSTER, - TableTemplateType.RENEWABLE_CLUSTER, - ]: - area, cluster = [v.strip() for v in key.split("/")] - return PathVars(area=area, cluster=cluster) - - return PathVars() - - -_TableIndex = str # row name -_TableColumn = str # column name -_CellValue = t.Any # cell value (str, int, float, bool, enum, etc.) -TableDataDTO = t.Mapping[_TableIndex, t.Mapping[_TableColumn, _CellValue]] + # Avoid "storages" because we may have "lt-storages" (long-term storages) in the future + ST_STORAGE = "st-storages" + # Avoid "constraints" because we may have other kinds of constraints in the future + BINDING_CONSTRAINT = "binding-constraints" class TableModeManager: @@ -679,25 +81,25 @@ def get_table_data( f"{area1_id} / {area2_id}": link.dict(by_alias=True) for (area1_id, area2_id), link in links_map.items() } elif table_type == TableTemplateType.THERMAL_CLUSTER: - thermals_map = self._thermal_manager.get_all_thermals_props(study) + thermals_by_areas = self._thermal_manager.get_all_thermals_props(study) data = { - f"{area_id} / {cluster.id}": cluster.dict(by_alias=True) - for area_id, clusters in thermals_map.items() - for cluster in clusters + f"{area_id} / {storage_id}": storage.dict(by_alias=True) + for area_id, thermals_by_ids in thermals_by_areas.items() + for storage_id, storage in thermals_by_ids.items() } elif table_type == TableTemplateType.RENEWABLE_CLUSTER: - renewables_map = self._renewable_manager.get_all_renewables_props(study) + renewables_by_areas = self._renewable_manager.get_all_renewables_props(study) data = { - f"{area_id} / {cluster.id}": cluster.dict(by_alias=True) - for area_id, clusters in renewables_map.items() - for cluster in clusters + f"{area_id} / {storage_id}": storage.dict(by_alias=True) + for area_id, renewables_by_ids in renewables_by_areas.items() + for storage_id, storage in renewables_by_ids.items() } elif table_type == TableTemplateType.ST_STORAGE: - storages_map = self._st_storage_manager.get_all_storages_props(study) + storages_by_areas = self._st_storage_manager.get_all_storages_props(study) data = { - f"{area_id} / {storage.id}": storage.dict(by_alias=True) - for area_id, storages in storages_map.items() - for storage in storages + f"{area_id} / {storage_id}": storage.dict(by_alias=True) + for area_id, storages_by_ids in storages_by_areas.items() + for storage_id, storage in storages_by_ids.items() } elif table_type == TableTemplateType.BINDING_CONSTRAINT: bc_seq = self._binding_constraint_manager.get_binding_constraints(study) @@ -710,7 +112,7 @@ def get_table_data( # Create a new dataframe with the listed columns. # If a column does not exist in the DataFrame, it is created with empty values. df = pd.DataFrame(df, columns=columns) # type: ignore - df = df.where(pd.notna(df), other=None) + df = df.where(pd.notna(df), other=None) # obj = df.to_dict(orient="index") @@ -722,16 +124,16 @@ def get_table_data( return t.cast(TableDataDTO, obj) - def set_table_data( + def update_table_data( self, study: RawStudy, table_type: TableTemplateType, data: TableDataDTO, ) -> TableDataDTO: if table_type == TableTemplateType.AREA: - return {} + raise NotImplementedError("Area table modification is not implemented yet") elif table_type == TableTemplateType.LINK: - links_map = {tuple(key.split(" / ")): GetLinkDTO(**values) for key, values in data.items()} + links_map = {tuple(key.split(" / ")): LinkOutput(**values) for key, values in data.items()} updated_map = self._link_manager.update_links_props(study, links_map) # type: ignore data = { f"{area1_id} / {area2_id}": link.dict(by_alias=True) @@ -739,22 +141,72 @@ def set_table_data( } return data elif table_type == TableTemplateType.THERMAL_CLUSTER: - thermals_by_areas = collections.defaultdict(list) + thermals_by_areas: t.MutableMapping[str, t.MutableMapping[str, ThermalClusterInput]] + thermals_by_areas = collections.defaultdict(dict) for key, values in data.items(): area_id, cluster_id = key.split(" / ") - thermals_by_areas[area_id].append(ThermalClusterOutput(**values, id=cluster_id)) + thermals_by_areas[area_id][cluster_id] = ThermalClusterInput(**values) thermals_map = self._thermal_manager.update_thermals_props(study, thermals_by_areas) data = { - f"{area_id} / {cluster.id}": cluster.dict(by_alias=True) - for area_id, clusters in thermals_map.items() - for cluster in clusters + f"{area_id} / {cluster_id}": cluster.dict(by_alias=True) + for area_id, thermals_by_ids in thermals_map.items() + for cluster_id, cluster in thermals_by_ids.items() + } + return data + elif table_type == TableTemplateType.RENEWABLE_CLUSTER: + renewables_by_areas: t.MutableMapping[str, t.MutableMapping[str, RenewableClusterInput]] + renewables_by_areas = collections.defaultdict(dict) + for key, values in data.items(): + area_id, cluster_id = key.split(" / ") + renewables_by_areas[area_id][cluster_id] = RenewableClusterInput(**values) + renewables_map = self._renewable_manager.update_renewables_props(study, renewables_by_areas) + data = { + f"{area_id} / {cluster_id}": cluster.dict(by_alias=True) + for area_id, renewables_by_ids in renewables_map.items() + for cluster_id, cluster in renewables_by_ids.items() + } + return data + elif table_type == TableTemplateType.ST_STORAGE: + storages_by_areas: t.MutableMapping[str, t.MutableMapping[str, STStorageInput]] + storages_by_areas = collections.defaultdict(dict) + for key, values in data.items(): + area_id, cluster_id = key.split(" / ") + storages_by_areas[area_id][cluster_id] = STStorageInput(**values) + storages_map = self._st_storage_manager.update_storages_props(study, storages_by_areas) + data = { + f"{area_id} / {cluster_id}": cluster.dict(by_alias=True) + for area_id, storages_by_ids in storages_map.items() + for cluster_id, cluster in storages_by_ids.items() } return data + elif table_type == TableTemplateType.BINDING_CONSTRAINT: + bcs_by_ids = {key: ConstraintInput(**values) for key, values in data.items()} + bcs_map = self._binding_constraint_manager.update_binding_constraints(study, bcs_by_ids) + return {bc_id: bc.dict(by_alias=True, exclude={"id", "name", "terms"}) for bc_id, bc in bcs_map.items()} + else: # pragma: no cover + raise NotImplementedError(f"Table type {table_type} not implemented") + + def get_table_schema(self, table_type: TableTemplateType) -> JSON: + """ + Get the properties of the table columns which type is provided as a parameter. + + Args: + table_type: The type of the table. + + Returns: + JSON Schema which allows to know the name, title and type of each column. + """ + if table_type == TableTemplateType.AREA: + return self._area_manager.get_table_schema() + elif table_type == TableTemplateType.LINK: + return self._link_manager.get_table_schema() + elif table_type == TableTemplateType.THERMAL_CLUSTER: + return self._thermal_manager.get_table_schema() elif table_type == TableTemplateType.RENEWABLE_CLUSTER: - return {} + return self._renewable_manager.get_table_schema() elif table_type == TableTemplateType.ST_STORAGE: - return {} + return self._st_storage_manager.get_table_schema() elif table_type == TableTemplateType.BINDING_CONSTRAINT: - return {} + return self._binding_constraint_manager.get_table_schema() else: # pragma: no cover raise NotImplementedError(f"Table type {table_type} not implemented") diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/area.py b/antarest/study/storage/rawstudy/model/filesystem/config/area.py index 52c70540b0..458f1185aa 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/config/area.py +++ b/antarest/study/storage/rawstudy/model/filesystem/config/area.py @@ -184,7 +184,7 @@ def to_config(self) -> t.Mapping[str, t.Any]: >>> from antarest.study.storage.rawstudy.model.filesystem.config.area import AreaUI >>> from pprint import pprint - >>> ui = AreaUI(x=1148, y=144, color_rgb='#0080FF') + >>> ui = AreaUI(x=1148, y=144, color_rgb="#0080FF") >>> pprint(ui.to_config(), width=80) {'color_b': 255, 'color_g': 128, 'color_r': 0, 'x': 1148, 'y': 144} """ @@ -260,8 +260,8 @@ class UIProperties(IniProperties): ) @root_validator(pre=True) - def _validate_layers(cls, values: t.MutableMapping[str, t.Any]) -> t.Mapping[str, t.Any]: - # Defined the default style if missing + def _set_default_style(cls, values: t.MutableMapping[str, t.Any]) -> t.Mapping[str, t.Any]: + """Defined the default style if missing.""" style = values.get("style") if style is None: values["style"] = AreaUI() @@ -269,13 +269,19 @@ def _validate_layers(cls, values: t.MutableMapping[str, t.Any]) -> t.Mapping[str values["style"] = AreaUI(**style) else: values["style"] = AreaUI(**style.dict()) + return values - # Define the default layers if missing - layers = values.get("layers") - if layers is None: + @root_validator(pre=True) + def _set_default_layers(cls, values: t.MutableMapping[str, t.Any]) -> t.Mapping[str, t.Any]: + """Define the default layers if missing.""" + _layers = values.get("layers") + if _layers is None: values["layers"] = {0} + return values - # Define the default layer styles if missing + @root_validator(pre=True) + def _set_default_layer_styles(cls, values: t.MutableMapping[str, t.Any]) -> t.Mapping[str, t.Any]: + """Define the default layer styles if missing.""" layer_styles = values.get("layer_styles") if layer_styles is None: values["layer_styles"] = {0: AreaUI()} @@ -289,7 +295,10 @@ def _validate_layers(cls, values: t.MutableMapping[str, t.Any]) -> t.Mapping[str values["layer_styles"][key] = AreaUI(**style.dict()) else: raise TypeError(f"Invalid type for layer_styles: {type(layer_styles)}") + return values + @root_validator(pre=True) + def _validate_layers(cls, values: t.MutableMapping[str, t.Any]) -> t.Mapping[str, t.Any]: # Parse the `[ui]` section (if any) ui_section = values.pop("ui", {}) if ui_section: @@ -341,9 +350,10 @@ def to_config(self) -> t.Mapping[str, t.Mapping[str, t.Any]]: ... style=AreaUI(x=1148, y=144, color_rgb=(0, 128, 255)), ... layers={0, 7}, ... layer_styles={ - ... 6: AreaUI(x=1148, y=144, color_rgb='#C0A863'), + ... 6: AreaUI(x=1148, y=144, color_rgb="#C0A863"), ... 7: AreaUI(x=18, y=-22, color_rgb=(0, 128, 255)), - ... }) + ... }, + ... ) >>> pprint(ui.to_config(), width=80) {'layerColor': {'0': '230, 108, 44', '6': '192, 168, 99', '7': '0, 128, 255'}, 'layerX': {'0': 0, '6': 1148, '7': 18}, diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/cluster.py b/antarest/study/storage/rawstudy/model/filesystem/config/cluster.py index 4563a0d217..1c84019294 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/config/cluster.py +++ b/antarest/study/storage/rawstudy/model/filesystem/config/cluster.py @@ -3,13 +3,12 @@ In the near future, this set of classes may be used for solar, wind and hydro clusters. """ + import functools import typing as t from pydantic import BaseModel, Extra, Field -__all__ = ("ItemProperties", "ClusterProperties") - @functools.total_ordering class ItemProperties( @@ -69,10 +68,16 @@ class ClusterProperties(ItemProperties): # Activity status: # - True: the plant may generate. # - False: not yet commissioned, moth-balled, etc. - enabled: bool = Field(default=True, description="Activity status") + enabled: bool = Field(default=True, description="Activity status", title="Enabled") # noinspection SpellCheckingInspection - unit_count: int = Field(default=1, ge=1, description="Unit count", alias="unitcount") + unit_count: int = Field( + default=1, + ge=1, + description="Unit count", + alias="unitcount", + title="Unit Count", + ) # noinspection SpellCheckingInspection nominal_capacity: float = Field( @@ -80,6 +85,7 @@ class ClusterProperties(ItemProperties): ge=0, description="Nominal capacity (MW per unit)", alias="nominalcapacity", + title="Nominal Capacity", ) @property diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/links.py b/antarest/study/storage/rawstudy/model/filesystem/config/links.py index ddd5d9abb9..979c9a6d09 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/config/links.py +++ b/antarest/study/storage/rawstudy/model/filesystem/config/links.py @@ -1,6 +1,7 @@ """ Object model used to read and update link configuration. """ + import typing as t from pydantic import Field, root_validator, validator diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/renewable.py b/antarest/study/storage/rawstudy/model/filesystem/config/renewable.py index 57beb01b29..ed0716147a 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/config/renewable.py +++ b/antarest/study/storage/rawstudy/model/filesystem/config/renewable.py @@ -6,16 +6,6 @@ from antarest.study.storage.rawstudy.model.filesystem.config.cluster import ClusterProperties from antarest.study.storage.rawstudy.model.filesystem.config.identifier import IgnoreCaseIdentifier -__all__ = ( - "RenewableClusterGroup", - "RenewableConfig", - "RenewableConfigType", - "RenewableProperties", - "TimeSeriesInterpretation", - "create_renewable_config", - "RenewableClusterGroup", -) - class TimeSeriesInterpretation(EnumIgnoreCase): """ @@ -74,11 +64,13 @@ class RenewableProperties(ClusterProperties): """ group: RenewableClusterGroup = Field( + title="Renewable Cluster Group", default=RenewableClusterGroup.OTHER1, description="Renewable Cluster Group", ) ts_interpretation: TimeSeriesInterpretation = Field( + title="Time Series Interpretation", default=TimeSeriesInterpretation.POWER_GENERATION, description="Time series interpretation", alias="ts-interpretation", @@ -106,6 +98,22 @@ class RenewableConfig(RenewableProperties, IgnoreCaseIdentifier): RenewableConfigType = RenewableConfig +def get_renewable_config_cls(study_version: t.Union[str, int]) -> t.Type[RenewableConfig]: + """ + Retrieves the renewable configuration class based on the study version. + + Args: + study_version: The version of the study. + + Returns: + The renewable configuration class. + """ + version = int(study_version) + if version >= 810: + return RenewableConfig + raise ValueError(f"Unsupported study version {study_version}, required 810 or above.") + + def create_renewable_config(study_version: t.Union[str, int], **kwargs: t.Any) -> RenewableConfigType: """ Factory method to create a renewable configuration model. @@ -120,4 +128,5 @@ def create_renewable_config(study_version: t.Union[str, int], **kwargs: t.Any) - Raises: ValueError: If the study version is not supported. """ - return RenewableConfig(**kwargs) + cls = get_renewable_config_cls(study_version) + return cls(**kwargs) diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/st_storage.py b/antarest/study/storage/rawstudy/model/filesystem/config/st_storage.py index 58efc0ceb8..0fa8b306b8 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/config/st_storage.py +++ b/antarest/study/storage/rawstudy/model/filesystem/config/st_storage.py @@ -6,14 +6,6 @@ from antarest.study.storage.rawstudy.model.filesystem.config.cluster import ItemProperties from antarest.study.storage.rawstudy.model.filesystem.config.identifier import LowerCaseIdentifier -__all__ = ( - "STStorageGroup", - "STStorageProperties", - "STStorageConfig", - "STStorageConfigType", - "create_st_storage_config", -) - class STStorageGroup(EnumIgnoreCase): """ @@ -50,30 +42,35 @@ class STStorageProperties(ItemProperties): group: STStorageGroup = Field( STStorageGroup.OTHER1, description="Energy storage system group", + title="Short-Term Storage Group", ) injection_nominal_capacity: float = Field( 0, description="Injection nominal capacity (MW)", ge=0, alias="injectionnominalcapacity", + title="Injection Nominal Capacity", ) withdrawal_nominal_capacity: float = Field( 0, description="Withdrawal nominal capacity (MW)", ge=0, alias="withdrawalnominalcapacity", + title="Withdrawal Nominal Capacity", ) reservoir_capacity: float = Field( 0, description="Reservoir capacity (MWh)", ge=0, alias="reservoircapacity", + title="Reservoir Capacity", ) efficiency: float = Field( 1, description="Efficiency of the storage system (%)", ge=0, le=1, + title="Efficiency", ) # The `initial_level` value must be between 0 and 1, but the default value is 0. initial_level: float = Field( @@ -82,11 +79,13 @@ class STStorageProperties(ItemProperties): ge=0, le=1, alias="initiallevel", + title="Initial Level", ) initial_level_optim: bool = Field( False, description="Flag indicating if the initial level is optimized", alias="initialleveloptim", + title="Initial Level Optimization", ) @@ -119,6 +118,22 @@ class STStorageConfig(STStorageProperties, LowerCaseIdentifier): STStorageConfigType = STStorageConfig +def get_st_storage_config_cls(study_version: t.Union[str, int]) -> t.Type[STStorageConfig]: + """ + Retrieves the short-term storage configuration class based on the study version. + + Args: + study_version: The version of the study. + + Returns: + The short-term storage configuration class. + """ + version = int(study_version) + if version >= 860: + return STStorageConfig + raise ValueError(f"Unsupported study version: {version}") + + def create_st_storage_config(study_version: t.Union[str, int], **kwargs: t.Any) -> STStorageConfigType: """ Factory method to create a short-term storage configuration model. @@ -133,7 +148,5 @@ def create_st_storage_config(study_version: t.Union[str, int], **kwargs: t.Any) Raises: ValueError: If the study version is not supported. """ - version = int(study_version) - if version < 860: - raise ValueError(f"Unsupported study version: {version}") - return STStorageConfig(**kwargs) + cls = get_st_storage_config_cls(study_version) + return cls(**kwargs) diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/thermal.py b/antarest/study/storage/rawstudy/model/filesystem/config/thermal.py index f2a810025a..dcd0bc7729 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/config/thermal.py +++ b/antarest/study/storage/rawstudy/model/filesystem/config/thermal.py @@ -6,20 +6,6 @@ from antarest.study.storage.rawstudy.model.filesystem.config.cluster import ClusterProperties from antarest.study.storage.rawstudy.model.filesystem.config.identifier import IgnoreCaseIdentifier -__all__ = ( - "LawOption", - "LocalTSGenerationBehavior", - "Thermal860Config", - "Thermal870Config", - "Thermal870Properties", - "ThermalClusterGroup", - "ThermalConfig", - "ThermalConfigType", - "ThermalCostGeneration", - "ThermalProperties", - "create_thermal_config", -) - class LocalTSGenerationBehavior(EnumIgnoreCase): """ @@ -108,17 +94,20 @@ class ThermalProperties(ClusterProperties): group: ThermalClusterGroup = Field( default=ThermalClusterGroup.OTHER1, description="Thermal Cluster Group", + title="Thermal Cluster Group", ) gen_ts: LocalTSGenerationBehavior = Field( default=LocalTSGenerationBehavior.USE_GLOBAL, description="Time Series Generation Option", alias="gen-ts", + title="Time Series Generation", ) min_stable_power: float = Field( default=0.0, description="Min. Stable Power (MW)", alias="min-stable-power", + title="Min. Stable Power", ) min_up_time: int = Field( default=1, @@ -126,6 +115,7 @@ class ThermalProperties(ClusterProperties): le=168, description="Min. Up time (h)", alias="min-up-time", + title="Min. Up Time", ) min_down_time: int = Field( default=1, @@ -133,17 +123,20 @@ class ThermalProperties(ClusterProperties): le=168, description="Min. Down time (h)", alias="min-down-time", + title="Min. Down Time", ) must_run: bool = Field( default=False, description="Must run flag", alias="must-run", + title="Must Run", ) spinning: float = Field( default=0.0, ge=0, le=100, description="Spinning (%)", + title="Spinning", ) volatility_forced: float = Field( default=0.0, @@ -151,6 +144,7 @@ class ThermalProperties(ClusterProperties): le=1, description="Forced Volatility", alias="volatility.forced", + title="Forced Volatility", ) volatility_planned: float = Field( default=0.0, @@ -158,51 +152,60 @@ class ThermalProperties(ClusterProperties): le=1, description="Planned volatility", alias="volatility.planned", + title="Planned Volatility", ) law_forced: LawOption = Field( default=LawOption.UNIFORM, description="Forced Law (ts-generator)", alias="law.forced", + title="Forced Law", ) law_planned: LawOption = Field( default=LawOption.UNIFORM, description="Planned Law (ts-generator)", alias="law.planned", + title="Planned Law", ) marginal_cost: float = Field( default=0.0, ge=0, description="Marginal cost (euros/MWh)", alias="marginal-cost", + title="Marginal Cost", ) spread_cost: float = Field( default=0.0, ge=0, description="Spread (euros/MWh)", alias="spread-cost", + title="Spread Cost", ) fixed_cost: float = Field( default=0.0, ge=0, description="Fixed cost (euros/hour)", alias="fixed-cost", + title="Fixed Cost", ) startup_cost: float = Field( default=0.0, ge=0, description="Startup cost (euros/startup)", alias="startup-cost", + title="Startup Cost", ) market_bid_cost: float = Field( default=0.0, ge=0, description="Market bid cost (euros/MWh)", alias="market-bid-cost", + title="Market Bid Cost", ) co2: float = Field( default=0.0, ge=0, description="Emission rate of CO2 (t/MWh)", + title="Emission rate of CO2", ) @@ -215,62 +218,74 @@ class Thermal860Properties(ThermalProperties): default=0.0, ge=0, description="Emission rate of NH3 (t/MWh)", + title="Emission rate of NH3", ) so2: float = Field( default=0.0, ge=0, description="Emission rate of SO2 (t/MWh)", + title="Emission rate of SO2", ) nox: float = Field( default=0.0, ge=0, description="Emission rate of NOX (t/MWh)", + title="Emission rate of NOX", ) pm2_5: float = Field( default=0.0, ge=0, description="Emission rate of PM 2.5 (t/MWh)", + title="Emission rate of PM 2.5", alias="pm2_5", ) pm5: float = Field( default=0.0, ge=0, description="Emission rate of PM 5 (t/MWh)", + title="Emission rate of PM 5", ) pm10: float = Field( default=0.0, ge=0, description="Emission rate of PM 10 (t/MWh)", + title="Emission rate of PM 10", ) nmvoc: float = Field( default=0.0, ge=0, description="Emission rate of NMVOC (t/MWh)", + title="Emission rate of NMVOC", ) op1: float = Field( default=0.0, ge=0, description="Emission rate of pollutant 1 (t/MWh)", + title="Emission rate of pollutant 1", ) op2: float = Field( default=0.0, ge=0, description="Emission rate of pollutant 2 (t/MWh)", + title="Emission rate of pollutant 2", ) op3: float = Field( default=0.0, ge=0, description="Emission rate of pollutant 3 (t/MWh)", + title="Emission rate of pollutant 3", ) op4: float = Field( default=0.0, ge=0, description="Emission rate of pollutant 4 (t/MWh)", + title="Emission rate of pollutant 4", ) op5: float = Field( default=0.0, ge=0, description="Emission rate of pollutant 5 (t/MWh)", + title="Emission rate of pollutant 5", ) @@ -284,18 +299,21 @@ class Thermal870Properties(Thermal860Properties): default=ThermalCostGeneration.SET_MANUALLY, alias="costgeneration", description="Cost generation option", + title="Cost Generation", ) efficiency: float = Field( default=100.0, ge=0, le=100, description="Efficiency (%)", + title="Efficiency", ) # Even if `variableomcost` is a cost it could be negative. variable_o_m_cost: float = Field( default=0.0, description="Operating and Maintenance Cost (€/MWh)", alias="variableomcost", + title="Variable O&M Cost", ) @@ -375,6 +393,25 @@ class Thermal870Config(Thermal870Properties, IgnoreCaseIdentifier): ThermalConfigType = t.Union[Thermal870Config, Thermal860Config, ThermalConfig] +def get_thermal_config_cls(study_version: t.Union[str, int]) -> t.Type[ThermalConfigType]: + """ + Retrieves the thermal configuration class based on the study version. + + Args: + study_version: The version of the study. + + Returns: + The thermal configuration class. + """ + version = int(study_version) + if version >= 870: + return Thermal870Config + elif version == 860: + return Thermal860Config + else: + return ThermalConfig + + def create_thermal_config(study_version: t.Union[str, int], **kwargs: t.Any) -> ThermalConfigType: """ Factory method to create a thermal configuration model. @@ -389,10 +426,5 @@ def create_thermal_config(study_version: t.Union[str, int], **kwargs: t.Any) -> Raises: ValueError: If the study version is not supported. """ - version = int(study_version) - if version >= 870: - return Thermal870Config(**kwargs) - elif version == 860: - return Thermal860Config(**kwargs) - else: - return ThermalConfig(**kwargs) + cls = get_thermal_config_cls(study_version) + return cls(**kwargs) diff --git a/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py b/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py index 5510f00c3d..4767104e3a 100644 --- a/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py +++ b/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py @@ -83,6 +83,25 @@ class BindingConstraintProperties870(BindingConstraintProperties): group: t.Optional[str] = None +BindingConstraintPropertiesType = t.Union[BindingConstraintProperties870, BindingConstraintProperties] + + +def get_binding_constraint_config_cls(study_version: t.Union[str, int]) -> t.Type[BindingConstraintPropertiesType]: + """ + Retrieves the short-term storage configuration class based on the study version. + + Args: + study_version: The version of the study. + + Returns: + The short-term storage configuration class. + """ + version = int(study_version) + if version >= 870: + return BindingConstraintProperties870 + return BindingConstraintProperties + + class BindingConstraintMatrices(BaseModel, extra=Extra.forbid, allow_population_by_field_name=True): """ Class used to store the matrices of a binding constraint. diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index c38e68af59..4898f30316 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -10,7 +10,7 @@ from antarest.core.config import Config from antarest.core.jwt import JWTUser -from antarest.core.model import StudyPermissionType +from antarest.core.model import JSON, StudyPermissionType from antarest.core.requests import RequestParameters from antarest.core.utils.utils import sanitize_uuid from antarest.core.utils.web import APITag @@ -862,6 +862,19 @@ def get_table_mode( table_data = study_service.table_mode_manager.get_table_data(study, table_type, column_list) return table_data + @bp.get( + path="/table-schema/{table_type}", + tags=[APITag.study_data], + summary="Get table schema", + ) + def get_table_schema( + table_type: TableTemplateType, + current_user: JWTUser = Depends(auth.get_current_user), + ) -> JSON: + logger.info("Getting table schema", extra={"user": current_user.id}) + model_schema = study_service.table_mode_manager.get_table_schema(table_type) + return model_schema + @bp.put( path="/studies/{uuid}/table-mode/{table_type}", tags=[APITag.study_data], @@ -879,7 +892,7 @@ def set_table_mode( ) params = RequestParameters(user=current_user) study = study_service.check_study_access(uuid, StudyPermissionType.WRITE, params) - table_data = study_service.table_mode_manager.set_table_data(study, table_type, data) + table_data = study_service.table_mode_manager.update_table_data(study, table_type, data) return table_data @bp.post( diff --git a/tests/integration/study_data_blueprint/test_binding_constraints.py b/tests/integration/study_data_blueprint/test_binding_constraints.py index fbf343d57b..04e80b7101 100644 --- a/tests/integration/study_data_blueprint/test_binding_constraints.py +++ b/tests/integration/study_data_blueprint/test_binding_constraints.py @@ -557,7 +557,7 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st # Delete a fake binding constraint res = client.delete(f"/v1/studies/{study_id}/bindingconstraints/fake_bc", headers=user_headers) assert res.status_code == 404, res.json() - assert res.json()["exception"] == "BindingConstraintNotFoundError" + assert res.json()["exception"] == "BindingConstraintNotFound" assert res.json()["description"] == "Binding constraint 'fake_bc' not found" # Add a group before v8.7 diff --git a/tests/integration/study_data_blueprint/test_table_mode.py b/tests/integration/study_data_blueprint/test_table_mode.py index bf45d3407f..f4127af644 100644 --- a/tests/integration/study_data_blueprint/test_table_mode.py +++ b/tests/integration/study_data_blueprint/test_table_mode.py @@ -56,6 +56,27 @@ def test_lifecycle__nominal( # Table Mode - Area # ================= + # Get the schema of the areas table + res = client.get( + f"/v1/table-schema/areas", + headers=user_headers, + ) + assert res.status_code == 200, res.json() + actual = res.json() + assert set(actual["properties"]) == { + "adequacyPatchMode", + "averageSpilledEnergyCost", + "averageUnsuppliedEnergyCost", + "dispatchableHydroPower", + "filterSynthesis", + "filterYearByYear", + "nonDispatchablePower", + "otherDispatchablePower", + "spreadSpilledEnergyCost", + "spreadUnsuppliedEnergyCost", + } + + # fixme: this test is not working because the table-mode is not implemented for areas # res = client.put( # f"/v1/studies/{study_id}/table-mode/areas", # headers=user_headers, @@ -132,6 +153,27 @@ def test_lifecycle__nominal( # Table Mode - Links # ================== + # Get the schema of the links table + res = client.get( + f"/v1/table-schema/links", + headers=user_headers, + ) + assert res.status_code == 200, res.json() + actual = res.json() + assert set(actual["properties"]) == { + "assetType", + "colorRgb", + "displayComments", + "filterSynthesis", + "filterYearByYear", + "hurdlesCost", + "linkStyle", + "linkWidth", + "loopFlow", + "transmissionCapacities", + "usePhaseShifter", + } + res = client.put( f"/v1/studies/{study_id}/table-mode/links", headers=user_headers, @@ -217,6 +259,53 @@ def test_lifecycle__nominal( # Table Mode - Thermal Clusters # ============================= + # Get the schema of the thermals table + res = client.get( + f"/v1/table-schema/thermals", + headers=user_headers, + ) + assert res.status_code == 200, res.json() + actual = res.json() + assert set(actual["properties"]) == { + "co2", + "costGeneration", + "efficiency", + "enabled", + "fixedCost", + "genTs", + "group", + "id", + "lawForced", + "lawPlanned", + "marginalCost", + "marketBidCost", + "minDownTime", + "minStablePower", + "minUpTime", + "mustRun", + "name", + "nh3", + "nmvoc", + "nominalCapacity", + "nox", + "op1", + "op2", + "op3", + "op4", + "op5", + "pm10", + "pm25", + "pm5", + "so2", + "spinning", + "spreadCost", + "startupCost", + "unitCount", + "variableOMCost", + "volatilityForced", + "volatilityPlanned", + } + res = client.put( f"/v1/studies/{study_id}/table-mode/thermals", headers=user_headers, @@ -238,82 +327,82 @@ def test_lifecycle__nominal( assert res.status_code == 200, res.json() expected_thermals = { "de / 01_solar": { - "co2": 0.0, + "co2": 0, "costGeneration": None, "efficiency": None, "enabled": True, - "fixedCost": 0.0, + "fixedCost": 0, "genTs": "use global", "group": "Other 2", "id": "01_solar", "lawForced": "uniform", "lawPlanned": "uniform", - "marginalCost": 10.0, - "marketBidCost": 10.0, + "marginalCost": 10, + "marketBidCost": 10, "minDownTime": 1, - "minStablePower": 0.0, + "minStablePower": 0, "minUpTime": 1, "mustRun": False, "name": "01_solar", - "nh3": 0.0, - "nmvoc": 0.0, - "nominalCapacity": 500000.0, - "nox": 0.0, - "op1": 0.0, - "op2": 0.0, - "op3": 0.0, - "op4": 0.0, - "op5": 0.0, - "pm10": 0.0, - "pm25": 0.0, - "pm5": 0.0, + "nh3": 0, + "nmvoc": 0, + "nominalCapacity": 500000, + "nox": 0, + "op1": 0, + "op2": 0, + "op3": 0, + "op4": 0, + "op5": 0, + "pm10": 0, + "pm25": 0, + "pm5": 0, "so2": 8.25, - "spinning": 0.0, - "spreadCost": 0.0, - "startupCost": 0.0, + "spinning": 0, + "spreadCost": 0, + "startupCost": 0, "unitCount": 17, "variableOMCost": None, - "volatilityForced": 0.0, - "volatilityPlanned": 0.0, + "volatilityForced": 0, + "volatilityPlanned": 0, }, "de / 02_wind_on": { - "co2": 123.0, + "co2": 123, "costGeneration": None, "efficiency": None, "enabled": True, - "fixedCost": 0.0, + "fixedCost": 0, "genTs": "use global", "group": "Nuclear", "id": "02_wind_on", "lawForced": "uniform", "lawPlanned": "uniform", - "marginalCost": 20.0, - "marketBidCost": 20.0, + "marginalCost": 20, + "marketBidCost": 20, "minDownTime": 1, - "minStablePower": 0.0, + "minStablePower": 0, "minUpTime": 1, "mustRun": False, "name": "02_wind_on", - "nh3": 0.0, - "nmvoc": 0.0, - "nominalCapacity": 314159.0, - "nox": 0.0, - "op1": 0.0, - "op2": 0.0, - "op3": 0.0, - "op4": 0.0, - "op5": 0.0, - "pm10": 0.0, - "pm25": 0.0, - "pm5": 0.0, - "so2": 0.0, - "spinning": 0.0, - "spreadCost": 0.0, - "startupCost": 0.0, + "nh3": 0, + "nmvoc": 0, + "nominalCapacity": 314159, + "nox": 0, + "op1": 0, + "op2": 0, + "op3": 0, + "op4": 0, + "op5": 0, + "pm10": 0, + "pm25": 0, + "pm5": 0, + "so2": 0, + "spinning": 0, + "spreadCost": 0, + "startupCost": 0, "unitCount": 15, "variableOMCost": None, - "volatilityForced": 0.0, - "volatilityPlanned": 0.0, + "volatilityForced": 0, + "volatilityPlanned": 0, }, } assert res.json()["de / 01_solar"] == expected_thermals["de / 01_solar"] @@ -326,42 +415,42 @@ def test_lifecycle__nominal( ) assert res.status_code == 200, res.json() expected = { - "de / 01_solar": {"group": "Other 2", "nominalCapacity": 500000.0, "so2": 8.25, "unitCount": 17}, - "de / 02_wind_on": {"group": "Nuclear", "nominalCapacity": 314159.0, "so2": 0.0, "unitCount": 15}, - "de / 03_wind_off": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "de / 04_res": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "de / 05_nuclear": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "de / 06_coal": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "de / 07_gas": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "de / 08_non-res": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "de / 09_hydro_pump": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "es / 01_solar": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "es / 02_wind_on": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "es / 03_wind_off": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "es / 04_res": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "es / 05_nuclear": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "es / 06_coal": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "es / 07_gas": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "es / 08_non-res": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "es / 09_hydro_pump": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "fr / 01_solar": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "fr / 02_wind_on": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "fr / 03_wind_off": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "fr / 04_res": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "fr / 05_nuclear": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "fr / 06_coal": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "fr / 07_gas": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "fr / 08_non-res": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "fr / 09_hydro_pump": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "it / 01_solar": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "it / 02_wind_on": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "it / 03_wind_off": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "it / 04_res": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "it / 05_nuclear": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "it / 06_coal": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "it / 07_gas": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "it / 08_non-res": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, - "it / 09_hydro_pump": {"group": "Other 1", "nominalCapacity": 1000000.0, "so2": 0.0, "unitCount": 1}, + "de / 01_solar": {"group": "Other 2", "nominalCapacity": 500000, "so2": 8.25, "unitCount": 17}, + "de / 02_wind_on": {"group": "Nuclear", "nominalCapacity": 314159, "so2": 0, "unitCount": 15}, + "de / 03_wind_off": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "de / 04_res": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "de / 05_nuclear": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "de / 06_coal": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "de / 07_gas": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "de / 08_non-res": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "de / 09_hydro_pump": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "es / 01_solar": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "es / 02_wind_on": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "es / 03_wind_off": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "es / 04_res": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "es / 05_nuclear": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "es / 06_coal": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "es / 07_gas": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "es / 08_non-res": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "es / 09_hydro_pump": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "fr / 01_solar": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "fr / 02_wind_on": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "fr / 03_wind_off": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "fr / 04_res": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "fr / 05_nuclear": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "fr / 06_coal": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "fr / 07_gas": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "fr / 08_non-res": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "fr / 09_hydro_pump": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "it / 01_solar": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "it / 02_wind_on": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "it / 03_wind_off": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "it / 04_res": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "it / 05_nuclear": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "it / 06_coal": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "it / 07_gas": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "it / 08_non-res": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, + "it / 09_hydro_pump": {"group": "Other 1", "nominalCapacity": 1000000, "so2": 0, "unitCount": 1}, } actual = res.json() assert actual == expected @@ -429,6 +518,34 @@ def test_lifecycle__nominal( ) res.raise_for_status() + # Get the schema of the renewables table + res = client.get( + f"/v1/table-schema/renewables", + headers=user_headers, + ) + assert res.status_code == 200, res.json() + actual = res.json() + assert set(actual["properties"]) == { + "enabled", + "group", + "id", + "name", + "nominalCapacity", + "tsInterpretation", + "unitCount", + } + + # Update some generators using the table mode + res = client.put( + f"/v1/studies/{study_id}/table-mode/renewables", + headers=user_headers, + json={ + "fr / Dieppe": {"enabled": False}, + "fr / La Rochelle": {"enabled": True, "nominalCapacity": 3.1, "unitCount": 2}, + "it / Pouilles": {"group": "Wind Onshore"}, + }, + ) + res = client.get( f"/v1/studies/{study_id}/table-mode/renewables", headers=user_headers, @@ -436,10 +553,10 @@ def test_lifecycle__nominal( ) assert res.status_code == 200, res.json() expected = { - "fr / Dieppe": {"enabled": True, "group": "Wind Offshore", "nominalCapacity": 8, "unitCount": 62}, - "fr / La Rochelle": {"enabled": True, "group": "Solar PV", "nominalCapacity": 2.1, "unitCount": 1}, + "fr / Dieppe": {"enabled": False, "group": "Wind Offshore", "nominalCapacity": 8, "unitCount": 62}, + "fr / La Rochelle": {"enabled": True, "group": "Solar PV", "nominalCapacity": 3.1, "unitCount": 2}, "fr / Oleron": {"enabled": True, "group": "Wind Offshore", "nominalCapacity": 15, "unitCount": 70}, - "it / Pouilles": {"enabled": False, "group": "Wind Offshore", "nominalCapacity": 11, "unitCount": 40}, + "it / Pouilles": {"enabled": False, "group": "Wind Onshore", "nominalCapacity": 11, "unitCount": 40}, "it / Sardaigne": {"enabled": True, "group": "Wind Offshore", "nominalCapacity": 12, "unitCount": 86}, "it / Sicile": {"enabled": True, "group": "Solar PV", "nominalCapacity": 1.8, "unitCount": 1}, } @@ -449,6 +566,25 @@ def test_lifecycle__nominal( # Table Mode - Short Term Storage # =============================== + # Get the schema of the short-term storages table + res = client.get( + f"/v1/table-schema/st-storages", + headers=user_headers, + ) + assert res.status_code == 200, res.json() + actual = res.json() + assert set(actual["properties"]) == { + "efficiency", + "group", + "id", + "initialLevel", + "initialLevelOptim", + "injectionNominalCapacity", + "name", + "reservoirCapacity", + "withdrawalNominalCapacity", + } + # Prepare data for short-term storage tests storage_by_country = { "fr": { @@ -499,8 +635,67 @@ def test_lifecycle__nominal( ) res.raise_for_status() + # Update some generators using the table mode + res = client.put( + f"/v1/studies/{study_id}/table-mode/st-storages", + headers=user_headers, + json={ + "fr / siemens": {"injectionNominalCapacity": 1550, "withdrawalNominalCapacity": 1550}, + "fr / tesla": {"efficiency": 0.75, "initialLevel": 0.89, "initialLevelOptim": False}, + "it / storage3": {"group": "Pondage"}, + }, + ) + assert res.status_code == 200, res.json() + actual = res.json() + assert actual == { + "fr / siemens": { + "efficiency": 1, + "group": "Battery", + "id": "siemens", + "initialLevel": 0.5, + "initialLevelOptim": False, + "injectionNominalCapacity": 1550, + "name": "Siemens", + "reservoirCapacity": 1500, + "withdrawalNominalCapacity": 1550, + }, + "fr / tesla": { + "efficiency": 0.75, + "group": "Battery", + "id": "tesla", + "initialLevel": 0.89, + "initialLevelOptim": False, + "injectionNominalCapacity": 1200, + "name": "Tesla", + "reservoirCapacity": 1200, + "withdrawalNominalCapacity": 1200, + }, + "it / storage3": { + "efficiency": 1, + "group": "Pondage", + "id": "storage3", + "initialLevel": 1, + "initialLevelOptim": False, + "injectionNominalCapacity": 1234, + "name": "storage3", + "reservoirCapacity": 1357, + "withdrawalNominalCapacity": 1020, + }, + "it / storage4": { + "efficiency": 1, + "group": "PSP_open", + "id": "storage4", + "initialLevel": 0.5, + "initialLevelOptim": True, + "injectionNominalCapacity": 567, + "name": "storage4", + "reservoirCapacity": 500, + "withdrawalNominalCapacity": 456, + }, + } + res = client.get( - f"/v1/studies/{study_id}/table-mode/storages", + f"/v1/studies/{study_id}/table-mode/st-storages", headers=user_headers, params={ "columns": ",".join( @@ -518,31 +713,31 @@ def test_lifecycle__nominal( expected = { "fr / siemens": { "group": "Battery", - "injectionNominalCapacity": 1500, + "injectionNominalCapacity": 1550, "reservoirCapacity": 1500, - "withdrawalNominalCapacity": 1500, "unknowColumn": None, + "withdrawalNominalCapacity": 1550, }, "fr / tesla": { "group": "Battery", "injectionNominalCapacity": 1200, "reservoirCapacity": 1200, - "withdrawalNominalCapacity": 1200, "unknowColumn": None, + "withdrawalNominalCapacity": 1200, }, "it / storage3": { - "group": "PSP_open", + "group": "Pondage", "injectionNominalCapacity": 1234, "reservoirCapacity": 1357, - "withdrawalNominalCapacity": 1020, "unknowColumn": None, + "withdrawalNominalCapacity": 1020, }, "it / storage4": { "group": "PSP_open", "injectionNominalCapacity": 567, "reservoirCapacity": 500, - "withdrawalNominalCapacity": 456, "unknowColumn": None, + "withdrawalNominalCapacity": 456, }, } actual = res.json() @@ -593,15 +788,65 @@ def test_lifecycle__nominal( ) assert res.status_code == 200, res.json() + # Get the schema of the binding constraints table res = client.get( - f"/v1/studies/{study_id}/table-mode/constraints", + f"/v1/table-schema/binding-constraints", + headers=user_headers, + ) + assert res.status_code == 200, res.json() + actual = res.json() + assert set(actual["properties"]) == { + "comments", + "enabled", + "filterSynthesis", + "filterYearByYear", + "group", + "id", + "name", + "operator", + "terms", + "timeStep", + } + + # Update some binding constraints using the table mode + res = client.put( + f"/v1/studies/{study_id}/table-mode/binding-constraints", + headers=user_headers, + json={ + "binding constraint 1": {"comments": "Hello World!", "enabled": True}, + "binding constraint 2": {"filterSynthesis": "hourly", "filterYearByYear": "hourly", "operator": "both"}, + }, + ) + assert res.status_code == 200, res.json() + actual = res.json() + assert actual == { + "binding constraint 1": { + "comments": "Hello World!", + "enabled": True, + "filterSynthesis": "", + "filterYearByYear": "", + "operator": "less", + "timeStep": "hourly", + }, + "binding constraint 2": { + "comments": "This is a binding constraint", + "enabled": False, + "filterSynthesis": "hourly", + "filterYearByYear": "hourly", + "operator": "both", + "timeStep": "daily", + }, + } + + res = client.get( + f"/v1/studies/{study_id}/table-mode/binding-constraints", headers=user_headers, params={"columns": ""}, ) assert res.status_code == 200, res.json() expected = { "binding constraint 1": { - "comments": "", + "comments": "Hello World!", "enabled": True, "filterSynthesis": "", "filterYearByYear": "", @@ -611,9 +856,9 @@ def test_lifecycle__nominal( "binding constraint 2": { "comments": "This is a binding constraint", "enabled": False, - "filterSynthesis": "hourly, daily, weekly", - "filterYearByYear": "", - "operator": "greater", + "filterSynthesis": "hourly", + "filterYearByYear": "hourly", + "operator": "both", "timeStep": "daily", }, } diff --git a/tests/storage/business/test_arealink_manager.py b/tests/storage/business/test_arealink_manager.py index 6d664d329b..af01133b8c 100644 --- a/tests/storage/business/test_arealink_manager.py +++ b/tests/storage/business/test_arealink_manager.py @@ -195,7 +195,7 @@ def test_area_crud(empty_study: FileStudy, matrix_service: SimpleMatrixService): }, { "target": "input/areas/test/ui/layerColor/0", - "data": "255 , 0 , 100", + "data": "255,0,100", }, ], ), diff --git a/tests/study/business/areas/test_st_storage_management.py b/tests/study/business/areas/test_st_storage_management.py index c7297e4694..2031a0d164 100644 --- a/tests/study/business/areas/test_st_storage_management.py +++ b/tests/study/business/areas/test_st_storage_management.py @@ -134,7 +134,10 @@ def test_get_all_storages__nominal_case( all_storages = manager.get_all_storages_props(study) # Check - actual = {area_id: [form.dict(by_alias=True) for form in forms] for area_id, forms in all_storages.items()} + actual = { + area_id: [form.dict(by_alias=True) for form in clusters_by_ids.values()] + for area_id, clusters_by_ids in all_storages.items() + } expected = { "west": [ { @@ -171,7 +174,6 @@ def test_get_all_storages__nominal_case( "initialLevelOptim": False, }, ], - "east": [], } assert actual == expected