diff --git a/src/antares/craft/exceptions/exceptions.py b/src/antares/craft/exceptions/exceptions.py index 26de1af1..554b4882 100644 --- a/src/antares/craft/exceptions/exceptions.py +++ b/src/antares/craft/exceptions/exceptions.py @@ -205,6 +205,12 @@ def __init__(self, constraint_name: str, message: str) -> None: super().__init__(self.message) +class ConstraintDoesNotExistError(Exception): + def __init__(self, constraint_name: str) -> None: + self.message = f"The binding constraint {constraint_name} doesn't exist: " + super().__init__(self.message) + + class ConstraintMatrixUpdateError(Exception): def __init__(self, constraint_name: str, matrix_name: str, message: str) -> None: self.message = f"Could not update matrix {matrix_name} for binding constraint {constraint_name}: " + message @@ -238,6 +244,12 @@ def __init__(self, constraint_id: str, term_id: str, message: str) -> None: super().__init__(self.message) +class ConstraintTermEditionError(Exception): + def __init__(self, constraint_id: str, term_id: str, message: str) -> None: + self.message = f"Could not update the term {term_id} of the binding constraint {constraint_id}: " + message + super().__init__(self.message) + + class StudyCreationError(Exception): def __init__(self, study_name: str, message: str) -> None: self.message = f"Could not create the study {study_name}: " + message diff --git a/src/antares/craft/model/binding_constraint.py b/src/antares/craft/model/binding_constraint.py index f87375ac..a1753c39 100644 --- a/src/antares/craft/model/binding_constraint.py +++ b/src/antares/craft/model/binding_constraint.py @@ -9,17 +9,14 @@ # SPDX-License-Identifier: MPL-2.0 # # This file is part of the Antares project. - +from dataclasses import dataclass from enum import Enum -from typing import Any, Optional, Union +from typing import Optional, Union import pandas as pd from antares.craft.service.base_services import BaseBindingConstraintService -from antares.craft.tools.all_optional_meta import all_optional_model from antares.craft.tools.contents_tool import EnumIgnoreCase, transform_name_to_id -from pydantic import BaseModel, Field, model_validator -from pydantic.alias_generators import to_camel class BindingConstraintFrequency(EnumIgnoreCase): @@ -41,20 +38,8 @@ class ConstraintMatrixName(Enum): GREATER_TERM = "gt" -class TermOperators(BaseModel): - weight: Optional[float] = None - offset: Optional[int] = None - - def weight_offset(self) -> str: - if self.offset is not None: - # Rounded the weight to 6 decimals to be in line with other floats in the ini files - weight_offset = f"{(self.weight if self.weight is not None else 0):.6f}%{self.offset}" - else: - weight_offset = f"{self.weight if self.weight is not None else 0}" - return weight_offset - - -class LinkData(BaseModel): +@dataclass +class LinkData: """ DTO for a constraint term on a link between two areas. """ @@ -63,7 +48,8 @@ class LinkData(BaseModel): area2: str -class ClusterData(BaseModel): +@dataclass +class ClusterData: """ DTO for a constraint term on a cluster in an area. """ @@ -72,40 +58,53 @@ class ClusterData(BaseModel): cluster: str -class ConstraintTerm(TermOperators): +@dataclass +class ConstraintTermData: data: Union[LinkData, ClusterData] - id: str = Field(init=False) - - @model_validator(mode="before") - def fill_id(cls, v: dict[str, Any]) -> dict[str, Any]: - v["id"] = cls.generate_id(v["data"]) - return v - - @classmethod - def generate_id(cls, data: Union[dict[str, str], LinkData, ClusterData]) -> str: - if isinstance(data, dict): - if "area1" in data: - return "%".join(sorted((data["area1"].lower(), data["area2"].lower()))) - return ".".join((data["area"].lower(), data["cluster"].lower())) - elif isinstance(data, LinkData): - return "%".join(sorted((data.area1.lower(), data.area2.lower()))) - return ".".join((data.area.lower(), data.cluster.lower())) - - -class DefaultBindingConstraintProperties(BaseModel, extra="forbid", populate_by_name=True, alias_generator=to_camel): - """Default properties for binding constraints - - Attributes: - enabled (bool): True - time_step (BindingConstraintFrequency): BindingConstraintFrequency.HOURLY - operator (BindingConstraintOperator): BindingConstraintOperator.LESS - comments (str): None - filter_year_by_year (str): "hourly" - filter_synthesis (str): "hourly" - group (str): "default" - """ + @property + def id(self) -> str: + if isinstance(self.data, LinkData): + return "%".join(sorted((self.data.area1.lower(), self.data.area2.lower()))) + return ".".join((self.data.area.lower(), self.data.cluster.lower())) + + @staticmethod + def from_dict(input: dict[str, str]) -> Union[LinkData, ClusterData]: + if "area1" in input: + return LinkData(area1=input["area1"], area2=input["area2"]) + elif "cluster" in input: + return ClusterData(area=input["area"], cluster=input["cluster"]) + raise ValueError(f"Dict {input} couldn't be serialized as a ConstraintTermData object") + +@dataclass +class ConstraintTermUpdate(ConstraintTermData): + weight: Optional[float] = None + offset: Optional[int] = None + + +@dataclass +class ConstraintTerm(ConstraintTermData): + weight: float = 1 + offset: int = 0 + + def weight_offset(self) -> str: + return f"{self.weight}%{self.offset}" if self.offset != 0 else f"{self.weight}" + + +@dataclass +class BindingConstraintPropertiesUpdate: + enabled: Optional[bool] = None + time_step: Optional[BindingConstraintFrequency] = None + operator: Optional[BindingConstraintOperator] = None + comments: Optional[str] = None + filter_year_by_year: Optional[str] = None + filter_synthesis: Optional[str] = None + group: Optional[str] = None + + +@dataclass +class BindingConstraintProperties: enabled: bool = True time_step: BindingConstraintFrequency = BindingConstraintFrequency.HOURLY operator: BindingConstraintOperator = BindingConstraintOperator.LESS @@ -115,11 +114,6 @@ class DefaultBindingConstraintProperties(BaseModel, extra="forbid", populate_by_ group: str = "default" -@all_optional_model -class BindingConstraintProperties(DefaultBindingConstraintProperties): - pass - - class BindingConstraint: def __init__( self, @@ -146,23 +140,24 @@ def id(self) -> str: def properties(self) -> BindingConstraintProperties: return self._properties - @properties.setter - def properties(self, new_properties: BindingConstraintProperties) -> None: - self._properties = new_properties - def get_terms(self) -> dict[str, ConstraintTerm]: return self._terms def add_terms(self, terms: list[ConstraintTerm]) -> None: - added_terms = self._binding_constraint_service.add_constraint_terms(self, terms) - for term in added_terms: + self._binding_constraint_service.add_constraint_terms(self, terms) + for term in terms: self._terms[term.id] = term def delete_term(self, term: ConstraintTerm) -> None: self._binding_constraint_service.delete_binding_constraint_term(self.id, term.id) self._terms.pop(term.id) - def update_properties(self, properties: BindingConstraintProperties) -> None: + def update_term(self, term: ConstraintTermUpdate) -> None: + existing_term = self._terms[term.id] + new_term = self._binding_constraint_service.update_binding_constraint_term(self.id, term, existing_term) + self._terms[term.id] = new_term + + def update_properties(self, properties: BindingConstraintPropertiesUpdate) -> None: new_properties = self._binding_constraint_service.update_binding_constraint_properties(self, properties) self._properties = new_properties diff --git a/src/antares/craft/service/api_services/models/binding_constraint.py b/src/antares/craft/service/api_services/models/binding_constraint.py new file mode 100644 index 00000000..6d76af90 --- /dev/null +++ b/src/antares/craft/service/api_services/models/binding_constraint.py @@ -0,0 +1,52 @@ +# Copyright (c) 2024, RTE (https://www.rte-france.com) +# +# See AUTHORS.txt +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# SPDX-License-Identifier: MPL-2.0 +# +# This file is part of the Antares project. + +from dataclasses import asdict +from typing import Union + +from antares.craft.model.binding_constraint import ( + BindingConstraintFrequency, + BindingConstraintOperator, + BindingConstraintProperties, + BindingConstraintPropertiesUpdate, +) +from antares.craft.service.api_services.models.base_model import APIBaseModel +from antares.craft.tools.all_optional_meta import all_optional_model + +BindingConstraintPropertiesType = Union[BindingConstraintProperties, BindingConstraintPropertiesUpdate] + + +@all_optional_model +class BindingConstraintPropertiesAPI(APIBaseModel): + enabled: bool + time_step: BindingConstraintFrequency + operator: BindingConstraintOperator + comments: str + filter_year_by_year: str + filter_synthesis: str + group: str + + @staticmethod + def from_user_model(user_class: BindingConstraintPropertiesType) -> "BindingConstraintPropertiesAPI": + user_dict = asdict(user_class) + return BindingConstraintPropertiesAPI.model_validate(user_dict) + + def to_user_model(self) -> BindingConstraintProperties: + return BindingConstraintProperties( + enabled=self.enabled, + time_step=self.time_step, + operator=self.operator, + comments=self.comments, + filter_year_by_year=self.filter_year_by_year, + filter_synthesis=self.filter_synthesis, + group=self.group, + ) diff --git a/src/antares/craft/service/api_services/binding_constraint_api.py b/src/antares/craft/service/api_services/services/binding_constraint.py similarity index 71% rename from src/antares/craft/service/api_services/binding_constraint_api.py rename to src/antares/craft/service/api_services/services/binding_constraint.py index a0fd83bc..8c49bb3d 100644 --- a/src/antares/craft/service/api_services/binding_constraint_api.py +++ b/src/antares/craft/service/api_services/services/binding_constraint.py @@ -9,9 +9,9 @@ # SPDX-License-Identifier: MPL-2.0 # # This file is part of the Antares project. - +from dataclasses import asdict from pathlib import PurePosixPath -from typing import Optional +from typing import Any, Optional import pandas as pd @@ -26,13 +26,18 @@ ConstraintRetrievalError, ConstraintTermAdditionError, ConstraintTermDeletionError, + ConstraintTermEditionError, ) from antares.craft.model.binding_constraint import ( BindingConstraint, BindingConstraintProperties, + BindingConstraintPropertiesUpdate, ConstraintMatrixName, ConstraintTerm, + ConstraintTermData, + ConstraintTermUpdate, ) +from antares.craft.service.api_services.models.binding_constraint import BindingConstraintPropertiesAPI from antares.craft.service.api_services.utils import get_matrix from antares.craft.service.base_services import BaseBindingConstraintService from typing_extensions import override @@ -77,7 +82,8 @@ def create_binding_constraint( try: body = {"name": name} if properties: - camel_properties = properties.model_dump(mode="json", by_alias=True, exclude_none=True) + api_model = BindingConstraintPropertiesAPI.from_user_model(properties) + camel_properties = api_model.model_dump(mode="json", by_alias=True, exclude_none=True) body = {**body, **camel_properties} for matrix, matrix_name in zip( [less_term_matrix, equal_term_matrix, greater_term_matrix], @@ -90,23 +96,20 @@ def create_binding_constraint( bc_id = created_properties["id"] for key in ["terms", "id", "name"]: del created_properties[key] - bc_properties = BindingConstraintProperties.model_validate(created_properties) - bc_terms: list[ConstraintTerm] = [] + api_properties = BindingConstraintPropertiesAPI.model_validate(created_properties) + bc_properties = api_properties.to_user_model() if terms: - json_terms = [term.model_dump() for term in terms] + json_terms = [ + {"weight": term.weight, "offset": term.offset, "data": asdict(term.data)} for term in terms + ] url = f"{base_url}/{bc_id}/terms" self._wrapper.post(url, json=json_terms) - url = f"{base_url}/{bc_id}" - response = self._wrapper.get(url) - created_terms = response.json()["terms"] - bc_terms = [ConstraintTerm.model_validate(term) for term in created_terms] - except APIError as e: raise BindingConstraintCreationError(name, e.message) from e - constraint = BindingConstraint(name, self, bc_properties, bc_terms) + constraint = BindingConstraint(name, self, bc_properties, terms) return constraint @@ -118,13 +121,33 @@ def delete_binding_constraint_term(self, constraint_id: str, term_id: str) -> No except APIError as e: raise ConstraintTermDeletionError(constraint_id, term_id, e.message) from e + @override + def update_binding_constraint_term( + self, constraint_id: str, term: ConstraintTermUpdate, existing_term: ConstraintTerm + ) -> ConstraintTerm: + url = f"{self._base_url}/studies/{self.study_id}/bindingconstraints/{constraint_id}/term" + try: + body: dict[str, Any] = {"data": asdict(term.data), "weight": term.weight or existing_term.weight} + if term.offset: + body["offset"] = term.offset + self._wrapper.put(url, json=body) + except APIError as e: + raise ConstraintTermEditionError(constraint_id, term.id, e.message) from e + + if term.weight: + existing_term.weight = term.weight + if term.offset: + existing_term.offset = term.offset + return existing_term + @override def update_binding_constraint_properties( - self, binding_constraint: BindingConstraint, properties: BindingConstraintProperties + self, binding_constraint: BindingConstraint, properties: BindingConstraintPropertiesUpdate ) -> BindingConstraintProperties: url = f"{self._base_url}/studies/{self.study_id}/bindingconstraints/{binding_constraint.id}" try: - body = properties.model_dump(mode="json", by_alias=True, exclude_none=True) + api_model = BindingConstraintPropertiesAPI.from_user_model(properties) + body = api_model.model_dump(mode="json", by_alias=True, exclude_none=True) if not body: return binding_constraint.properties @@ -132,7 +155,8 @@ def update_binding_constraint_properties( json_response = response.json() for key in ["terms", "id", "name"]: del json_response[key] - new_properties = BindingConstraintProperties.model_validate(json_response) + new_api_properties = BindingConstraintPropertiesAPI.model_validate(json_response) + new_properties = new_api_properties.to_user_model() except APIError as e: raise ConstraintPropertiesUpdateError(binding_constraint.id, e.message) from e @@ -164,39 +188,35 @@ def update_constraint_matrix( raise ConstraintMatrixUpdateError(constraint.id, matrix_name.value, e.message) from e @override - def add_constraint_terms(self, constraint: BindingConstraint, terms: list[ConstraintTerm]) -> list[ConstraintTerm]: + def add_constraint_terms(self, constraint: BindingConstraint, terms: list[ConstraintTerm]) -> None: url = f"{self._base_url}/studies/{self.study_id}/bindingconstraints/{constraint.id}" try: - json_terms = [term.model_dump() for term in terms] + json_terms = [{"weight": term.weight, "offset": term.offset, "data": asdict(term.data)} for term in terms] self._wrapper.post(f"{url}/terms", json=json_terms) - response = self._wrapper.get(url) - all_terms = response.json()["terms"] - validated_terms = [ConstraintTerm.model_validate(term) for term in all_terms] - new_terms = [term for term in validated_terms if term.id not in constraint.get_terms()] except APIError as e: raise ConstraintTermAdditionError(constraint.id, [term.id for term in terms], e.message) from e - return new_terms - @override def read_binding_constraints(self) -> list[BindingConstraint]: url = f"{self._base_url}/studies/{self.study_id}/bindingconstraints" try: response = self._wrapper.get(url) constraints_json = response.json() + constraints = [] + + for constraint in constraints_json: + constraint_name = constraint.pop("name") + del constraint["id"] + api_terms = constraint.pop("terms") + api_properties = BindingConstraintPropertiesAPI.model_validate(constraint) + bc_properties = api_properties.to_user_model() + terms: list[ConstraintTerm] = [] + for api_term in api_terms: + term_data = ConstraintTermData.from_dict(api_term["data"]) + terms.append(ConstraintTerm(weight=api_term["weight"], offset=api_term["offset"], data=term_data)) + constraints.append(BindingConstraint(constraint_name, self, bc_properties, terms)) - constraints = [ - BindingConstraint( - constraint["name"], - self, - BindingConstraintProperties.model_validate( - {k: v for k, v in constraint.items() if k not in ["terms", "id", "name"]} - ), - [ConstraintTerm.model_validate(term) for term in constraint["terms"]], - ) - for constraint in constraints_json - ] constraints.sort(key=lambda constraint: constraint.id) return constraints except APIError as e: diff --git a/src/antares/craft/service/base_services.py b/src/antares/craft/service/base_services.py index d6e8ee4b..38aa0291 100644 --- a/src/antares/craft/service/base_services.py +++ b/src/antares/craft/service/base_services.py @@ -25,8 +25,10 @@ from antares.craft.model.binding_constraint import ( BindingConstraint, BindingConstraintProperties, + BindingConstraintPropertiesUpdate, ConstraintMatrixName, ConstraintTerm, + ConstraintTermUpdate, ) from antares.craft.model.hydro import HydroProperties, HydroPropertiesUpdate from antares.craft.model.link import Link, LinkProperties, LinkUi @@ -493,8 +495,6 @@ def read_thermal_clusters(self, area_id: str) -> list["ThermalCluster"]: class BaseBindingConstraintService(ABC): - binding_constraints: dict[str, "BindingConstraint"] - @abstractmethod def create_binding_constraint( self, @@ -520,9 +520,7 @@ def create_binding_constraint( pass @abstractmethod - def add_constraint_terms( - self, constraint: "BindingConstraint", terms: list["ConstraintTerm"] - ) -> list["ConstraintTerm"]: + def add_constraint_terms(self, constraint: "BindingConstraint", terms: list["ConstraintTerm"]) -> None: """ Args: constraint: the concerned binding constraint @@ -542,9 +540,21 @@ def delete_binding_constraint_term(self, constraint_id: str, term_id: str) -> No """ pass + @abstractmethod + def update_binding_constraint_term( + self, constraint_id: str, term: "ConstraintTermUpdate", existing_term: "ConstraintTerm" + ) -> "ConstraintTerm": + """ + Args: + constraint_id: binding constraint's id containing the term + term: term with new values + existing_term: existing term with existing values + """ + pass + @abstractmethod def update_binding_constraint_properties( - self, binding_constraint: "BindingConstraint", properties: "BindingConstraintProperties" + self, binding_constraint: "BindingConstraint", properties: "BindingConstraintPropertiesUpdate" ) -> "BindingConstraintProperties": """ Args: diff --git a/src/antares/craft/service/local_services/models/binding_constraint.py b/src/antares/craft/service/local_services/models/binding_constraint.py new file mode 100644 index 00000000..f115ac8a --- /dev/null +++ b/src/antares/craft/service/local_services/models/binding_constraint.py @@ -0,0 +1,51 @@ +# Copyright (c) 2024, RTE (https://www.rte-france.com) +# +# See AUTHORS.txt +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# SPDX-License-Identifier: MPL-2.0 +# +# This file is part of the Antares project. + +from dataclasses import asdict +from typing import Union + +from antares.craft.model.binding_constraint import ( + BindingConstraintFrequency, + BindingConstraintOperator, + BindingConstraintProperties, + BindingConstraintPropertiesUpdate, +) +from antares.craft.service.local_services.models.base_model import LocalBaseModel +from pydantic import Field + +BindingConstraintPropertiesType = Union[BindingConstraintProperties, BindingConstraintPropertiesUpdate] + + +class BindingConstraintPropertiesLocal(LocalBaseModel): + enabled: bool = True + time_step: BindingConstraintFrequency = Field(BindingConstraintFrequency.HOURLY, alias="type") + operator: BindingConstraintOperator = BindingConstraintOperator.LESS + comments: str = "" + filter_year_by_year: str = Field("hourly", alias="filter-year-by-year") + filter_synthesis: str = Field("hourly", alias="filter-synthesis") + group: str = "default" + + @staticmethod + def from_user_model(user_class: BindingConstraintPropertiesType) -> "BindingConstraintPropertiesLocal": + user_dict = asdict(user_class) + return BindingConstraintPropertiesLocal.model_validate(user_dict) + + def to_user_model(self) -> BindingConstraintProperties: + return BindingConstraintProperties( + enabled=self.enabled, + time_step=self.time_step, + operator=self.operator, + comments=self.comments, + filter_year_by_year=self.filter_year_by_year, + filter_synthesis=self.filter_synthesis, + group=self.group, + ) diff --git a/src/antares/craft/service/local_services/binding_constraint_local.py b/src/antares/craft/service/local_services/services/binding_constraint.py similarity index 54% rename from src/antares/craft/service/local_services/binding_constraint_local.py rename to src/antares/craft/service/local_services/services/binding_constraint.py index fc73c965..a59dc171 100644 --- a/src/antares/craft/service/local_services/binding_constraint_local.py +++ b/src/antares/craft/service/local_services/services/binding_constraint.py @@ -9,70 +9,35 @@ # SPDX-License-Identifier: MPL-2.0 # # This file is part of the Antares project. -from typing import Any, Optional, Union +from typing import Any, Optional import numpy as np import pandas as pd from antares.craft.config.local_configuration import LocalConfiguration -from antares.craft.exceptions.exceptions import BindingConstraintCreationError +from antares.craft.exceptions.exceptions import ( + BindingConstraintCreationError, + ConstraintDoesNotExistError, +) from antares.craft.model.binding_constraint import ( BindingConstraint, BindingConstraintFrequency, BindingConstraintOperator, BindingConstraintProperties, + BindingConstraintPropertiesUpdate, ConstraintMatrixName, ConstraintTerm, - DefaultBindingConstraintProperties, + ConstraintTermUpdate, ) from antares.craft.service.base_services import BaseBindingConstraintService +from antares.craft.service.local_services.models.binding_constraint import BindingConstraintPropertiesLocal +from antares.craft.tools.contents_tool import transform_name_to_id from antares.craft.tools.ini_tool import IniFile, InitializationFilesTypes from antares.craft.tools.matrix_tool import df_read, df_save from antares.craft.tools.time_series_tool import TimeSeriesFileType -from pydantic import Field from typing_extensions import override -class BindingConstraintPropertiesLocal(DefaultBindingConstraintProperties): - """ - Used to create the entries for the bindingconstraints.ini file - - Attributes: - constraint_name: The constraint name - constraint_id: The constraint id - properties (BindingConstraintProperties): The BindingConstraintProperties to set - terms (dict[str, ConstraintTerm]]): The terms applying to the binding constraint - """ - - constraint_name: str - constraint_id: str - terms: dict[str, ConstraintTerm] = Field(default_factory=dict[str, ConstraintTerm]) - - @property - def list_ini_fields(self) -> dict[str, str]: - ini_dict = { - "name": self.constraint_name, - "id": self.constraint_id, - "enabled": f"{self.enabled}".lower(), - "type": self.time_step.value, - "operator": self.operator.value, - "comments": self.comments, - "filter-year-by-year": self.filter_year_by_year, - "filter-synthesis": self.filter_synthesis, - "group": self.group, - } | {term_id: term.weight_offset() for term_id, term in self.terms.items()} - return {key: value for key, value in ini_dict.items() if value not in [None, ""]} - - def yield_binding_constraint_properties(self) -> BindingConstraintProperties: - excludes = { - "constraint_name", - "constraint_id", - "terms", - "list_ini_fields", - } - return BindingConstraintProperties(**self.model_dump(mode="json", exclude=excludes)) - - class BindingConstraintLocalService(BaseBindingConstraintService): def __init__(self, config: LocalConfiguration, study_name: str, **kwargs: Any) -> None: super().__init__(**kwargs) @@ -90,6 +55,7 @@ def create_binding_constraint( equal_term_matrix: Optional[pd.DataFrame] = None, greater_term_matrix: Optional[pd.DataFrame] = None, ) -> BindingConstraint: + properties = properties or BindingConstraintProperties() constraint = BindingConstraint( name=name, binding_constraint_service=self, @@ -97,33 +63,14 @@ def create_binding_constraint( terms=terms, ) - local_properties = self._generate_local_properties(constraint) - constraint.properties = local_properties.yield_binding_constraint_properties() - - current_ini_content = self.ini_file.ini_dict_binding_constraints or {} - if any(values.get("id") == constraint.id for values in current_ini_content.values()): - raise BindingConstraintCreationError( - constraint_name=name, message=f"A binding constraint with the name {name} already exists." - ) + local_properties = BindingConstraintPropertiesLocal.from_user_model(properties) - self._write_binding_constraint_ini(local_properties, name, name, terms) + self._create_constraint_inside_ini(name, local_properties, terms or []) self._store_time_series(constraint, less_term_matrix, equal_term_matrix, greater_term_matrix) return constraint - @staticmethod - def _create_local_property_args(constraint: BindingConstraint) -> dict[str, Union[str, dict[str, ConstraintTerm]]]: - return { - "constraint_name": constraint.name, - "constraint_id": constraint.id, - "terms": constraint.get_terms(), - **constraint.properties.model_dump(mode="json", exclude_none=True), - } - - def _generate_local_properties(self, constraint: BindingConstraint) -> BindingConstraintPropertiesLocal: - return BindingConstraintPropertiesLocal.model_validate(self._create_local_property_args(constraint)) - def _store_time_series( self, constraint: BindingConstraint, @@ -154,87 +101,79 @@ def _check_if_empty_ts(time_step: BindingConstraintFrequency, time_series: Optio time_series_length = (365 * 24 + 24) if time_step == BindingConstraintFrequency.HOURLY else 366 return time_series if time_series is not None else pd.DataFrame(np.zeros([time_series_length, 1])) - def _write_binding_constraint_ini( + def _create_constraint_inside_ini( self, - local_properties: BindingConstraintPropertiesLocal, constraint_name: str, - constraint_id: str, - terms: Optional[list[ConstraintTerm]] = None, + properties: BindingConstraintPropertiesLocal, + terms: list[ConstraintTerm], ) -> None: - """ - Write or update a binding constraint in the INI file. - - """ - - current_ini_content = self.ini_file.ini_dict_binding_constraints or {} - - existing_section = next( - (section for section, values in current_ini_content.items() if values.get("name") == constraint_name), - None, - ) - - if existing_section: - existing_terms = current_ini_content[existing_section] - - serialized_terms = {term.id: term.weight_offset() for term in terms} if terms else {} - - existing_terms.update(serialized_terms) - current_ini_content[existing_section] = existing_terms - - # Persist the updated INI content - self.ini_file.write_ini_file() - else: - section_index = len(current_ini_content) - current_ini_content[str(section_index)] = local_properties.list_ini_fields - - self.ini_file.ini_dict_binding_constraints = current_ini_content + current_ini_content = self.ini_file.ini_dict + constraint_id = transform_name_to_id(constraint_name) + # Ensures the constraint doesn't already exist + for existing_constraint in current_ini_content.values(): + if existing_constraint["id"] == constraint_id: + raise BindingConstraintCreationError( + constraint_name=constraint_name, + message=f"A binding constraint with the name {constraint_name} already exists.", + ) + new_key = str(len(current_ini_content.keys())) + props_content = { + "id": constraint_id, + "name": constraint_name, + **properties.model_dump(mode="json", by_alias=True), + } + term_content = {term.id: term.weight_offset() for term in terms} + whole_content = props_content | term_content + current_ini_content[new_key] = whole_content + self.ini_file.ini_dict = current_ini_content self.ini_file.write_ini_file() @override - def add_constraint_terms(self, constraint: BindingConstraint, terms: list[ConstraintTerm]) -> list[ConstraintTerm]: + def add_constraint_terms(self, constraint: BindingConstraint, terms: list[ConstraintTerm]) -> None: """ Add terms to a binding constraint and update the INI file. Args: constraint (BindingConstraint): The binding constraint to update. terms (list[ConstraintTerm]): A list of new terms to add. - - Returns: - list[ConstraintTerm]: The updated list of terms. """ - new_terms = constraint.get_terms().copy() - + # Checks the terms to add are not already defined + current_terms = constraint.get_terms() for term in terms: - if term.id in constraint.get_terms(): + if term.id in current_terms: raise BindingConstraintCreationError( constraint_name=constraint.name, message=f"Duplicate term found: {term.id}" ) - new_terms[term.id] = term - - local_properties = self._generate_local_properties(constraint) - local_properties.terms = new_terms - - terms_values = list(new_terms.values()) - - self._write_binding_constraint_ini( - local_properties=local_properties, - constraint_name=constraint.name, - constraint_id=constraint.id, - terms=terms_values, - ) - return terms_values + current_ini_content = self.ini_file.ini_dict_binding_constraints or {} + existing_constraint = self._get_constraint_inside_ini(current_ini_content, constraint) + new_terms = {term.id: term.weight_offset() for term in terms} + existing_constraint.update(new_terms) + self.ini_file.ini_dict = current_ini_content + self.ini_file.write_ini_file() @override def delete_binding_constraint_term(self, constraint_id: str, term_id: str) -> None: raise NotImplementedError + @override + def update_binding_constraint_term( + self, constraint_id: str, term: ConstraintTermUpdate, existing_term: ConstraintTerm + ) -> ConstraintTerm: + raise NotImplementedError + @override def update_binding_constraint_properties( - self, binding_constraint: BindingConstraint, properties: BindingConstraintProperties + self, binding_constraint: BindingConstraint, properties: BindingConstraintPropertiesUpdate ) -> BindingConstraintProperties: - raise NotImplementedError + current_ini_content = self.ini_file.ini_dict + existing_constraint = self._get_constraint_inside_ini(current_ini_content, binding_constraint) + local_properties = BindingConstraintPropertiesLocal.from_user_model(properties) + existing_constraint.update(local_properties.model_dump(mode="json", by_alias=True)) + self.ini_file.ini_dict = current_ini_content + self.ini_file.write_ini_file() + return local_properties.to_user_model() @override def get_constraint_matrix(self, constraint: BindingConstraint, matrix_name: ConstraintMatrixName) -> pd.DataFrame: @@ -252,3 +191,11 @@ def update_constraint_matrix( @override def read_binding_constraints(self) -> list[BindingConstraint]: raise NotImplementedError + + @staticmethod + def _get_constraint_inside_ini(ini_content: dict[str, Any], constraint: BindingConstraint) -> dict[str, Any]: + existing_key = next((key for key, bc in ini_content.items() if bc["id"] == constraint.id), None) + if not existing_key: + raise ConstraintDoesNotExistError(constraint.name) + + return ini_content[existing_key] # type: ignore diff --git a/src/antares/craft/service/service_factory.py b/src/antares/craft/service/service_factory.py index 0aa8a7c7..4d201508 100644 --- a/src/antares/craft/service/service_factory.py +++ b/src/antares/craft/service/service_factory.py @@ -14,8 +14,8 @@ from antares.craft.config.base_configuration import BaseConfiguration from antares.craft.config.local_configuration import LocalConfiguration from antares.craft.service.api_services.area_api import AreaApiService -from antares.craft.service.api_services.binding_constraint_api import BindingConstraintApiService from antares.craft.service.api_services.link_api import LinkApiService +from antares.craft.service.api_services.services.binding_constraint import BindingConstraintApiService from antares.craft.service.api_services.services.hydro import HydroApiService from antares.craft.service.api_services.services.output import OutputApiService from antares.craft.service.api_services.services.renewable import RenewableApiService @@ -38,8 +38,8 @@ BaseThermalService, ) from antares.craft.service.local_services.area_local import AreaLocalService -from antares.craft.service.local_services.binding_constraint_local import BindingConstraintLocalService from antares.craft.service.local_services.link_local import LinkLocalService +from antares.craft.service.local_services.services.binding_constraint import BindingConstraintLocalService from antares.craft.service.local_services.services.hydro import HydroLocalService from antares.craft.service.local_services.services.output import OutputLocalService from antares.craft.service.local_services.services.renewable import RenewableLocalService diff --git a/tests/antares/delete/test_delete_api.py b/tests/antares/delete/test_delete_api.py index 1887b9a0..18488987 100644 --- a/tests/antares/delete/test_delete_api.py +++ b/tests/antares/delete/test_delete_api.py @@ -30,8 +30,8 @@ from antares.craft.model.st_storage import STStorage from antares.craft.model.thermal import ThermalCluster from antares.craft.service.api_services.area_api import AreaApiService -from antares.craft.service.api_services.binding_constraint_api import BindingConstraintApiService from antares.craft.service.api_services.link_api import LinkApiService +from antares.craft.service.api_services.services.binding_constraint import BindingConstraintApiService from antares.craft.service.api_services.services.hydro import HydroApiService from antares.craft.service.api_services.services.output import OutputApiService from antares.craft.service.api_services.services.renewable import RenewableApiService diff --git a/tests/antares/integration/test_local_client.py b/tests/antares/integration/test_local_client.py index d9b23db8..e0a85316 100644 --- a/tests/antares/integration/test_local_client.py +++ b/tests/antares/integration/test_local_client.py @@ -18,7 +18,13 @@ from antares.craft.exceptions.exceptions import AreaCreationError, LinkCreationError from antares.craft.model.area import AdequacyPatchMode, Area, AreaProperties, AreaUi -from antares.craft.model.binding_constraint import BindingConstraintProperties, ClusterData, ConstraintTerm, LinkData +from antares.craft.model.binding_constraint import ( + BindingConstraintOperator, + BindingConstraintProperties, + ClusterData, + ConstraintTerm, + LinkData, +) from antares.craft.model.commons import FilterOption from antares.craft.model.link import Link, LinkProperties, LinkUi from antares.craft.model.renewable import RenewableClusterGroup, RenewableClusterProperties @@ -240,7 +246,7 @@ def test_local_study(self, tmp_path: Path, unknown_area): assert constraint_1.get_terms() == {link_term_1.id: link_term_1, cluster_term.id: cluster_term} # Case that succeeds - properties = BindingConstraintProperties(operator="less") + properties = BindingConstraintProperties(operator=BindingConstraintOperator.LESS) matrix = pd.DataFrame(data=(np.ones((8784, 1)))) constraint_3 = test_study.create_binding_constraint(name="bc_3", less_term_matrix=matrix, properties=properties) assert constraint_3.get_less_term_matrix().equals(matrix) diff --git a/tests/antares/services/api_services/test_binding_constraint_api.py b/tests/antares/services/api_services/test_binding_constraint_api.py index c61c4613..76967973 100644 --- a/tests/antares/services/api_services/test_binding_constraint_api.py +++ b/tests/antares/services/api_services/test_binding_constraint_api.py @@ -16,10 +16,23 @@ import pandas as pd from antares.craft.api_conf.api_conf import APIconf -from antares.craft.exceptions.exceptions import ConstraintMatrixDownloadError, ConstraintPropertiesUpdateError +from antares.craft.exceptions.exceptions import ( + ConstraintMatrixDownloadError, + ConstraintPropertiesUpdateError, + ConstraintTermEditionError, +) from antares.craft.model.area import Area -from antares.craft.model.binding_constraint import BindingConstraint, BindingConstraintProperties, ConstraintMatrixName +from antares.craft.model.binding_constraint import ( + BindingConstraint, + BindingConstraintProperties, + BindingConstraintPropertiesUpdate, + ConstraintMatrixName, + ConstraintTerm, + ConstraintTermUpdate, + LinkData, +) from antares.craft.model.study import Study +from antares.craft.service.api_services.models.binding_constraint import BindingConstraintPropertiesAPI from antares.craft.service.service_factory import ServiceFactory @@ -50,17 +63,24 @@ class TestCreateAPI: def test_update_binding_constraint_properties_success(self): with requests_mock.Mocker() as mocker: - properties = BindingConstraintProperties(enabled=False) + update_properties = BindingConstraintPropertiesUpdate(enabled=False) + creation_properties = BindingConstraintProperties(enabled=False) + api_properties = BindingConstraintPropertiesAPI.from_user_model(creation_properties) constraint = BindingConstraint( "bc_1", ServiceFactory(self.api, self.study_id).create_binding_constraints_service() ) url = f"https://antares.com/api/v1/studies/{self.study_id}/bindingconstraints/{constraint.id}" - mocker.put(url, json={"id": "id", "name": "name", "terms": [], **properties.model_dump()}, status_code=200) - constraint.update_properties(properties=properties) + mocker.put( + url, + json={"id": "id", "name": "name", "terms": [], **api_properties.model_dump(mode="json")}, + status_code=200, + ) + constraint.update_properties(properties=update_properties) + assert constraint.properties == BindingConstraintProperties(enabled=False) def test_update_binding_constraint_properties_fails(self): with requests_mock.Mocker() as mocker: - properties = BindingConstraintProperties(enabled=False) + update_properties = BindingConstraintPropertiesUpdate(enabled=False) constraint = BindingConstraint( "bc_1", ServiceFactory(self.api, self.study_id).create_binding_constraints_service() ) @@ -72,7 +92,37 @@ def test_update_binding_constraint_properties_fails(self): ConstraintPropertiesUpdateError, match=f"Could not update properties for binding constraint {constraint.id}: {antares_web_description_msg}", ): - constraint.update_properties(properties=properties) + constraint.update_properties(properties=update_properties) + + def test_update_binding_constraint_term_success(self): + with requests_mock.Mocker() as mocker: + existing_term = ConstraintTerm(data=LinkData(area1="fr", area2="be"), weight=4, offset=3) + service = ServiceFactory(self.api, self.study_id).create_binding_constraints_service() + constraint = BindingConstraint("bc_1", service, None, [existing_term]) + + url = f"https://antares.com/api/v1/studies/{self.study_id}/bindingconstraints/{constraint.id}/term" + mocker.put(url, status_code=200) + + new_term = ConstraintTermUpdate(data=LinkData(area1="fr", area2="be"), weight=2) + constraint.update_term(new_term) + updated_term = constraint.get_terms()[existing_term.id] + assert updated_term == ConstraintTerm(data=LinkData(area1="fr", area2="be"), weight=2, offset=3) + + def test_update_binding_constraint_term_fails(self): + with requests_mock.Mocker() as mocker: + existing_term = ConstraintTerm(data=LinkData(area1="fr", area2="be"), weight=4, offset=3) + service = ServiceFactory(self.api, self.study_id).create_binding_constraints_service() + constraint = BindingConstraint("bc_1", service, None, [existing_term]) + + url = f"https://antares.com/api/v1/studies/{self.study_id}/bindingconstraints/{constraint.id}/term" + mocker.put(url, json={"description": self.antares_web_description_msg}, status_code=422) + + new_term = ConstraintTermUpdate(data=LinkData(area1="fr", area2="be"), weight=2) + with pytest.raises( + ConstraintTermEditionError, + match=f"Could not update the term {new_term.id} of the binding constraint {constraint.id}: {self.antares_web_description_msg}", + ): + constraint.update_term(new_term) def test_get_constraint_matrix_success(self, constraint_set): constraint = BindingConstraint( diff --git a/tests/antares/services/api_services/test_study_api.py b/tests/antares/services/api_services/test_study_api.py index d745b5a6..dbae4938 100644 --- a/tests/antares/services/api_services/test_study_api.py +++ b/tests/antares/services/api_services/test_study_api.py @@ -40,7 +40,6 @@ ) from antares.craft.model.area import Area, AreaProperties, AreaUi from antares.craft.model.binding_constraint import ( - BindingConstraint, BindingConstraintFrequency, BindingConstraintOperator, BindingConstraintProperties, @@ -53,6 +52,7 @@ from antares.craft.model.settings.study_settings import StudySettingsUpdate from antares.craft.model.simulation import AntaresSimulationParameters, Job, JobStatus, Solver from antares.craft.model.study import Study, create_study_api, create_variant_api, import_study_api, read_study_api +from antares.craft.service.api_services.models.binding_constraint import BindingConstraintPropertiesAPI from antares.craft.service.api_services.models.hydro import HydroPropertiesAPI from antares.craft.service.api_services.services.output import OutputApiService from antares.craft.service.service_factory import ServiceFactory @@ -199,11 +199,15 @@ def test_create_link_success(self): def test_create_binding_constraint_success(self): with requests_mock.Mocker() as mocker: url = f"https://antares.com/api/v1/studies/{self.study_id}/bindingconstraints" - json_response = BindingConstraintProperties().model_dump(mode="json", by_alias=True) + properties = BindingConstraintProperties(enabled=False, filter_synthesis="annual") + json_response = BindingConstraintPropertiesAPI.from_user_model(properties).model_dump( + mode="json", by_alias=True + ) constraint_name = "bc_1" mocker.post(url, json={"id": "id", "name": constraint_name, "terms": [], **json_response}, status_code=201) - constraint = self.study.create_binding_constraint(name=constraint_name) - assert isinstance(constraint, BindingConstraint) + constraint = self.study.create_binding_constraint(name=constraint_name, properties=properties) + assert constraint.name == constraint_name + assert constraint.properties == properties def test_create_binding_constraint_fails(self): with requests_mock.Mocker() as mocker: diff --git a/tests/antares/services/local_services/test_study.py b/tests/antares/services/local_services/test_study.py index 07e1d4f3..4be5b471 100644 --- a/tests/antares/services/local_services/test_study.py +++ b/tests/antares/services/local_services/test_study.py @@ -36,6 +36,7 @@ BindingConstraintOperator, BindingConstraintProperties, ConstraintTerm, + LinkData, ) from antares.craft.model.commons import FilterOption from antares.craft.model.hydro import Hydro @@ -1214,15 +1215,8 @@ def test_duplicate_name_errors(self, local_study_with_constraint): ): local_study_with_constraint.create_binding_constraint(name=binding_constraint_name) - def test_constraints_have_default_properties(self, local_study_with_constraint): - # Given - constraint = local_study_with_constraint.get_binding_constraints()["test constraint"] - - # Then - assert constraint.properties.model_dump(exclude_none=True) - - def test_constraints_have_correct_default_properties(self, test_constraint, default_constraint_properties): - assert test_constraint.properties == default_constraint_properties + def test_constraints_have_correct_default_properties(self, test_constraint): + assert test_constraint.properties == BindingConstraintProperties() def test_creating_constraints_creates_ini(self, local_study_with_constraint): # Given @@ -1239,11 +1233,12 @@ def test_constraints_ini_have_correct_default_content( ): # Given expected_ini_contents = """[0] -name = test constraint id = test constraint -enabled = true +name = test constraint +enabled = True type = hourly operator = less +comments = filter-year-by-year = hourly filter-synthesis = hourly group = default @@ -1274,19 +1269,20 @@ def test_constraints_and_ini_have_custom_properties(self, local_study_with_const group="test group", ) expected_ini_content = """[0] -name = test constraint id = test constraint -enabled = true +name = test constraint +enabled = True type = hourly operator = less +comments = filter-year-by-year = hourly filter-synthesis = hourly group = default [1] -name = test constraint two id = test constraint two -enabled = false +name = test constraint two +enabled = False type = weekly operator = both comments = test comment @@ -1311,26 +1307,27 @@ def test_constraints_and_ini_have_custom_properties(self, local_study_with_const assert actual_ini_content == expected_ini_content def test_constraint_can_add_term(self, test_constraint): - new_term = [ConstraintTerm(data={"area1": "fr", "area2": "at"})] + new_term = [ConstraintTerm(data=LinkData(area1="fr", area2="at"))] test_constraint.add_terms(new_term) assert test_constraint.get_terms() def test_constraint_term_and_ini_have_correct_defaults(self, local_study_with_constraint, test_constraint): # Given expected_ini_contents = """[0] -name = test constraint id = test constraint -enabled = true +name = test constraint +enabled = True type = hourly operator = less +comments = filter-year-by-year = hourly filter-synthesis = hourly group = default -at%fr = 0 +at%fr = 1 """ # When - new_term = [ConstraintTerm(data={"area1": "fr", "area2": "at"})] + new_term = [ConstraintTerm(data=LinkData(area1="fr", area2="at"))] test_constraint.add_terms(new_term) with local_study_with_constraint._binding_constraints_service.ini_file.ini_path.open("r") as file: actual_ini_content = file.read() @@ -1342,19 +1339,20 @@ def test_constraint_term_with_offset_and_ini_have_correct_values( ): # Given expected_ini_contents = """[0] -name = test constraint id = test constraint -enabled = true +name = test constraint +enabled = True type = hourly operator = less +comments = filter-year-by-year = hourly filter-synthesis = hourly group = default -at%fr = 0.000000%1 +at%fr = 1%1 """ # When - new_term = [ConstraintTerm(offset=1, data={"area1": "fr", "area2": "at"})] + new_term = [ConstraintTerm(offset=1, data=LinkData(area1="fr", area2="at"))] test_constraint.add_terms(new_term) with local_study_with_constraint._binding_constraints_service.ini_file.ini_path.open("r") as file: actual_ini_content = file.read() diff --git a/tests/integration/test_web_client.py b/tests/integration/test_web_client.py index 3dd47474..1f11f098 100644 --- a/tests/integration/test_web_client.py +++ b/tests/integration/test_web_client.py @@ -32,8 +32,10 @@ BindingConstraintFrequency, BindingConstraintOperator, BindingConstraintProperties, + BindingConstraintPropertiesUpdate, ClusterData, ConstraintTerm, + ConstraintTermUpdate, LinkData, ) from antares.craft.model.hydro import HydroPropertiesUpdate @@ -381,7 +383,7 @@ def test_creation_lifecycle(self, antares_web: AntaresWebDesktop, tmp_path): constraint_2.update_equal_term_matrix(wrong_matrix) # Case that succeeds - properties = BindingConstraintProperties(operator="less") + properties = BindingConstraintProperties(operator=BindingConstraintOperator.LESS) matrix = pd.DataFrame(data=(np.ones((8784, 1)))) constraint_3 = study.create_binding_constraint(name="bc_3", less_term_matrix=matrix, properties=properties) assert constraint_3.get_less_term_matrix().equals(matrix) @@ -389,8 +391,8 @@ def test_creation_lifecycle(self, antares_web: AntaresWebDesktop, tmp_path): # test update constraint matrices new_matrix = pd.DataFrame(data=(np.ones((8784, 1)))) new_matrix.iloc[0, 0] = 72 - properties.operator = "equal" - constraint_3.update_properties(properties) + update_properties = BindingConstraintPropertiesUpdate(operator=BindingConstraintOperator.EQUAL) + constraint_3.update_properties(update_properties) constraint_3.update_equal_term_matrix(new_matrix) assert constraint_3.get_equal_term_matrix().equals(new_matrix) @@ -410,6 +412,13 @@ def test_creation_lifecycle(self, antares_web: AntaresWebDesktop, tmp_path): constraint_3.id: constraint_3, } + # tests updating an existing term + new_term = ConstraintTermUpdate(data=cluster_data, offset=12) + constraint_1.update_term(new_term) + updated_term = constraint_1.get_terms()[new_term.id] + assert updated_term.weight == 100 # Checks the weight wasn't modified + assert updated_term.offset == 12 + # test area property edition new_props = AreaProperties() new_props.adequacy_patch_mode = AdequacyPatchMode.VIRTUAL @@ -462,10 +471,10 @@ def test_creation_lifecycle(self, antares_web: AntaresWebDesktop, tmp_path): assert battery_fr.properties.reservoir_capacity == 0.5 # Checks old value wasn't modified # tests constraint properties update - new_props = BindingConstraintProperties() - new_props.group = "another_group" + new_props = BindingConstraintPropertiesUpdate(group="another_group") constraint_1.update_properties(new_props) assert constraint_1.properties.group == "another_group" + assert constraint_1.properties.enabled is False # Checks old value wasn't modified # tests constraint deletion study.delete_binding_constraint(constraint_1)