diff --git a/antarest/study/business/all_optional_meta.py b/antarest/study/business/all_optional_meta.py new file mode 100644 index 0000000000..06ddc012d8 --- /dev/null +++ b/antarest/study/business/all_optional_meta.py @@ -0,0 +1,94 @@ +import typing as t + +import pydantic.fields +import pydantic.main +from pydantic import BaseModel + +from antarest.core.utils.string import to_camel_case + + +class AllOptionalMetaclass(pydantic.main.ModelMetaclass): + """ + Metaclass that makes all fields of a Pydantic model optional. + + Usage: + class MyModel(BaseModel, metaclass=AllOptionalMetaclass): + field1: str + field2: int + ... + + Instances of the model can be created even if not all fields are provided during initialization. + Default values, when provided, are used unless `use_none` is set to `True`. + """ + + def __new__( + cls: t.Type["AllOptionalMetaclass"], + name: str, + bases: t.Tuple[t.Type[t.Any], ...], + namespaces: t.Dict[str, t.Any], + use_none: bool = False, + **kwargs: t.Dict[str, t.Any], + ) -> t.Any: + """ + Create a new instance of the metaclass. + + Args: + name: Name of the class to create. + bases: Base classes of the class to create (a Pydantic model). + namespaces: namespace of the class to create that defines the fields of the model. + use_none: If `True`, the default value of the fields is set to `None`. + Note that this field is not part of the Pydantic model, but it is an extension. + **kwargs: Additional keyword arguments used by the metaclass. + """ + # Modify the annotations of the class (but not of the ancestor classes) + # in order to make all fields optional. + # If the current model inherits from another model, the annotations of the ancestor models + # are not modified, because the fields are already converted to `ModelField`. + annotations = namespaces.get("__annotations__", {}) + for field_name, field_type in annotations.items(): + if not field_name.startswith("__"): + # Making already optional fields optional is not a problem (nothing is changed). + annotations[field_name] = t.Optional[field_type] + namespaces["__annotations__"] = annotations + + if use_none: + # Modify the namespace fields to set their default value to `None`. + for field_name, field_info in namespaces.items(): + if isinstance(field_info, pydantic.fields.FieldInfo): + field_info.default = None + field_info.default_factory = None + + # Create the class: all annotations are converted into `ModelField`. + instance = super().__new__(cls, name, bases, namespaces, **kwargs) + + # Modify the inherited fields of the class to make them optional + # and set their default value to `None`. + model_field: pydantic.fields.ModelField + for field_name, model_field in instance.__fields__.items(): + model_field.required = False + model_field.allow_none = True + if use_none: + model_field.default = None + model_field.default_factory = None + model_field.field_info.default = None + + return instance + + +MODEL = t.TypeVar("MODEL", bound=t.Type[BaseModel]) + + +def camel_case_model(model: MODEL) -> MODEL: + """ + This decorator can be used to modify a model to use camel case aliases. + + Args: + model: The pydantic model to modify. + + Returns: + The modified model. + """ + model.__config__.alias_generator = to_camel_case + for field_name, field in model.__fields__.items(): + field.alias = to_camel_case(field_name) + return model diff --git a/antarest/study/business/areas/renewable_management.py b/antarest/study/business/areas/renewable_management.py index 7858409f17..84f6e56672 100644 --- a/antarest/study/business/areas/renewable_management.py +++ b/antarest/study/business/areas/renewable_management.py @@ -4,8 +4,9 @@ from pydantic import validator from antarest.core.exceptions import DuplicateRenewableCluster, RenewableClusterConfigNotFound, RenewableClusterNotFound +from antarest.study.business.all_optional_meta import AllOptionalMetaclass, camel_case_model 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.business.utils import execute_or_add_commands from antarest.study.model import Study from antarest.study.storage.rawstudy.model.filesystem.config.model import transform_name_to_id from antarest.study.storage.rawstudy.model.filesystem.config.renewable import ( diff --git a/antarest/study/business/areas/st_storage_management.py b/antarest/study/business/areas/st_storage_management.py index c6d4e9f868..4ac726dffa 100644 --- a/antarest/study/business/areas/st_storage_management.py +++ b/antarest/study/business/areas/st_storage_management.py @@ -15,7 +15,8 @@ STStorageMatrixNotFound, STStorageNotFound, ) -from antarest.study.business.utils import AllOptionalMetaclass, camel_case_model, execute_or_add_commands +from antarest.study.business.all_optional_meta import AllOptionalMetaclass, camel_case_model +from antarest.study.business.utils import execute_or_add_commands from antarest.study.model import Study from antarest.study.storage.rawstudy.model.filesystem.config.model import transform_name_to_id from antarest.study.storage.rawstudy.model.filesystem.config.st_storage import ( diff --git a/antarest/study/business/areas/thermal_management.py b/antarest/study/business/areas/thermal_management.py index 9fea2c568d..b7cb1182a7 100644 --- a/antarest/study/business/areas/thermal_management.py +++ b/antarest/study/business/areas/thermal_management.py @@ -4,7 +4,8 @@ from pydantic import validator from antarest.core.exceptions import DuplicateThermalCluster, ThermalClusterConfigNotFound, ThermalClusterNotFound -from antarest.study.business.utils import AllOptionalMetaclass, camel_case_model, execute_or_add_commands +from antarest.study.business.all_optional_meta import AllOptionalMetaclass, camel_case_model +from antarest.study.business.utils import execute_or_add_commands from antarest.study.model import Study from antarest.study.storage.rawstudy.model.filesystem.config.model import transform_name_to_id from antarest.study.storage.rawstudy.model.filesystem.config.thermal import ( diff --git a/antarest/study/business/binding_constraint_management.py b/antarest/study/business/binding_constraint_management.py index ac012ed0e0..686eedd8fd 100644 --- a/antarest/study/business/binding_constraint_management.py +++ b/antarest/study/business/binding_constraint_management.py @@ -19,7 +19,8 @@ NoConstraintError, ) 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.business.all_optional_meta import AllOptionalMetaclass, camel_case_model +from antarest.study.business.utils import execute_or_add_commands from antarest.study.model import Study from antarest.study.storage.rawstudy.model.filesystem.config.binding_constraint import BindingConstraintFrequency from antarest.study.storage.rawstudy.model.filesystem.config.model import transform_name_to_id @@ -48,6 +49,8 @@ from antarest.study.storage.variantstudy.model.command.update_binding_constraint import UpdateBindingConstraint from antarest.study.storage.variantstudy.model.dbmodel import VariantStudy +_TERM_MATRICES = ["less_term_matrix", "equal_term_matrix", "greater_term_matrix"] + logger = logging.getLogger(__name__) DEFAULT_GROUP = "default" @@ -298,17 +301,23 @@ def check_matrices_dimensions(cls, values: Dict[str, Any]) -> Dict[str, Any]: class ConstraintOutputBase(BindingConstraintProperties): id: str name: str - terms: MutableSequence[ConstraintTerm] = Field( - default_factory=lambda: [], - ) + terms: MutableSequence[ConstraintTerm] = Field(default_factory=lambda: []) @camel_case_model -class ConstraintOutput870(ConstraintOutputBase): +class ConstraintOutput830(ConstraintOutputBase): + filter_year_by_year: str = "" + filter_synthesis: str = "" + + +@camel_case_model +class ConstraintOutput870(ConstraintOutput830): group: str = DEFAULT_GROUP -ConstraintOutput = Union[ConstraintOutputBase, ConstraintOutput870] +# WARNING: Do not change the order of the following line, it is used to determine +# the type of the output constraint in the FastAPI endpoint. +ConstraintOutput = Union[ConstraintOutputBase, ConstraintOutput830, ConstraintOutput870] def _validate_binding_constraints(file_study: FileStudy, bcs: Sequence[ConstraintOutput]) -> bool: @@ -355,9 +364,7 @@ def __init__( self.storage_service = storage_service @staticmethod - def parse_and_add_terms( - key: str, value: Any, adapted_constraint: Union[ConstraintOutputBase, ConstraintOutput870] - ) -> None: + def parse_and_add_terms(key: str, value: Any, adapted_constraint: ConstraintOutput) -> None: """Parse a single term from the constraint dictionary and add it to the adapted_constraint model.""" if "%" in key or "." in key: separator = "%" if "%" in key else "." @@ -393,24 +400,24 @@ def parse_and_add_terms( @staticmethod def constraint_model_adapter(constraint: Mapping[str, Any], version: int) -> ConstraintOutput: """ - Adapts a constraint configuration to the appropriate version-specific format. + Adapts a binding constraint configuration to the appropriate model version. - Parameters: - - constraint: A dictionary or model representing the constraint to be adapted. - This can either be a dictionary coming from client input or an existing - model that needs reformatting. - - version: An integer indicating the target version of the study configuration. This is used to + Args: + constraint: A dictionary or model representing the constraint to be adapted. + This can either be a dictionary coming from client input or an existing + model that needs reformatting. + version: An integer indicating the target version of the study configuration. This is used to determine which model class to instantiate and which default values to apply. Returns: - - A new instance of either `ConstraintOutputBase` or `ConstraintOutput870`, - populated with the adapted values from the input constraint, and conforming to the - structure expected by the specified version. + A new instance of either `ConstraintOutputBase`, `ConstraintOutput830`, or `ConstraintOutput870`, + populated with the adapted values from the input constraint, and conforming to the + structure expected by the specified version. Note: - This method is crucial for ensuring backward compatibility and future-proofing the application - as it evolves. It allows client-side data to be accurately represented within the config and - ensures data integrity when storing or retrieving constraint configurations from the database. + This method is crucial for ensuring backward compatibility and future-proofing the application + as it evolves. It allows client-side data to be accurately represented within the config and + ensures data integrity when storing or retrieving constraint configurations from the database. """ constraint_output = { @@ -423,19 +430,20 @@ def constraint_model_adapter(constraint: Mapping[str, Any], version: int) -> Con "terms": constraint.get("terms", []), } - # TODO: Implement a model for version-specific fields. Output filters are sent regardless of the version. - if version >= 840: - constraint_output["filter_year_by_year"] = constraint.get("filter_year_by_year") or constraint.get( - "filter-year-by-year", "" - ) - constraint_output["filter_synthesis"] = constraint.get("filter_synthesis") or constraint.get( - "filter-synthesis", "" - ) - - adapted_constraint: Union[ConstraintOutputBase, ConstraintOutput870] + if version >= 830: + _filter_year_by_year = constraint.get("filter_year_by_year") or constraint.get("filter-year-by-year", "") + _filter_synthesis = constraint.get("filter_synthesis") or constraint.get("filter-synthesis", "") + constraint_output["filter_year_by_year"] = _filter_year_by_year + constraint_output["filter_synthesis"] = _filter_synthesis if version >= 870: constraint_output["group"] = constraint.get("group", DEFAULT_GROUP) + + # Choose the right model according to the version + adapted_constraint: ConstraintOutput + if version >= 870: adapted_constraint = ConstraintOutput870(**constraint_output) + elif version >= 830: + adapted_constraint = ConstraintOutput830(**constraint_output) else: adapted_constraint = ConstraintOutputBase(**constraint_output) @@ -648,17 +656,20 @@ def create_binding_constraint( "time_step": data.time_step, "operator": data.operator, "coeffs": self.terms_to_coeffs(data.terms), - "values": data.values, - "less_term_matrix": data.less_term_matrix, - "equal_term_matrix": data.equal_term_matrix, - "greater_term_matrix": data.greater_term_matrix, - "filter_year_by_year": data.filter_year_by_year, - "filter_synthesis": data.filter_synthesis, "comments": data.comments or "", } + if version >= 830: + new_constraint["filter_year_by_year"] = data.filter_year_by_year or "" + new_constraint["filter_synthesis"] = data.filter_synthesis or "" + if version >= 870: new_constraint["group"] = data.group or DEFAULT_GROUP + new_constraint["less_term_matrix"] = data.less_term_matrix + new_constraint["equal_term_matrix"] = data.equal_term_matrix + new_constraint["greater_term_matrix"] = data.greater_term_matrix + else: + new_constraint["values"] = data.values command = CreateBindingConstraint( **new_constraint, command_context=self.storage_service.variant_study_service.command_factory.command_context @@ -699,12 +710,12 @@ def update_binding_constraint( "comments": data.comments or existing_constraint.comments, } - if study_version >= 840: + if isinstance(existing_constraint, ConstraintOutput830): upd_constraint["filter_year_by_year"] = data.filter_year_by_year or existing_constraint.filter_year_by_year upd_constraint["filter_synthesis"] = data.filter_synthesis or existing_constraint.filter_synthesis - if study_version >= 870: - upd_constraint["group"] = data.group or existing_constraint.group # type: ignore + if isinstance(existing_constraint, ConstraintOutput870): + upd_constraint["group"] = data.group or existing_constraint.group args = { **upd_constraint, @@ -723,9 +734,7 @@ def update_binding_constraint( # Validates the matrices. Needed when the study is a variant because we only append the command to the list if isinstance(study, VariantStudy): - updated_matrices = [ - term for term in ["less_term_matrix", "equal_term_matrix", "greater_term_matrix"] if getattr(data, term) - ] + updated_matrices = [term for term in _TERM_MATRICES if getattr(data, term)] command.validates_and_fills_matrices( specific_matrices=updated_matrices, version=study_version, create=False ) @@ -794,15 +803,20 @@ def update_constraint_term( coeffs = {term.id: [term.weight, term.offset] if term.offset else [term.weight] for term in constraint_terms} + filter_year_by_year = constraint.filter_year_by_year if isinstance(constraint, ConstraintOutput830) else None + filter_synthesis = constraint.filter_synthesis if isinstance(constraint, ConstraintOutput830) else None + group = constraint.group if isinstance(constraint, ConstraintOutput870) else None + command = UpdateBindingConstraint( id=constraint.id, enabled=constraint.enabled, time_step=constraint.time_step, operator=constraint.operator, - coeffs=coeffs, - filter_year_by_year=constraint.filter_year_by_year, - filter_synthesis=constraint.filter_synthesis, comments=constraint.comments, + filter_year_by_year=filter_year_by_year, + filter_synthesis=filter_synthesis, + group=group, + coeffs=coeffs, command_context=self.storage_service.variant_study_service.command_factory.command_context, ) execute_or_add_commands(study, file_study, [command], self.storage_service) @@ -840,15 +854,20 @@ def create_constraint_term( if term.offset: coeffs[term.id].append(term.offset) + filter_year_by_year = constraint.filter_year_by_year if isinstance(constraint, ConstraintOutput830) else None + filter_synthesis = constraint.filter_synthesis if isinstance(constraint, ConstraintOutput830) else None + group = constraint.group if isinstance(constraint, ConstraintOutput870) else None + command = UpdateBindingConstraint( id=constraint.id, enabled=constraint.enabled, time_step=constraint.time_step, operator=constraint.operator, - coeffs=coeffs, comments=constraint.comments, - filter_year_by_year=constraint.filter_year_by_year, - filter_synthesis=constraint.filter_synthesis, + filter_year_by_year=filter_year_by_year, + filter_synthesis=filter_synthesis, + group=group, + coeffs=coeffs, command_context=self.storage_service.variant_study_service.command_factory.command_context, ) execute_or_add_commands(study, file_study, [command], self.storage_service) @@ -880,7 +899,7 @@ def _replace_matrices_according_to_frequency_and_version( BindingConstraintFrequency.DAILY.value: default_bc_weekly_daily_87, BindingConstraintFrequency.WEEKLY.value: default_bc_weekly_daily_87, }[data.time_step].tolist() - for term in ["less_term_matrix", "equal_term_matrix", "greater_term_matrix"]: + for term in _TERM_MATRICES: if term not in args: args[term] = matrix return args diff --git a/antarest/study/business/thematic_trimming_field_infos.py b/antarest/study/business/thematic_trimming_field_infos.py index 30d95a9393..490b403f94 100644 --- a/antarest/study/business/thematic_trimming_field_infos.py +++ b/antarest/study/business/thematic_trimming_field_infos.py @@ -4,7 +4,8 @@ import typing as t -from antarest.study.business.utils import AllOptionalMetaclass, FormFieldsBaseModel +from antarest.study.business.all_optional_meta import AllOptionalMetaclass +from antarest.study.business.utils import FormFieldsBaseModel class ThematicTrimmingFormFields(FormFieldsBaseModel, metaclass=AllOptionalMetaclass, use_none=True): diff --git a/antarest/study/business/utils.py b/antarest/study/business/utils.py index 53596bf797..8c4b567b22 100644 --- a/antarest/study/business/utils.py +++ b/antarest/study/business/utils.py @@ -1,7 +1,5 @@ import typing as t -import pydantic.fields -import pydantic.main from pydantic import BaseModel from antarest.core.exceptions import CommandApplicationError @@ -82,90 +80,3 @@ class FieldInfo(t.TypedDict, total=False): encode: t.Optional[t.Callable[[t.Any], t.Any]] # (encoded_value, current_value) -> decoded_value decode: t.Optional[t.Callable[[t.Any, t.Optional[t.Any]], t.Any]] - - -class AllOptionalMetaclass(pydantic.main.ModelMetaclass): - """ - Metaclass that makes all fields of a Pydantic model optional. - - Usage: - class MyModel(BaseModel, metaclass=AllOptionalMetaclass): - field1: str - field2: int - ... - - Instances of the model can be created even if not all fields are provided during initialization. - Default values, when provided, are used unless `use_none` is set to `True`. - """ - - def __new__( - cls: t.Type["AllOptionalMetaclass"], - name: str, - bases: t.Tuple[t.Type[t.Any], ...], - namespaces: t.Dict[str, t.Any], - use_none: bool = False, - **kwargs: t.Dict[str, t.Any], - ) -> t.Any: - """ - Create a new instance of the metaclass. - - Args: - name: Name of the class to create. - bases: Base classes of the class to create (a Pydantic model). - namespaces: namespace of the class to create that defines the fields of the model. - use_none: If `True`, the default value of the fields is set to `None`. - Note that this field is not part of the Pydantic model, but it is an extension. - **kwargs: Additional keyword arguments used by the metaclass. - """ - # Modify the annotations of the class (but not of the ancestor classes) - # in order to make all fields optional. - # If the current model inherits from another model, the annotations of the ancestor models - # are not modified, because the fields are already converted to `ModelField`. - annotations = namespaces.get("__annotations__", {}) - for field_name, field_type in annotations.items(): - if not field_name.startswith("__"): - # Making already optional fields optional is not a problem (nothing is changed). - annotations[field_name] = t.Optional[field_type] - namespaces["__annotations__"] = annotations - - if use_none: - # Modify the namespace fields to set their default value to `None`. - for field_name, field_info in namespaces.items(): - if isinstance(field_info, pydantic.fields.FieldInfo): - field_info.default = None - field_info.default_factory = None - - # Create the class: all annotations are converted into `ModelField`. - instance = super().__new__(cls, name, bases, namespaces, **kwargs) - - # Modify the inherited fields of the class to make them optional - # and set their default value to `None`. - model_field: pydantic.fields.ModelField - for field_name, model_field in instance.__fields__.items(): - model_field.required = False - model_field.allow_none = True - if use_none: - model_field.default = None - model_field.default_factory = None - model_field.field_info.default = None - - return instance - - -MODEL = t.TypeVar("MODEL", bound=t.Type[BaseModel]) - - -def camel_case_model(model: MODEL) -> MODEL: - """ - This decorator can be used to modify a model to use camel case aliases. - - Args: - model: The pydantic model to modify. - - Returns: - The modified model. - """ - model.__config__.alias_generator = to_camel_case - for field_name, field in model.__fields__.items(): - field.alias = to_camel_case(field_name) - return model diff --git a/antarest/study/business/xpansion_management.py b/antarest/study/business/xpansion_management.py index 1bb80cfbaf..22c612af9a 100644 --- a/antarest/study/business/xpansion_management.py +++ b/antarest/study/business/xpansion_management.py @@ -11,8 +11,8 @@ from antarest.core.exceptions import BadZipBinary from antarest.core.model import JSON +from antarest.study.business.all_optional_meta import AllOptionalMetaclass from antarest.study.business.enum_ignore_case import EnumIgnoreCase -from antarest.study.business.utils import AllOptionalMetaclass from antarest.study.model import Study from antarest.study.storage.rawstudy.model.filesystem.bucket_node import BucketNode from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy diff --git a/antarest/study/storage/variantstudy/business/command_extractor.py b/antarest/study/storage/variantstudy/business/command_extractor.py index 9aa5a9b397..2699fdbf26 100644 --- a/antarest/study/storage/variantstudy/business/command_extractor.py +++ b/antarest/study/storage/variantstudy/business/command_extractor.py @@ -354,7 +354,7 @@ def extract_binding_constraint( for coeff, value in binding.items() if "%" in coeff or "." in coeff }, - comments=binding.get("comments", None), + comments=binding.get("comments", ""), command_context=self.command_context, ) study_commands: List[ICommand] = [ diff --git a/antarest/study/storage/variantstudy/business/utils_binding_constraint.py b/antarest/study/storage/variantstudy/business/utils_binding_constraint.py index 0779f7e048..7bc64f60e7 100644 --- a/antarest/study/storage/variantstudy/business/utils_binding_constraint.py +++ b/antarest/study/storage/variantstudy/business/utils_binding_constraint.py @@ -31,22 +31,6 @@ def apply_binding_constraint( group: t.Optional[str] = None, ) -> CommandOutput: version = study_data.config.version - binding_constraints[new_key] = { - "name": name, - "id": bd_id, - "enabled": enabled, - "type": freq.value, - "operator": operator.value, - } - if group: - binding_constraints[new_key]["group"] = group - if version >= 830: - if filter_year_by_year: - binding_constraints[new_key]["filter-year-by-year"] = filter_year_by_year - if filter_synthesis: - binding_constraints[new_key]["filter-synthesis"] = filter_synthesis - if comments is not None: - binding_constraints[new_key]["comments"] = comments for link_or_cluster in coeffs: if "%" in link_or_cluster: 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 8d666db596..fcdd4a2544 100644 --- a/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py +++ b/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py @@ -1,3 +1,4 @@ +import json import typing as t from abc import ABCMeta @@ -73,15 +74,18 @@ def check_matrix_values(time_step: BindingConstraintFrequency, values: MatrixTyp class BindingConstraintProperties(BaseModel, extra=Extra.forbid, allow_population_by_field_name=True): enabled: bool = True - time_step: BindingConstraintFrequency = BindingConstraintFrequency.HOURLY + time_step: BindingConstraintFrequency = Field(BindingConstraintFrequency.HOURLY, alias="type") operator: BindingConstraintOperator = BindingConstraintOperator.EQUAL - comments: t.Optional[str] = "" - filter_year_by_year: t.Optional[str] = "" - filter_synthesis: t.Optional[str] = "" + comments: str = "" -class BindingConstraintProperties870(BindingConstraintProperties): - group: t.Optional[str] = "default" +class BindingConstraintProperties830(BindingConstraintProperties): + filter_year_by_year: str = Field("", alias="filter-year-by-year") + filter_synthesis: str = Field("", alias="filter-synthesis") + + +class BindingConstraintProperties870(BindingConstraintProperties830): + group: str = "default" class BindingConstraintMatrices(BaseModel, extra=Extra.forbid, allow_population_by_field_name=True): @@ -145,6 +149,12 @@ def to_dto(self) -> CommandDTO: "filter_synthesis": self.filter_synthesis, } + # The `filter_year_by_year` and `filter_synthesis` attributes are only available for studies since v8.3 + if self.filter_synthesis: + args["filter_synthesis"] = self.filter_synthesis + if self.filter_year_by_year: + args["filter_year_by_year"] = self.filter_year_by_year + # The `group` attribute is only available for studies since v8.7 if self.group: args["group"] = self.group @@ -239,7 +249,18 @@ def _apply(self, study_data: FileStudy) -> CommandOutput: binding_constraints = study_data.tree.get(["input", "bindingconstraints", "bindingconstraints"]) new_key = len(binding_constraints) bd_id = transform_name_to_id(self.name) - self.validates_and_fills_matrices(specific_matrices=None, version=study_data.config.version, create=True) + study_version = study_data.config.version + self.validates_and_fills_matrices(specific_matrices=None, version=study_version, create=True) + + include = {"name", "enabled", "time_step", "operator", "comments"} + if study_version >= 830: + include |= {"filter_year_by_year", "filter_synthesis"} + if study_version >= 870: + include |= {"group"} + + obj = json.loads(self.json(by_alias=True, include=include)) + + binding_constraints[str(new_key)] = {"id": bd_id, **obj} return apply_binding_constraint( study_data, @@ -303,12 +324,15 @@ def _create_diff(self, other: "ICommand") -> t.List["ICommand"]: "time_step": other.time_step, "operator": other.operator, "coeffs": other.coeffs, - "filter_year_by_year": other.filter_year_by_year, - "filter_synthesis": other.filter_synthesis, "comments": other.comments, "command_context": other.command_context, - "group": other.group, } + if other.filter_year_by_year: + args["filter_year_by_year"] = other.filter_year_by_year + if other.filter_synthesis: + args["filter_synthesis"] = other.filter_synthesis + if other.group and self.group != "default": + args["group"] = self.group matrix_service = self.command_context.matrix_service for matrix_name in ["values", "less_term_matrix", "equal_term_matrix", "greater_term_matrix"]: diff --git a/antarest/study/storage/variantstudy/model/command/update_binding_constraint.py b/antarest/study/storage/variantstudy/model/command/update_binding_constraint.py index 70ad16702f..a842f8c1de 100644 --- a/antarest/study/storage/variantstudy/model/command/update_binding_constraint.py +++ b/antarest/study/storage/variantstudy/model/command/update_binding_constraint.py @@ -1,7 +1,10 @@ +import json +from enum import Enum from typing import Any, Dict, List, Optional, Tuple from antarest.core.model import JSON from antarest.matrixstore.model import MatrixData +from antarest.study.business.all_optional_meta import AllOptionalMetaclass from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfig from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy from antarest.study.storage.variantstudy.business.utils_binding_constraint import apply_binding_constraint @@ -10,12 +13,14 @@ from antarest.study.storage.variantstudy.model.command.icommand import MATCH_SIGNATURE_SEPARATOR, ICommand from antarest.study.storage.variantstudy.model.model import CommandDTO -__all__ = ("UpdateBindingConstraint",) - MatrixType = List[List[MatrixData]] -class UpdateBindingConstraint(AbstractBindingConstraintCommand): +class _UpdateBindingConstraintBase(AbstractBindingConstraintCommand, metaclass=AllOptionalMetaclass, use_none=True): + pass + + +class UpdateBindingConstraint(_UpdateBindingConstraintBase): """ Command used to update a binding constraint. """ @@ -51,10 +56,22 @@ def _apply(self, study_data: FileStudy) -> CommandOutput: message="Failed to retrieve existing binding constraint", ) - # fmt: off - updated_matrices = [term for term in ["less_term_matrix", "equal_term_matrix", "greater_term_matrix"] if self.__getattribute__(term)] - self.validates_and_fills_matrices(specific_matrices=updated_matrices or None, version=study_data.config.version, create=False) - # fmt: on + field_names = ["less_term_matrix", "equal_term_matrix", "greater_term_matrix"] + updated_matrices = [term for term in field_names if hasattr(self, term)] + study_version = study_data.config.version + self.validates_and_fills_matrices( + specific_matrices=updated_matrices or None, version=study_version, create=False + ) + + include = {"enabled", "time_step", "operator", "comments"} + if study_version >= 830: + include |= {"filter_year_by_year", "filter_synthesis"} + if study_version >= 870: + include |= {"group"} + + obj = json.loads(self.json(by_alias=True, include=include, exclude_none=True)) + + binding_constraints[str(new_key)].update(obj) return apply_binding_constraint( study_data, diff --git a/tests/study/business/test_all_optional_metaclass.py b/tests/study/business/test_all_optional_metaclass.py index 2e83a4e433..1c379f6460 100644 --- a/tests/study/business/test_all_optional_metaclass.py +++ b/tests/study/business/test_all_optional_metaclass.py @@ -3,7 +3,7 @@ import pytest from pydantic import BaseModel, Field, ValidationError -from antarest.study.business.utils import AllOptionalMetaclass +from antarest.study.business.all_optional_meta import AllOptionalMetaclass # ============================================== # Classic way to use default and optional values diff --git a/tests/variantstudy/model/command/test_manage_binding_constraints.py b/tests/variantstudy/model/command/test_manage_binding_constraints.py index fc124bdb40..8ba22fc7b9 100644 --- a/tests/variantstudy/model/command/test_manage_binding_constraints.py +++ b/tests/variantstudy/model/command/test_manage_binding_constraints.py @@ -79,7 +79,8 @@ def test_manage_binding_constraint(empty_study: FileStudy, command_context: Comm cfg_path = study_path / "input/bindingconstraints/bindingconstraints.ini" bd_config = IniReader().read(cfg_path) - assert bd_config.get("0") == { + + expected_bd_1 = { "name": "BD 1", "id": "bd 1", "enabled": True, @@ -88,7 +89,7 @@ def test_manage_binding_constraint(empty_study: FileStudy, command_context: Comm "operator": "less", "type": "hourly", } - assert bd_config.get("1") == { + expected_bd_2 = { "name": "BD 2", "id": "bd 2", "enabled": False, @@ -97,6 +98,17 @@ def test_manage_binding_constraint(empty_study: FileStudy, command_context: Comm "operator": "both", "type": "daily", } + if empty_study.config.version >= 830: + expected_bd_1["filter-year-by-year"] = "" + expected_bd_1["filter-synthesis"] = "" + expected_bd_2["filter-year-by-year"] = "" + expected_bd_2["filter-synthesis"] = "" + if empty_study.config.version >= 870: + expected_bd_1["group"] = "default" + expected_bd_2["group"] = "default" + + assert bd_config.get("0") == expected_bd_1 + assert bd_config.get("1") == expected_bd_2 if empty_study.config.version < 870: weekly_values = default_bc_weekly_daily.tolist() @@ -123,15 +135,21 @@ def test_manage_binding_constraint(empty_study: FileStudy, command_context: Comm res = bind_update.apply(empty_study) assert res.status bd_config = IniReader().read(cfg_path) - assert bd_config.get("0") == { + expected_bd_1 = { "name": "BD 1", "id": "bd 1", "enabled": False, + "comments": "Hello", # comments are not updated "area1%area2": "800.0%30", - "comments": "", "operator": "both", "type": "weekly", } + if empty_study.config.version >= 830: + expected_bd_1["filter-year-by-year"] = "" + expected_bd_1["filter-synthesis"] = "" + if empty_study.config.version >= 870: + expected_bd_1["group"] = "default" + assert bd_config.get("0") == expected_bd_1 remove_bind = RemoveBindingConstraint(id="bd 1", command_context=command_context) res3 = remove_bind.apply(empty_study) @@ -148,7 +166,7 @@ def test_manage_binding_constraint(empty_study: FileStudy, command_context: Comm bd_config = IniReader().read(cfg_path) assert len(bd_config) == 1 - assert bd_config.get("0") == { + expected_bd_2 = { "name": "BD 2", "id": "bd 2", "enabled": False, @@ -157,6 +175,12 @@ def test_manage_binding_constraint(empty_study: FileStudy, command_context: Comm "operator": "both", "type": "daily", } + if empty_study.config.version >= 830: + expected_bd_2["filter-year-by-year"] = "" + expected_bd_2["filter-synthesis"] = "" + if empty_study.config.version >= 870: + expected_bd_2["group"] = "default" + assert bd_config.get("0") == expected_bd_2 def test_match(command_context: CommandContext): @@ -343,6 +367,8 @@ def test_revert(command_context: CommandContext): values=hourly_matrix_id, comments="", command_context=command_context, + filter_year_by_year="", + filter_synthesis="", ) ] study = FileStudy(config=Mock(), tree=Mock()) @@ -379,6 +405,7 @@ def test_create_diff(command_context: CommandContext): enabled=True, time_step=BindingConstraintFrequency.HOURLY, operator=BindingConstraintOperator.EQUAL, + comments="", coeffs={"b": [0.3]}, values=matrix_b_id, command_context=command_context,