diff --git a/alembic/versions/dadad513f1b6_create_links_ts_generation_tables.py b/alembic/versions/dadad513f1b6_create_links_ts_generation_tables.py new file mode 100644 index 0000000000..116924106e --- /dev/null +++ b/alembic/versions/dadad513f1b6_create_links_ts_generation_tables.py @@ -0,0 +1,59 @@ +"""create_links_ts_generation_tables + +Revision ID: dadad513f1b6 +Revises: bae9c99bc42d +Create Date: 2025-01-20 10:11:01.293931 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'dadad513f1b6' +down_revision = 'bae9c99bc42d' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "nb_years_ts_generation", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("links", sa.Integer(), server_default="1", nullable=False), + sa.ForeignKeyConstraint( + ["id"], + ["study.id"], + ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + sa.Index('ix_nb_years_ts_generation_study_id', 'study_id') + ) + + default_boolean = sa.text('1') if op.get_context().dialect.name == 'sqlite' else 't' + op.create_table( + "links_parameters_ts_generation", + sa.Column("id", sa.Integer()), + sa.Column("area_from", sa.String(), nullable=False), + sa.Column("area_to", sa.String(), nullable=False), + sa.Column("prepro", sa.String(), nullable=True), + sa.Column("modulation", sa.String(), nullable=True), + sa.Column("unit_count", sa.Integer(), nullable=False, server_default="1"), + sa.Column("nominal_capacity", sa.Float(), nullable=False, server_default="0"), + sa.Column("law_planned", sa.Enum('uniform', 'geometric', name='lawplanned'), nullable=False), + sa.Column("law_forced", sa.Enum('uniform', 'geometric', name='lawforced'), nullable=False), + sa.Column("volatility_planned", sa.String(), nullable=False, server_default="0"), + sa.Column("volatility_forced", sa.String(), nullable=False, server_default="0"), + sa.Column("force_no_generation", sa.Boolean(), nullable=False, server_default=default_boolean), + sa.Column("study_id", sa.String(length=36), nullable=False), + sa.ForeignKeyConstraint( + ["study_id"], + ["study.id"], + ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id") + ) + + +def downgrade(): + op.drop_table("nb_years_ts_generation") + op.drop_table("links_parameters_ts_generation") diff --git a/antarest/study/business/link_management.py b/antarest/study/business/link_management.py index 36ad03a795..9532558db9 100644 --- a/antarest/study/business/link_management.py +++ b/antarest/study/business/link_management.py @@ -16,9 +16,10 @@ from antarest.core.exceptions import LinkNotFound from antarest.core.model import JSON -from antarest.study.business.model.link_model import LinkBaseDTO, LinkDTO, LinkInternal +from antarest.core.utils.fastapi_sqlalchemy import db +from antarest.study.business.model.link_model import LinkBaseDTO, LinkDTO, LinkInternal, LinkTsGeneration from antarest.study.business.utils import execute_or_add_commands -from antarest.study.model import RawStudy, Study +from antarest.study.model import LinksParametersTsGeneration, RawStudy, Study from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy from antarest.study.storage.storage_service import StudyStorageService from antarest.study.storage.variantstudy.model.command.create_link import CreateLink @@ -34,6 +35,8 @@ def get_all_links(self, study: Study) -> List[LinkDTO]: file_study = self.storage_service.get_storage(study).get_raw(study) result: List[LinkDTO] = [] + ts_generation_parameters = self.get_all_links_ts_generation_information(study.id) + for area_id, area in file_study.config.areas.items(): links_config = file_study.tree.get(["input", "links", area_id, "properties"]) @@ -41,6 +44,10 @@ def get_all_links(self, study: Study) -> List[LinkDTO]: link_tree_config: Dict[str, Any] = links_config[link] link_tree_config.update({"area1": area_id, "area2": link}) + if area_id in ts_generation_parameters and link in ts_generation_parameters[area_id]: + link_ts_generation = ts_generation_parameters[area_id][link] + link_tree_config.update(link_ts_generation.model_dump(mode="json")) + link_internal = LinkInternal.model_validate(link_tree_config) result.append(link_internal.to_dto()) @@ -54,10 +61,38 @@ def get_link(self, study: RawStudy, link: LinkInternal) -> LinkInternal: link_properties.update({"area1": link.area1, "area2": link.area2}) + ts_generation_parameters = self.get_single_link_ts_generation_information(study.id, link.area1, link.area2) + link_properties.update(ts_generation_parameters.model_dump(mode="json")) + updated_link = LinkInternal.model_validate(link_properties) return updated_link + @staticmethod + def get_all_links_ts_generation_information(study_id: str) -> dict[str, dict[str, LinkTsGeneration]]: + db_dictionnary: dict[str, dict[str, LinkTsGeneration]] = {} + with db(): + all_links_parameters: list[LinksParametersTsGeneration] = ( + db.session.query(LinksParametersTsGeneration).filter_by(study_id=study_id).all() + ) + for link_parameters in all_links_parameters: + area_from = link_parameters.area_from + area_to = link_parameters.area_to + db_dictionnary.setdefault(area_from, {})[area_to] = LinkTsGeneration.from_db_model(link_parameters) + return db_dictionnary + + @staticmethod + def get_single_link_ts_generation_information(study_id: str, area_from: str, area_to: str) -> LinkTsGeneration: + with db(): + links_parameters = ( + db.session.query(LinksParametersTsGeneration) + .filter_by(study_id=study_id, area_from=area_from, area_to=area_to) + .first() + ) + if links_parameters: + return LinkTsGeneration.from_db_model(links_parameters) + return LinkTsGeneration() + def create_link(self, study: Study, link_creation_dto: LinkDTO) -> LinkDTO: link = link_creation_dto.to_internal(StudyVersion.parse(study.version)) diff --git a/antarest/study/business/model/link_model.py b/antarest/study/business/model/link_model.py index a7f286fb6a..5dca95af6d 100644 --- a/antarest/study/business/model/link_model.py +++ b/antarest/study/business/model/link_model.py @@ -19,7 +19,8 @@ from antarest.core.serde import AntaresBaseModel from antarest.core.utils.string import to_camel_case, to_kebab_case from antarest.study.business.enum_ignore_case import EnumIgnoreCase -from antarest.study.model import STUDY_VERSION_8_2 +from antarest.study.model import STUDY_VERSION_8_2, LinksParametersTsGeneration +from antarest.study.storage.rawstudy.model.filesystem.config.thermal import LawOption class AssetType(EnumIgnoreCase): @@ -139,7 +140,7 @@ def join_with_comma(values: List[FilterOption]) -> str: ] -class LinkBaseDTO(AntaresBaseModel): +class LinkIniDTO(AntaresBaseModel): model_config = ConfigDict(alias_generator=to_camel_case, populate_by_name=True, extra="forbid") hurdles_cost: bool = False @@ -158,6 +159,49 @@ class LinkBaseDTO(AntaresBaseModel): filter_year_by_year: Optional[comma_separated_enum_list] = field(default_factory=lambda: FILTER_VALUES) +class LinkTsGeneration(AntaresBaseModel): + model_config = ConfigDict(alias_generator=to_camel_case, populate_by_name=True, extra="forbid") + + unit_count: int = 1 + nominal_capacity: float = 0 + law_planned: LawOption = LawOption.UNIFORM + law_forced: LawOption = LawOption.UNIFORM + volatility_planned: float = Field(default=0.0, ge=0, le=1) + volatility_forced: float = Field(default=0.0, ge=0, le=1) + force_no_generation: bool = True + + @staticmethod + def from_db_model(links_parameters_db: LinksParametersTsGeneration) -> "LinkTsGeneration": + args = { + "unit_count": links_parameters_db.unit_count, + "nominal_capacity": links_parameters_db.nominal_capacity, + "law_planned": links_parameters_db.law_planned, + "law_forced": links_parameters_db.law_forced, + "volatility_planned": links_parameters_db.volatility_planned, + "volatility_forced": links_parameters_db.volatility_forced, + "force_no_generation": links_parameters_db.force_no_generation, + } + return LinkTsGeneration.model_validate(args) + + def to_db_model(self, study_id: str, area_from: str, area_to: str) -> LinksParametersTsGeneration: + return LinksParametersTsGeneration( + study_id=study_id, + area_from=area_from, + area_to=area_to, + unit_count=self.unit_count, + nominal_capacity=self.nominal_capacity, + law_planned=self.law_planned, + law_forced=self.law_forced, + volatility_planned=self.volatility_planned, + volatility_forced=self.volatility_forced, + force_no_generation=self.force_no_generation, + ) + + +class LinkBaseDTO(LinkIniDTO, LinkTsGeneration): + pass + + class Area(AntaresBaseModel): area1: str area2: str @@ -177,7 +221,7 @@ def to_internal(self, version: StudyVersion) -> "LinkInternal": if version < STUDY_VERSION_8_2 and {"filter_synthesis", "filter_year_by_year"} & self.model_fields_set: raise LinkValidationError("Cannot specify a filter value for study's version earlier than v8.2") - data = self.model_dump() + data = self.model_dump(mode="json") if version < STUDY_VERSION_8_2: data["filter_synthesis"] = None @@ -206,6 +250,15 @@ class LinkInternal(AntaresBaseModel): filter_synthesis: Optional[comma_separated_enum_list] = field(default_factory=lambda: FILTER_VALUES) filter_year_by_year: Optional[comma_separated_enum_list] = field(default_factory=lambda: FILTER_VALUES) + # Ts-generation part + unit_count: int = 1 + nominal_capacity: float = 0 + law_planned: LawOption = LawOption.UNIFORM + law_forced: LawOption = LawOption.UNIFORM + volatility_planned: float = Field(default=0.0, ge=0, le=1) + volatility_forced: float = Field(default=0.0, ge=0, le=1) + force_no_generation: bool = True + def to_dto(self) -> LinkDTO: data = self.model_dump() return LinkDTO(**data) diff --git a/antarest/study/model.py b/antarest/study/model.py index a07be97d1a..efa2e970bb 100644 --- a/antarest/study/model.py +++ b/antarest/study/model.py @@ -25,6 +25,7 @@ Column, DateTime, Enum, + Float, ForeignKey, Integer, PrimaryKeyConstraint, @@ -40,6 +41,7 @@ from antarest.core.serde import AntaresBaseModel from antarest.login.model import Group, GroupDTO, Identity from antarest.study.css4_colors import COLOR_NAMES +from antarest.study.storage.rawstudy.model.filesystem.config.thermal import LawOption if TYPE_CHECKING: # avoid circular import @@ -90,6 +92,43 @@ } +class NbYearsTsGeneration(Base): # type:ignore + """ + A table to store how many columns needs to be generated by category and by study + + Attributes: + id: A foreign key on the study ID. + links: An integer representing how many columns needs to be generated during links TS generation. + """ + + __tablename__ = "nb_years_ts_generation" + + id: str = Column( + String(36), ForeignKey("study.id", ondelete="CASCADE"), primary_key=True, index=True, nullable=False + ) + links: int = Column(Integer, default=1, nullable=False) + + +class LinksParametersTsGeneration(Base): # type:ignore + """A table to store for each link of a study, the input parameters to give to the TS generation algorithm""" + + __tablename__ = "links_parameters_ts_generation" + + id = Column(Integer, primary_key=True, unique=True) + study_id = Column(String(36), ForeignKey("study.id", ondelete="CASCADE"), index=True, nullable=False) + area_from = Column(String(255), nullable=False) + area_to = Column(String(255), nullable=False) + prepro = Column(String(255), nullable=True) + modulation = Column(String(255), nullable=True) + unit_count = Column(Integer, nullable=False, default=1) + nominal_capacity = Column(Float, nullable=False, default=0) + law_planned = Column(Enum(LawOption), default=LawOption.UNIFORM, nullable=False) + law_forced = Column(Enum(LawOption), default=LawOption.UNIFORM, nullable=False) + volatility_planned = Column(Float, nullable=False, default=0) + volatility_forced = Column(Float, nullable=False, default=0) + force_no_generation = Column(Boolean, nullable=False, default=True) + + class StudyGroup(Base): # type:ignore """ A table to manage the many-to-many relationship between `Study` and `Group` diff --git a/antarest/study/storage/variantstudy/model/command/create_link.py b/antarest/study/storage/variantstudy/model/command/create_link.py index 7f5154986b..98022e6acc 100644 --- a/antarest/study/storage/variantstudy/model/command/create_link.py +++ b/antarest/study/storage/variantstudy/model/command/create_link.py @@ -17,10 +17,11 @@ from typing_extensions import override from antarest.core.exceptions import LinkValidationError +from antarest.core.utils.fastapi_sqlalchemy import db from antarest.core.utils.utils import assert_this from antarest.matrixstore.model import MatrixData -from antarest.study.business.model.link_model import LinkInternal -from antarest.study.model import STUDY_VERSION_8_2 +from antarest.study.business.model.link_model import Area, LinkInternal, LinkTsGeneration +from antarest.study.model import STUDY_VERSION_8_2, LinksParametersTsGeneration from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfig, Link from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy from antarest.study.storage.variantstudy.business.utils import strip_matrix_protocol, validate_matrix @@ -216,19 +217,38 @@ def _apply(self, study_data: FileStudy, listener: Optional[ICommandListener] = N if not output.status: return output - to_exclude = {"area1", "area2"} - if version < STUDY_VERSION_8_2: - to_exclude.update("filter-synthesis", "filter-year-by-year") - - validated_properties = LinkInternal.model_validate(self.parameters).model_dump( - by_alias=True, exclude=to_exclude - ) - + internal_link = LinkInternal.model_validate(self.parameters) area_from = data["area_from"] area_to = data["area_to"] - study_data.tree.save(validated_properties, ["input", "links", area_from, "properties", area_to]) + # Saves ini properties + to_exclude = set(Area.model_fields.keys() | LinkTsGeneration.model_fields.keys()) + if version < STUDY_VERSION_8_2: + to_exclude.update("filter-synthesis", "filter-year-by-year") + ini_properties = internal_link.model_dump(by_alias=True, exclude=to_exclude) + study_data.tree.save(ini_properties, ["input", "links", area_from, "properties", area_to]) + + # Saves DB properties + includes = set(LinkTsGeneration.model_fields.keys()) + db_properties = LinkTsGeneration.model_validate(internal_link.model_dump(mode="json", include=includes)) + + with db(): + new_parameters = LinksParametersTsGeneration( + study_id=study_data.config.study_id, + area_from=area_from, + area_to=area_to, + unit_count=db_properties.unit_count, + nominal_capacity=db_properties.nominal_capacity, + law_planned=db_properties.law_planned, + law_forced=db_properties.law_forced, + volatility_planned=db_properties.volatility_planned, + volatility_forced=db_properties.volatility_forced, + force_no_generation=db_properties.force_no_generation, + ) + db.session.add(new_parameters) + db.session.commit() + # Saves matrices self.series = self.series or (self.command_context.generator_matrix_constants.get_link(version=version)) self.direct = self.direct or (self.command_context.generator_matrix_constants.get_link_direct()) self.indirect = self.indirect or (self.command_context.generator_matrix_constants.get_link_indirect()) diff --git a/antarest/study/storage/variantstudy/model/command/remove_link.py b/antarest/study/storage/variantstudy/model/command/remove_link.py index 93c0eb58f6..ed02757604 100644 --- a/antarest/study/storage/variantstudy/model/command/remove_link.py +++ b/antarest/study/storage/variantstudy/model/command/remove_link.py @@ -15,7 +15,8 @@ from pydantic import field_validator, model_validator from typing_extensions import override -from antarest.study.model import STUDY_VERSION_8_2 +from antarest.core.utils.fastapi_sqlalchemy import db +from antarest.study.model import STUDY_VERSION_8_2, LinksParametersTsGeneration from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfig, transform_name_to_id from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy from antarest.study.storage.variantstudy.model.command.common import CommandName, CommandOutput @@ -103,12 +104,8 @@ def _apply_config(self, study_cfg: FileStudyTreeConfig) -> OutputTuple: A tuple containing the command output and a dictionary of extra data. On success, the dictionary contains the source and target areas. """ - output, data = self._check_link_exists(study_cfg) - - if output.status: - del study_cfg.areas[self.area1].links[self.area2] - - return output, data + del study_cfg.areas[self.area1].links[self.area2] + return CommandOutput(status=True, message=f"Link between '{self.area1}' and '{self.area2}' removed"), {} def _remove_link_from_scenario_builder(self, study_data: FileStudy) -> None: """ @@ -140,17 +137,28 @@ def _apply(self, study_data: FileStudy, listener: Optional[ICommandListener] = N """ output = self._check_link_exists(study_data.config)[0] + if not output.status: + return output - if output.status: - if study_data.config.version < STUDY_VERSION_8_2: - study_data.tree.delete(["input", "links", self.area1, self.area2]) - else: - study_data.tree.delete(["input", "links", self.area1, f"{self.area2}_parameters"]) - study_data.tree.delete(["input", "links", self.area1, "capacities", f"{self.area2}_direct"]) - study_data.tree.delete(["input", "links", self.area1, "capacities", f"{self.area2}_indirect"]) - study_data.tree.delete(["input", "links", self.area1, "properties", self.area2]) - - self._remove_link_from_scenario_builder(study_data) + if study_data.config.version < STUDY_VERSION_8_2: + study_data.tree.delete(["input", "links", self.area1, self.area2]) + else: + study_data.tree.delete(["input", "links", self.area1, f"{self.area2}_parameters"]) + study_data.tree.delete(["input", "links", self.area1, "capacities", f"{self.area2}_direct"]) + study_data.tree.delete(["input", "links", self.area1, "capacities", f"{self.area2}_indirect"]) + study_data.tree.delete(["input", "links", self.area1, "properties", self.area2]) + + self._remove_link_from_scenario_builder(study_data) + + with db(): + removed_link = ( + db.session.query(LinksParametersTsGeneration) + .filter_by(study_id=study_data.config.study_id, area_from=self.area1, area_to=self.area2) + .first() + ) + if removed_link: # The DB can be empty for the considered study as we're filling it on the fly + db.session.delete(removed_link) + db.session.commit() return self._apply_config(study_data.config)[0] diff --git a/antarest/study/storage/variantstudy/model/command/update_link.py b/antarest/study/storage/variantstudy/model/command/update_link.py index 742ff54d4a..3ef7880cb8 100644 --- a/antarest/study/storage/variantstudy/model/command/update_link.py +++ b/antarest/study/storage/variantstudy/model/command/update_link.py @@ -13,7 +13,9 @@ from typing_extensions import override -from antarest.study.business.model.link_model import LinkInternal +from antarest.core.utils.fastapi_sqlalchemy import db +from antarest.study.business.model.link_model import Area, LinkInternal, LinkTsGeneration +from antarest.study.model import STUDY_VERSION_8_2, LinksParametersTsGeneration from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfig from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy from antarest.study.storage.variantstudy.model.command.common import CommandName, CommandOutput @@ -47,25 +49,58 @@ def _apply_config(self, study_data: FileStudyTreeConfig) -> OutputTuple: @override def _apply(self, study_data: FileStudy, listener: Optional[ICommandListener] = None) -> CommandOutput: version = study_data.config.version + area_from, area_to = sorted([self.area1, self.area2]) - properties = study_data.tree.get(["input", "links", self.area1, "properties", self.area2]) + internal_link = LinkInternal.model_validate(self.parameters) - new_properties = LinkInternal.model_validate(self.parameters).model_dump(include=self.parameters, by_alias=True) - - properties.update(new_properties) - - study_data.tree.save(properties, ["input", "links", self.area1, "properties", self.area2]) + # Updates ini properties + to_exclude = set(Area.model_fields.keys() | LinkTsGeneration.model_fields.keys()) + if version < STUDY_VERSION_8_2: + to_exclude.update("filter-synthesis", "filter-year-by-year") + new_ini_properties = internal_link.model_dump(by_alias=True, exclude=to_exclude, exclude_unset=True) + if new_ini_properties: # If no new INI properties were given we shouldn't update the INI file + properties = study_data.tree.get(["input", "links", area_from, "properties", area_to]) + properties.update(new_ini_properties) + study_data.tree.save(properties, ["input", "links", area_from, "properties", area_to]) output, _ = self._apply_config(study_data.config) + # Updates DB properties + includes = set(LinkTsGeneration.model_fields.keys()) + db_properties_json = internal_link.model_dump(mode="json", include=includes, exclude_unset=True) + if db_properties_json: # If no new DB properties were given we shouldn't update the DB + study_id = study_data.config.study_id + with db(): + old_parameters = ( + db.session.query(LinksParametersTsGeneration) + .filter_by(study_id=study_id, area_from=area_from, area_to=area_to) + .first() + ) + if not old_parameters: + db_properties = LinkTsGeneration.model_validate(db_properties_json) + new_parameters = db_properties.to_db_model(study_id, area_from, area_to) + else: + old_props = LinkTsGeneration.from_db_model(old_parameters).model_dump(mode="json") + old_props.update(db_properties_json) + new_parameters = LinkTsGeneration.model_validate(old_props).to_db_model( + study_id, area_from, area_to + ) + # We should keep the same matrices + new_parameters.modulation = old_parameters.modulation + new_parameters.project = old_parameters.prepro + db.session.delete(old_parameters) + db.session.add(new_parameters) + db.session.commit() + + # Updates matrices if self.series: - self.save_series(self.area1, self.area2, study_data, version) + self.save_series(area_from, area_to, study_data, version) if self.direct: - self.save_direct(self.area1, self.area2, study_data, version) + self.save_direct(area_from, area_to, study_data, version) if self.indirect: - self.save_indirect(self.area1, self.area2, study_data, version) + self.save_indirect(area_from, area_to, study_data, version) return output diff --git a/antarest/study/storage/variantstudy/snapshot_generator.py b/antarest/study/storage/variantstudy/snapshot_generator.py index 60e726f2e0..35210a574e 100644 --- a/antarest/study/storage/variantstudy/snapshot_generator.py +++ b/antarest/study/storage/variantstudy/snapshot_generator.py @@ -24,7 +24,8 @@ from antarest.core.jwt import JWTUser from antarest.core.model import StudyPermissionType from antarest.core.tasks.service import ITaskNotifier, NoopNotifier -from antarest.study.model import RawStudy, StudyAdditionalData +from antarest.core.utils.fastapi_sqlalchemy import db +from antarest.study.model import LinksParametersTsGeneration, RawStudy, StudyAdditionalData from antarest.study.storage.patch_service import PatchService from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfigDTO from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy, StudyFactory @@ -126,6 +127,7 @@ def generate_snapshot( logger.info(f"Reading additional data from files for study {file_study.config.study_id}") variant_study.additional_data = self._read_additional_data(file_study) self.repository.save(variant_study) + self._copy_parent_ts_generation_info(parent_study_id=ref_study.id, variant_study_id=variant_study_id) self._update_cache(file_study) @@ -218,6 +220,21 @@ def _update_cache(self, file_study: FileStudy) -> None: FileStudyTreeConfigDTO.from_build_config(file_study.config).model_dump(), ) + @staticmethod + def _copy_parent_ts_generation_info(parent_study_id: str, variant_study_id: str) -> None: + with db(): + parent_parameters: list[LinksParametersTsGeneration] = ( + db.session.query(LinksParametersTsGeneration).filter_by(study_id=parent_study_id).all() + ) + if not parent_parameters: + return + child_parameters = [] + for parameter in parent_parameters: + parameter.study_id = variant_study_id + child_parameters.append(parameter) + db.session.add_all(child_parameters) + db.session.commit() + class RefStudySearchResult(NamedTuple): """ diff --git a/antarest/study/storage/variantstudy/variant_command_generator.py b/antarest/study/storage/variantstudy/variant_command_generator.py index d30b77ece0..a4fd855f73 100644 --- a/antarest/study/storage/variantstudy/variant_command_generator.py +++ b/antarest/study/storage/variantstudy/variant_command_generator.py @@ -122,7 +122,8 @@ def generate( ) -> GenerationResultInfoDTO: # Build file study logger.info("Building study tree") - study = self.study_factory.create_from_fs(dest_path, "", use_cache=False) + study_id = metadata.id if metadata else "" + study = self.study_factory.create_from_fs(dest_path, study_id, use_cache=False) if metadata: update_antares_info(metadata, study.tree, update_author=True) diff --git a/antarest/study/storage/variantstudy/variant_study_service.py b/antarest/study/storage/variantstudy/variant_study_service.py index 69f3d43731..41be5b32ea 100644 --- a/antarest/study/storage/variantstudy/variant_study_service.py +++ b/antarest/study/storage/variantstudy/variant_study_service.py @@ -52,7 +52,15 @@ from antarest.core.utils.utils import assert_this, suppress_exception from antarest.login.model import Identity from antarest.matrixstore.service import MatrixService -from antarest.study.model import RawStudy, Study, StudyAdditionalData, StudyMetadataDTO, StudySimResultDTO +from antarest.study.model import ( + LinksParametersTsGeneration, + NbYearsTsGeneration, + RawStudy, + Study, + StudyAdditionalData, + StudyMetadataDTO, + StudySimResultDTO, +) from antarest.study.repository import AccessPermissions, StudyFilter from antarest.study.storage.abstract_storage_service import AbstractStorageService from antarest.study.storage.patch_service import PatchService @@ -431,8 +439,16 @@ def invalidate_cache( invalidate_self_snapshot: bool = False, ) -> None: remove_from_cache(self.cache, variant_study.id) - if isinstance(variant_study, VariantStudy) and variant_study.snapshot and invalidate_self_snapshot: - variant_study.snapshot.last_executed_command = None + if isinstance(variant_study, VariantStudy): + # Removes TS-generation related information from the database + with db(): + db.session.query(NbYearsTsGeneration).filter_by(id=variant_study.id).delete() + db.session.query(LinksParametersTsGeneration).filter_by(study_id=variant_study.id).delete() + db.session.commit() + + if variant_study.snapshot and invalidate_self_snapshot: + variant_study.snapshot.last_executed_command = None + self.repository.save( metadata=variant_study, update_modification_date=True, diff --git a/tests/integration/study_data_blueprint/test_link.py b/tests/integration/study_data_blueprint/test_link.py index 9585ad674d..3a8a63ecab 100644 --- a/tests/integration/study_data_blueprint/test_link.py +++ b/tests/integration/study_data_blueprint/test_link.py @@ -9,87 +9,52 @@ # SPDX-License-Identifier: MPL-2.0 # # This file is part of the Antares project. +import copy import re import pytest from starlette.testclient import TestClient -from antarest.study.business.model.link_model import TransmissionCapacity from tests.integration.prepare_proxy import PreparerProxy @pytest.mark.unit_test class TestLink: @pytest.mark.parametrize("study_type", ["raw", "variant"]) - def test_link_update(self, client: TestClient, user_access_token: str, study_type: str) -> None: + def test_nominal_case(self, client: TestClient, user_access_token: str, study_type: str) -> None: + # ============================= + # SET UP + # ============================= + client.headers = {"Authorization": f"Bearer {user_access_token}"} # type: ignore preparer = PreparerProxy(client, user_access_token) - study_id = preparer.create_study("foo", version=820) + study_id = preparer.create_study("foo") if study_type == "variant": study_id = preparer.create_variant(study_id, name="Variant 1") + links_url = f"/v1/studies/{study_id}/links" + area1_id = preparer.create_area(study_id, name="Area 1")["id"] area2_id = preparer.create_area(study_id, name="Area 2")["id"] - client.post( - f"/v1/studies/{study_id}/links", - json={"area1": area1_id, "area2": area2_id, "hurdlesCost": True, "comments": "comment"}, - ) - res = client.put( - f"/v1/studies/{study_id}/links/{area1_id}/{area2_id}", - json={"colorr": 150}, - ) - - assert res.status_code == 200 - expected = { - "area1": "area 1", - "area2": "area 2", - "assetType": "ac", - "colorb": 112, - "colorg": 112, - "colorr": 150, - "displayComments": True, - "comments": "comment", - "filterSynthesis": "hourly, daily, weekly, monthly, annual", - "filterYearByYear": "hourly, daily, weekly, monthly, annual", - "hurdlesCost": True, - "linkStyle": "plain", - "linkWidth": 1.0, - "loopFlow": False, - "transmissionCapacities": "enabled", - "usePhaseShifter": False, - } - assert expected == res.json() + area3_id = preparer.create_area(study_id, name="Area 3")["id"] - # Test update link same area + # ============================= + # CREATION AND UPDATE + # ============================= - res = client.put( - f"/v1/studies/{study_id}/links/{area1_id}/{area1_id}", - json={"hurdlesCost": False}, - ) - assert res.status_code == 422 - expected = { - "description": "Cannot create a link that goes from and to the same single area: area 1", - "exception": "LinkValidationError", - } - assert expected == res.json() + # Link creation with default values + res = client.post(links_url, json={"area1": area1_id, "area2": area2_id}) - # Test update link area not ordered + assert res.status_code == 200, res.json() - res = client.put( - f"/v1/studies/{study_id}/links/{area2_id}/{area1_id}", - json={"hurdlesCost": False}, - ) - assert res.status_code == 200 - expected = { - "area1": "area 1", - "area2": "area 2", + default_parameters = { "assetType": "ac", "colorb": 112, "colorg": 112, - "colorr": 150, + "colorr": 112, "displayComments": True, - "comments": "comment", + "comments": "", "filterSynthesis": "hourly, daily, weekly, monthly, annual", "filterYearByYear": "hourly, daily, weekly, monthly, annual", "hurdlesCost": False, @@ -98,193 +63,128 @@ def test_link_update(self, client: TestClient, user_access_token: str, study_typ "loopFlow": False, "transmissionCapacities": "enabled", "usePhaseShifter": False, + "volatilityForced": 0.0, + "volatilityPlanned": 0.0, + "lawForced": "uniform", + "lawPlanned": "uniform", + "forceNoGeneration": True, + "nominalCapacity": 0.0, + "unitCount": 1, } - assert expected == res.json() + expected_result = copy.deepcopy(default_parameters) + expected_result["area1"] = area1_id + expected_result["area2"] = area2_id + assert res.json() == expected_result - # Test update link with non existing area - - res = client.put( - f"/v1/studies/{study_id}/links/{area1_id}/id_do_not_exist", - json={"hurdlesCost": False}, + # Link creation with specific values and then updates another value + client.post( + links_url, + json={"area1": area1_id, "area2": area3_id, "hurdlesCost": True, "comments": "comment"}, ) - assert res.status_code == 404 - expected = { - "description": "The link area 1 -> id_do_not_exist is not present in the study", - "exception": "LinkNotFound", - } - assert expected == res.json() + res = client.put(f"/v1/studies/{study_id}/links/{area1_id}/{area3_id}", json={"colorr": 150}) + assert res.status_code == 200 + expected = copy.deepcopy(default_parameters) + expected["area1"] = area1_id + expected["area2"] = area3_id + expected["hurdlesCost"] = True + expected["comments"] = "comment" + expected["colorr"] = 150 + assert res.json() == expected - # Test update link fails when given wrong parameters - if study_type == "raw": - res = client.post( - f"/v1/studies/{study_id}/commands", - json=[ - { - "action": "update_link", - "args": { - "area1": area1_id, - "area2": area2_id, - "parameters": {"hurdles-cost": False, "wrong": "parameter"}, - }, - } - ], - ) - assert res.status_code == 500 - expected = "Unexpected exception occurred when trying to apply command CommandName.UPDATE_LINK" - assert expected in res.json()["description"] + # Test update link area not ordered + + res = client.put(f"/v1/studies/{study_id}/links/{area3_id}/{area1_id}", json={"hurdlesCost": False}) + assert res.status_code == 200 + expected["hurdlesCost"] = False + assert res.json() == expected # Test update link variant returns only modified values if study_type == "variant": res = client.put( - f"/v1/studies/{study_id}/links/{area1_id}/{area2_id}", - json={"hurdlesCost": False}, + f"/v1/studies/{study_id}/links/{area1_id}/{area3_id}", + json={"assetType": "dc"}, ) assert res.status_code == 200 res = client.get(f"/v1/studies/{study_id}/commands") commands = res.json() command_args = commands[-1]["args"] - assert command_args["parameters"] == {"hurdles_cost": False} - - @pytest.mark.parametrize("study_type", ["raw", "variant"]) - def test_link_820(self, client: TestClient, user_access_token: str, study_type: str) -> None: - client.headers = {"Authorization": f"Bearer {user_access_token}"} # type: ignore - - preparer = PreparerProxy(client, user_access_token) - study_id = preparer.create_study("foo", version=820) - if study_type == "variant": - study_id = preparer.create_variant(study_id, name="Variant 1") - - area1_id = preparer.create_area(study_id, name="Area 1")["id"] - area2_id = preparer.create_area(study_id, name="Area 2")["id"] - area3_id = preparer.create_area(study_id, name="Area 3")["id"] + assert command_args["parameters"] == {"asset_type": "dc"} - # Test create link with default values - res = client.post(f"/v1/studies/{study_id}/links", json={"area1": area1_id, "area2": area2_id}) + # Test create link with empty filters + res = client.post(links_url, json={"area1": area2_id, "area2": area3_id, "filterSynthesis": ""}) assert res.status_code == 200, res.json() - - expected = { - "area1": "area 1", - "area2": "area 2", - "assetType": "ac", - "colorb": 112, - "colorg": 112, - "colorr": 112, - "displayComments": True, - "comments": "", - "filterSynthesis": "hourly, daily, weekly, monthly, annual", - "filterYearByYear": "hourly, daily, weekly, monthly, annual", - "hurdlesCost": False, - "linkStyle": "plain", - "linkWidth": 1.0, - "loopFlow": False, - "transmissionCapacities": "enabled", - "usePhaseShifter": False, - } - assert expected == res.json() - res = client.delete(f"/v1/studies/{study_id}/links/{area1_id}/{area2_id}") + expected = copy.deepcopy(default_parameters) + expected["area1"] = area2_id + expected["area2"] = area3_id + expected["filterSynthesis"] = "" + assert res.json() == expected + res = client.delete(f"/v1/studies/{study_id}/links/{area2_id}/{area3_id}") res.raise_for_status() - # Test create link with parameters - - parameters = { - "area1": "area 1", - "area2": "area 2", - "assetType": "ac", - "colorb": 160, - "colorg": 170, - "colorr": 180, - "displayComments": True, - "comments": "comment", - "filterSynthesis": "hourly, daily, weekly, monthly, annual", - "filterYearByYear": "hourly, daily, weekly, monthly, annual", - "hurdlesCost": False, - "linkStyle": "plain", - "linkWidth": 1.0, - "loopFlow": False, - "transmissionCapacities": "enabled", - "usePhaseShifter": False, - } - res = client.post( - f"/v1/studies/{study_id}/links", - json=parameters, - ) + # Test create link with double value in filter + res = client.post(links_url, json={"area1": area2_id, "area2": area3_id, "filterSynthesis": "hourly, hourly"}) assert res.status_code == 200, res.json() + expected = copy.deepcopy(default_parameters) + expected["filterSynthesis"] = "hourly" + expected["area1"] = area2_id + expected["area2"] = area3_id + assert res.json() == expected + + # ============================= + # DELETION + # ============================= + + # Deletes one link and asserts it's not present anymore + res = client.get(links_url) + assert res.status_code == 200, res.json() + assert 3 == len(res.json()) - assert parameters == res.json() - res = client.delete(f"/v1/studies/{study_id}/links/{area1_id}/{area2_id}") - res.raise_for_status() - - # Create two links, count them, then delete one - - res1 = client.post(f"/v1/studies/{study_id}/links", json={"area1": area1_id, "area2": area2_id}) - res2 = client.post(f"/v1/studies/{study_id}/links", json={"area1": area1_id, "area2": area3_id}) - - assert res1.status_code == 200, res1.json() - assert res2.status_code == 200, res2.json() - - res = client.get(f"/v1/studies/{study_id}/links") - + res = client.delete(f"/v1/studies/{study_id}/links/{area2_id}/{area3_id}") assert res.status_code == 200, res.json() - assert 2 == len(res.json()) - res = client.delete(f"/v1/studies/{study_id}/links/{area1_id}/{area3_id}") - res.raise_for_status() + res = client.get(links_url) + assert res.status_code == 200 + assert 2 == len(res.json()) - res = client.get(f"/v1/studies/{study_id}/links") + # ============================= + # ERRORS + # ============================= - assert res.status_code == 200, res.json() - assert 1 == len(res.json()) - client.delete(f"/v1/studies/{study_id}/links/{area1_id}/{area2_id}") - res.raise_for_status() + # Test update link same area - # Test create link with same area + res = client.put(f"/v1/studies/{study_id}/links/{area1_id}/{area1_id}", json={"hurdlesCost": False}) + assert res.status_code == 422 + assert res.json()["description"] == "Cannot create a link that goes from and to the same single area: area 1" + assert res.json()["exception"] == "LinkValidationError" - res = client.post(f"/v1/studies/{study_id}/links", json={"area1": area1_id, "area2": area1_id}) + # Test update link with non-existing area - assert res.status_code == 422, res.json() - expected = { - "description": "Cannot create a link that goes from and to the same single area: area 1", - "exception": "LinkValidationError", - } - assert expected == res.json() + res = client.put(f"/v1/studies/{study_id}/links/{area1_id}/id_do_not_exist", json={"hurdlesCost": False}) + assert res.status_code == 404 + assert res.json()["description"] == "The link area 1 -> id_do_not_exist is not present in the study" + assert res.json()["exception"] == "LinkNotFound" # Test create link with wrong value for enum - res = client.post( - f"/v1/studies/{study_id}/links", - json={"area1": area1_id, "area2": area2_id, "assetType": TransmissionCapacity.ENABLED}, - ) + res = client.post(links_url, json={"area1": area1_id, "area2": area2_id, "assetType": "enabled"}) assert res.status_code == 422, res.json() - expected = { - "body": {"area1": "area 1", "area2": "area 2", "assetType": "enabled"}, - "description": "Input should be 'ac', 'dc', 'gaz', 'virt' or 'other'", - "exception": "RequestValidationError", - } - assert expected == res.json() + assert res.json()["description"] == "Input should be 'ac', 'dc', 'gaz', 'virt' or 'other'" + assert res.json()["exception"] == "RequestValidationError" # Test create link with wrong color parameter - res = client.post(f"/v1/studies/{study_id}/links", json={"area1": area1_id, "area2": area2_id, "colorr": 260}) - + res = client.post(links_url, json={"area1": area1_id, "area2": area2_id, "colorr": 260}) assert res.status_code == 422, res.json() - expected = { - "body": {"area1": "area 1", "area2": "area 2", "colorr": 260}, - "description": "Input should be less than or equal to 255", - "exception": "RequestValidationError", - } - assert expected == res.json() + assert res.json()["description"] == "Input should be less than or equal to 255" + assert res.json()["exception"] == "RequestValidationError" # Test create link with wrong filter parameter - res = client.post( - f"/v1/studies/{study_id}/links", - json={"area1": area1_id, "area2": area2_id, "filterSynthesis": "centurial"}, - ) - + res = client.post(links_url, json={"area1": area1_id, "area2": area2_id, "filterSynthesis": "centurial"}) assert res.status_code == 422, res.json() res_json = res.json() @@ -295,78 +195,73 @@ def test_link_820(self, client: TestClient, user_access_token: str, study_type: expected_values = sorted(["daily", "hourly", "monthly", "weekly", "annual"]) assert res_values == expected_values, f"Returned values: {res_values}, expected: {expected_values}" - # Test create link with empty filters - - res = client.post( - f"/v1/studies/{study_id}/links", - json={"area1": area1_id, "area2": area2_id, "filterSynthesis": ""}, - ) - - assert res.status_code == 200, res.json() - expected = { - "area1": "area 1", - "area2": "area 2", - "assetType": "ac", - "colorb": 112, - "colorg": 112, - "colorr": 112, - "displayComments": True, - "comments": "", - "filterSynthesis": "", - "filterYearByYear": "hourly, daily, weekly, monthly, annual", - "hurdlesCost": False, - "linkStyle": "plain", - "linkWidth": 1.0, - "loopFlow": False, - "transmissionCapacities": "enabled", - "usePhaseShifter": False, - } - assert expected == res.json() - - # Test create link with double value in filter - - client.delete(f"/v1/studies/{study_id}/links/{area1_id}/{area2_id}") - res = client.post( - f"/v1/studies/{study_id}/links", - json={"area1": area1_id, "area2": area2_id, "filterSynthesis": "hourly, hourly"}, - ) + # Test update link command fails when given wrong parameters - assert res.status_code == 200, res.json() - expected = { - "area1": "area 1", - "area2": "area 2", - "assetType": "ac", - "colorb": 112, - "colorg": 112, - "colorr": 112, - "displayComments": True, - "comments": "", - "filterSynthesis": "hourly", - "filterYearByYear": "hourly, daily, weekly, monthly, annual", - "hurdlesCost": False, - "linkStyle": "plain", - "linkWidth": 1.0, - "loopFlow": False, - "transmissionCapacities": "enabled", - "usePhaseShifter": False, - } - assert expected == res.json() + if study_type == "raw": + res = client.post( + f"/v1/studies/{study_id}/commands", + json=[ + { + "action": "update_link", + "args": { + "area1": area1_id, + "area2": area2_id, + "parameters": {"hurdles-cost": False, "wrong": "parameter"}, + }, + } + ], + ) + assert res.status_code == 500 + expected = "Unexpected exception occurred when trying to apply command CommandName.UPDATE_LINK" + assert expected in res.json()["description"] - def test_create_link_810(self, client: TestClient, user_access_token: str) -> None: + def test_other_behaviors(self, client: TestClient, user_access_token: str) -> None: client.headers = {"Authorization": f"Bearer {user_access_token}"} # type: ignore preparer = PreparerProxy(client, user_access_token) study_id = preparer.create_study("foo", version=810) area1_id = preparer.create_area(study_id, name="Area 1")["id"] area2_id = preparer.create_area(study_id, name="Area 2")["id"] + links_url = f"/v1/studies/{study_id}/links" + + # Asserts we cannot give a filter value to a study prior to v8.2 + res = client.post(links_url, json={"area1": area1_id, "area2": area2_id, "filterSynthesis": "hourly"}) + assert res.status_code == 422, res.json() + assert res.json()["description"] == "Cannot specify a filter value for study's version earlier than v8.2" + assert res.json()["exception"] == "LinkValidationError" + + # ============================= + # TS GENERATION + # ============================= + # Creates a link inside parent study with a specific unit count + res = client.post(links_url, json={"area1": area1_id, "area2": area2_id, "unitCount": 24}) + assert res.status_code == 200, res.json() + # Asserts the value was saved correctly + res = client.get(links_url) + assert res.json()[0]["unitCount"] == 24 + # Creates a variant + variant_id = preparer.create_variant(study_id, name="Variant 1") + # Asserts we still have the parent value + res = client.get(f"/v1/studies/{variant_id}/links") + assert res.json()[0]["unitCount"] == 24 + # Modifies the unitCount value. The command is only appended not applied so the data isn't saved in DB res = client.post( - f"/v1/studies/{study_id}/links", json={"area1": area1_id, "area2": area2_id, "filterSynthesis": "hourly"} + f"/v1/studies/{study_id}/commands", + json=[ + { + "action": "update_link", + "args": { + "area1": area1_id, + "area2": area2_id, + "parameters": {"unit-count": 12}, + }, + } + ], ) - - assert res.status_code == 422, res.json() - expected = { - "description": "Cannot specify a filter value for study's version earlier than v8.2", - "exception": "LinkValidationError", - } - assert expected == res.json() + assert res.status_code == 200, res.json() + # Creates a variant of level 2 + level_2_variant_id = preparer.create_variant(variant_id, name="Variant 2") + # Asserts we see the right value + res = client.get(f"/v1/studies/{level_2_variant_id}/links") + assert res.json()[0]["unitCount"] == 12 diff --git a/tests/integration/study_data_blueprint/test_table_mode.py b/tests/integration/study_data_blueprint/test_table_mode.py index f2e53ba347..4ab7d9d1dd 100644 --- a/tests/integration/study_data_blueprint/test_table_mode.py +++ b/tests/integration/study_data_blueprint/test_table_mode.py @@ -194,16 +194,23 @@ def test_lifecycle__nominal( "colorb", "colorg", "colorr", - "displayComments", "comments", + "displayComments", "filterSynthesis", "filterYearByYear", + "forceNoGeneration", "hurdlesCost", + "lawForced", + "lawPlanned", "linkStyle", "linkWidth", "loopFlow", + "nominalCapacity", "transmissionCapacities", + "unitCount", "usePhaseShifter", + "volatilityForced", + "volatilityPlanned", } # Test links @@ -247,30 +254,44 @@ def test_lifecycle__nominal( "colorb": 100, "colorg": 150, "colorr": 200, - "displayComments": False, "comments": "", + "displayComments": False, + "forceNoGeneration": True, "hurdlesCost": True, + "lawForced": "uniform", + "lawPlanned": "uniform", "linkStyle": "plain", - "linkWidth": 2, + "linkWidth": 2.0, "loopFlow": False, + "nominalCapacity": 0.0, "transmissionCapacities": "ignore", + "unitCount": 1, "usePhaseShifter": False, + "volatilityForced": 0.0, + "volatilityPlanned": 0.0, }, "de / it": { "area1": "de", "area2": "it", "assetType": "ac", - "colorr": 112, - "colorg": 112, "colorb": 112, - "displayComments": True, + "colorg": 112, + "colorr": 112, "comments": "", + "displayComments": True, + "forceNoGeneration": True, "hurdlesCost": False, + "lawForced": "uniform", + "lawPlanned": "uniform", "linkStyle": "plain", - "linkWidth": 1, + "linkWidth": 1.0, "loopFlow": False, + "nominalCapacity": 0.0, "transmissionCapacities": "enabled", + "unitCount": 1, "usePhaseShifter": False, + "volatilityForced": 0.0, + "volatilityPlanned": 0.0, }, "es / fr": { "area1": "es", @@ -279,14 +300,21 @@ def test_lifecycle__nominal( "colorb": 100, "colorg": 150, "colorr": 200, - "displayComments": True, "comments": "", + "displayComments": True, + "forceNoGeneration": True, "hurdlesCost": True, + "lawForced": "uniform", + "lawPlanned": "uniform", "linkStyle": "plain", - "linkWidth": 1, + "linkWidth": 1.0, "loopFlow": False, + "nominalCapacity": 0.0, "transmissionCapacities": "enabled", + "unitCount": 1, "usePhaseShifter": True, + "volatilityForced": 0.0, + "volatilityPlanned": 0.0, }, "fr / it": { "area1": "fr", @@ -295,14 +323,21 @@ def test_lifecycle__nominal( "colorb": 112, "colorg": 112, "colorr": 112, - "displayComments": True, "comments": "", + "displayComments": True, + "forceNoGeneration": True, "hurdlesCost": True, + "lawForced": "uniform", + "lawPlanned": "uniform", "linkStyle": "plain", - "linkWidth": 1, + "linkWidth": 1.0, "loopFlow": False, + "nominalCapacity": 0.0, "transmissionCapacities": "enabled", + "unitCount": 1, "usePhaseShifter": False, + "volatilityForced": 0.0, + "volatilityPlanned": 0.0, }, } diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index 003aca1828..43e4d8ac58 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -613,6 +613,13 @@ def test_area_management(client: TestClient, admin_access_token: str) -> None: "loopFlow": False, "transmissionCapacities": "enabled", "usePhaseShifter": False, + "forceNoGeneration": True, + "lawForced": "uniform", + "lawPlanned": "uniform", + "nominalCapacity": 0.0, + "unitCount": 1, + "volatilityForced": 0.0, + "volatilityPlanned": 0.0, } ] diff --git a/tests/storage/business/test_arealink_manager.py b/tests/storage/business/test_arealink_manager.py index 2763827a0c..95021c8267 100644 --- a/tests/storage/business/test_arealink_manager.py +++ b/tests/storage/business/test_arealink_manager.py @@ -13,7 +13,7 @@ import json import uuid from pathlib import Path -from unittest.mock import Mock, patch +from unittest.mock import Mock from zipfile import ZipFile import pytest @@ -100,11 +100,10 @@ def test_area_crud(empty_study: FileStudy, matrix_service: SimpleMatrixService): link_manager = LinkManager(storage_service=storage_service) # Check `AreaManager` behaviour with a RAW study - study_id = str(uuid.uuid4()) # noinspection PyArgumentList study_version = empty_study.config.version study = RawStudy( - id=study_id, + id=empty_study.config.study_id, path=str(empty_study.config.study_path), additional_data=StudyAdditionalData(), version="820", @@ -199,10 +198,7 @@ def test_area_crud(empty_study: FileStudy, matrix_service: SimpleMatrixService): area_manager.create_area(study, AreaCreationDTO(name="test2", type=AreaType.AREA)) link_manager.create_link( study, - LinkDTO( - area1="test", - area2="test2", - ), + LinkDTO(area1="test", area2="test2", asset_type=AssetType.DC, unit_count=4), ) variant_study_service.append_commands.assert_called_with( variant_id, @@ -219,7 +215,7 @@ def test_area_crud(empty_study: FileStudy, matrix_service: SimpleMatrixService): "loop_flow": False, "use_phase_shifter": False, "transmission_capacities": TransmissionCapacity.ENABLED, - "asset_type": AssetType.AC, + "asset_type": AssetType.DC, "display_comments": True, "comments": "", "colorr": 112, @@ -229,6 +225,13 @@ def test_area_crud(empty_study: FileStudy, matrix_service: SimpleMatrixService): "link_style": LinkStyle.PLAIN, "filter_synthesis": "hourly, daily, weekly, monthly, annual", "filter_year_by_year": "hourly, daily, weekly, monthly, annual", + "force_no_generation": True, + "law_forced": "uniform", + "law_planned": "uniform", + "nominal_capacity": 0.0, + "unit_count": 4, + "volatility_forced": 0.0, + "volatility_planned": 0.0, }, }, study_version=study_version, @@ -268,6 +271,13 @@ def test_area_crud(empty_study: FileStudy, matrix_service: SimpleMatrixService): "colorb": 112, "link_width": 1.0, "link_style": LinkStyle.PLAIN, + "force_no_generation": True, + "law_forced": "uniform", + "law_planned": "uniform", + "nominal_capacity": 0.0, + "unit_count": 1, + "volatility_forced": 0.0, + "volatility_planned": 0.0, }, }, study_version=study_version, @@ -408,7 +418,7 @@ def test_get_all_area(): }, ] areas = area_manager.get_all_areas(study, AreaType.AREA) - assert expected_areas == [area.model_dump() for area in areas] + assert [area.model_dump() for area in areas] == expected_areas expected_clusters = [ { @@ -510,7 +520,7 @@ def test_get_all_area(): }, ] links = link_manager.get_all_links(study) - assert [ + assert [link.model_dump(mode="json") for link in links] == [ { "area1": "a1", "area2": "a2", @@ -528,6 +538,13 @@ def test_get_all_area(): "loop_flow": False, "transmission_capacities": "enabled", "use_phase_shifter": False, + "force_no_generation": True, + "law_forced": "uniform", + "law_planned": "uniform", + "nominal_capacity": 0.0, + "unit_count": 1, + "volatility_forced": 0.0, + "volatility_planned": 0.0, }, { "area1": "a1", @@ -546,6 +563,13 @@ def test_get_all_area(): "loop_flow": False, "transmission_capacities": "enabled", "use_phase_shifter": False, + "force_no_generation": True, + "law_forced": "uniform", + "law_planned": "uniform", + "nominal_capacity": 0.0, + "unit_count": 1, + "volatility_forced": 0.0, + "volatility_planned": 0.0, }, { "area1": "a2", @@ -564,8 +588,15 @@ def test_get_all_area(): "loop_flow": False, "transmission_capacities": "enabled", "use_phase_shifter": False, + "force_no_generation": True, + "law_forced": "uniform", + "law_planned": "uniform", + "nominal_capacity": 0.0, + "unit_count": 1, + "volatility_forced": 0.0, + "volatility_planned": 0.0, }, - ] == [link.model_dump(mode="json") for link in links] + ] def test_update_area(): @@ -647,8 +678,8 @@ def test_update_clusters(): { "a": { "name": "A", - "unitcount": 1, - "nominalcapacity": 500, + "unit_count": 1, + "nominal_capacity": 500, "min-stable-power": 200, } } diff --git a/tests/study/storage/variantstudy/test_snapshot_generator.py b/tests/study/storage/variantstudy/test_snapshot_generator.py index 6956502504..4b2c5ca4c7 100644 --- a/tests/study/storage/variantstudy/test_snapshot_generator.py +++ b/tests/study/storage/variantstudy/test_snapshot_generator.py @@ -876,13 +876,16 @@ def test_generate__nominal_case( ) # Check: the number of database queries is kept as low as possible. - # We expect 5 queries: + # We expect 6 queries: # - 1 query to fetch the ancestors of a variant study, # - 1 query to fetch the root study (with owner and groups for permission check), # - 1 query to fetch the list of variants with snapshot, commands, etc., # - 1 query to update the variant study additional_data, # - 1 query to insert the variant study snapshot. - assert len(db_recorder.sql_statements) == 5, str(db_recorder) + # - 1 query to fetch the TS generation info of the parent study + # - 1 query to insert them for the newly created variant study + # - 1 query to check the DB state inside the create_link command + assert len(db_recorder.sql_statements) == 8, str(db_recorder) # Check: the variant generation must succeed. assert results.model_dump() == { diff --git a/tests/study/storage/variantstudy/test_variant_study_service.py b/tests/study/storage/variantstudy/test_variant_study_service.py index 54e1db01ab..43e48a7760 100644 --- a/tests/study/storage/variantstudy/test_variant_study_service.py +++ b/tests/study/storage/variantstudy/test_variant_study_service.py @@ -12,9 +12,8 @@ import datetime import re -import typing from pathlib import Path -from unittest.mock import Mock, patch +from unittest.mock import Mock import numpy as np import pytest @@ -29,7 +28,7 @@ from antarest.login.utils import current_user_context from antarest.matrixstore.service import SimpleMatrixService from antarest.study.business.utils import execute_or_add_commands -from antarest.study.model import RawStudy, StudyAdditionalData +from antarest.study.model import LinksParametersTsGeneration, NbYearsTsGeneration, RawStudy, StudyAdditionalData from antarest.study.storage.patch_service import PatchService from antarest.study.storage.rawstudy.model.filesystem.config.st_storage import STStorageConfig, STStorageGroup from antarest.study.storage.rawstudy.raw_study_service import RawStudyService @@ -401,6 +400,16 @@ def utcnow(cls) -> datetime.datetime: variant_list[2].last_access = datetime.datetime.utcnow() - datetime.timedelta(hours=1) db.session.commit() + # Fills the DB with TS gen information to ensure it will be removed with the snapshot cleaning + study_id = variant_list[0].id + ts_gen_link_info = LinksParametersTsGeneration(study_id=study_id, area_from="from", area_to="to") + db.session.add(ts_gen_link_info) + db.session.commit() + + nb_years_ts_gen_info = NbYearsTsGeneration(id=study_id, links=4) + db.session.add(nb_years_ts_gen_info) + db.session.commit() + # Clear old snapshots task_id = variant_study_service.clear_all_snapshots( datetime.timedelta(hours=5), @@ -432,3 +441,10 @@ def utcnow(cls) -> datetime.datetime: nb_snapshot_dir = 0 # after the for iterations, must equal 0 for variant_path in variant_study_path.iterdir(): assert not variant_path.joinpath("snapshot").exists() + + # Ensures that the DB was emptied by the snapshot cleaning + ts_gen_properties = db.session.query(LinksParametersTsGeneration).filter_by(study_id=study_id).all() + assert not ts_gen_properties + + nb_years_properties = db.session.query(NbYearsTsGeneration).filter_by(id=study_id).all() + assert not nb_years_properties diff --git a/tests/variantstudy/model/command/test_create_link.py b/tests/variantstudy/model/command/test_create_link.py index ed886e8908..cd728625b4 100644 --- a/tests/variantstudy/model/command/test_create_link.py +++ b/tests/variantstudy/model/command/test_create_link.py @@ -12,10 +12,11 @@ import pytest from pydantic import ValidationError +from sqlalchemy.orm import Session from antarest.core.exceptions import LinkValidationError from antarest.core.serde.ini_reader import IniReader -from antarest.study.model import STUDY_VERSION_8_8 +from antarest.study.model import STUDY_VERSION_8_8, LinksParametersTsGeneration, RawStudy from antarest.study.storage.rawstudy.model.filesystem.config.model import transform_name_to_id from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy from antarest.study.storage.variantstudy.model.command.create_area import CreateArea @@ -47,9 +48,14 @@ def test_validation(self, empty_study: FileStudy, command_context: CommandContex study_version=STUDY_VERSION_8_8, ) - def test_apply(self, empty_study: FileStudy, command_context: CommandContext): + def test_apply(self, empty_study: FileStudy, command_context: CommandContext, db_session: Session): study_version = empty_study.config.version study_path = empty_study.config.study_path + study_id = empty_study.config.study_id + raw_study = RawStudy(id=study_id, version=str(study_version), path=str(study_path)) + db_session.add(raw_study) + db_session.commit() + area1 = "Area1" area1_id = transform_name_to_id(area1) @@ -149,6 +155,7 @@ def test_apply(self, empty_study: FileStudy, command_context: CommandContext): "display-comments": True, "filter-synthesis": "hourly", "filter-year-by-year": "hourly", + "unit_count": 56, } create_link_command: ICommand = CreateLink.model_validate( @@ -193,6 +200,21 @@ def test_apply(self, empty_study: FileStudy, command_context: CommandContext): assert int(link_data[area3_id]["colorg"]) == parameters["colorg"] assert int(link_data[area3_id]["colorb"]) == parameters["colorb"] assert link_data[area3_id]["display-comments"] == parameters["display-comments"] + assert "unit_count" not in link_data[area3_id] # asserts a DB field is not present inside the INI file + + # Checks the DB state + ts_gen_properties = ( + db_session.query(LinksParametersTsGeneration) + .filter_by(study_id=study_id, area_from=area1_id, area_to=area3_id) + .all() + ) + assert len(ts_gen_properties) == 1 + link_ts_gen_props = ts_gen_properties[0] + assert link_ts_gen_props.unit_count == parameters["unit_count"] + # Asserts the other values correspond to their default values + assert link_ts_gen_props.nominal_capacity == 0 + assert link_ts_gen_props.volatility_forced == 0 + assert link_ts_gen_props.prepro is None output = create_link_command.apply( study_data=empty_study, diff --git a/tests/variantstudy/model/command/test_manage_binding_constraints.py b/tests/variantstudy/model/command/test_manage_binding_constraints.py index e70ffc29a9..c6f6f38201 100644 --- a/tests/variantstudy/model/command/test_manage_binding_constraints.py +++ b/tests/variantstudy/model/command/test_manage_binding_constraints.py @@ -11,8 +11,10 @@ # This file is part of the Antares project. import pytest +from sqlalchemy.orm import Session from antarest.core.serde.ini_reader import IniReader +from antarest.study.model import STUDY_VERSION_8_8, RawStudy from antarest.study.storage.rawstudy.model.filesystem.config.binding_constraint import ( BindingConstraintFrequency, BindingConstraintOperator, @@ -223,13 +225,17 @@ def test_manage_binding_constraint(empty_study: FileStudy, command_context: Comm @pytest.mark.parametrize("empty_study", ["empty_study_870.zip"], indirect=True) -def test_scenario_builder(empty_study: FileStudy, command_context: CommandContext): +def test_scenario_builder(empty_study: FileStudy, command_context: CommandContext, db_session: Session): """ Test that the scenario builder is updated when a binding constraint group is renamed or removed """ # This test requires a study with version >= 870, which support "scenarised" binding constraints. study_version = empty_study.config.version assert study_version >= 870 + study_id = empty_study.config.study_id + raw_study = RawStudy(id=study_id, version=str(study_version), path=str(empty_study.config.study_path)) + db_session.add(raw_study) + db_session.commit() # Create two areas and a link between them: areas = {name: transform_name_to_id(name) for name in ["Area X", "Area Y"]} diff --git a/tests/variantstudy/model/command/test_remove_area.py b/tests/variantstudy/model/command/test_remove_area.py index a29cd693ec..76dc2e5015 100644 --- a/tests/variantstudy/model/command/test_remove_area.py +++ b/tests/variantstudy/model/command/test_remove_area.py @@ -12,8 +12,9 @@ import pytest from checksumdir import dirhash +from sqlalchemy.orm import Session -from antarest.study.model import STUDY_VERSION_8_8 +from antarest.study.model import STUDY_VERSION_8_8, RawStudy from antarest.study.storage.rawstudy.model.filesystem.config.binding_constraint import ( BindingConstraintFrequency, BindingConstraintOperator, @@ -78,10 +79,15 @@ def test_remove_with_aggregated(self, empty_study: FileStudy, command_context: C assert output.status, output.message @pytest.mark.parametrize("empty_study", ["empty_study_810.zip", "empty_study_840.zip"], indirect=True) - def test_apply(self, empty_study: FileStudy, command_context: CommandContext): + def test_apply(self, empty_study: FileStudy, command_context: CommandContext, db_session: Session): # noinspection SpellCheckingInspection (empty_study, area_id) = self._set_up(empty_study, command_context) study_version = empty_study.config.version + study_path = empty_study.config.study_path + + raw_study = RawStudy(id=empty_study.config.study_id, version=str(study_version), path=str(study_path)) + db_session.add(raw_study) + db_session.commit() create_district_command = CreateDistrict( name="foo", @@ -110,8 +116,8 @@ def test_apply(self, empty_study: FileStudy, command_context: CommandContext): ######################################################################################## # Line ending of the `settings/scenariobuilder.dat` must be reset before checksum - reset_line_separator(empty_study.config.study_path.joinpath("settings/scenariobuilder.dat")) - hash_before_removal = dirhash(empty_study.config.study_path, "md5") + reset_line_separator(study_path.joinpath("settings/scenariobuilder.dat")) + hash_before_removal = dirhash(study_path, "md5") empty_study_cfg = empty_study.tree.get(depth=999) if study_version >= 830: @@ -237,7 +243,7 @@ def test_apply(self, empty_study: FileStudy, command_context: CommandContext): ) output = remove_area_command.apply(study_data=empty_study) assert output.status, output.message - assert dirhash(empty_study.config.study_path, "md5") == hash_before_removal + assert dirhash(study_path, "md5") == hash_before_removal actual_cfg = empty_study.tree.get(depth=999) assert actual_cfg == empty_study_cfg diff --git a/tests/variantstudy/model/command/test_remove_link.py b/tests/variantstudy/model/command/test_remove_link.py index e65934738f..239ddef8f3 100644 --- a/tests/variantstudy/model/command/test_remove_link.py +++ b/tests/variantstudy/model/command/test_remove_link.py @@ -20,8 +20,9 @@ import pytest from checksumdir import dirhash from pydantic import ValidationError +from sqlalchemy.orm import Session -from antarest.study.model import STUDY_VERSION_8_8 +from antarest.study.model import STUDY_VERSION_8_8, LinksParametersTsGeneration, RawStudy from antarest.study.storage.rawstudy.model.filesystem.config.files import build from antarest.study.storage.rawstudy.model.filesystem.config.model import transform_name_to_id from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy @@ -92,9 +93,15 @@ def make_study(tmpdir: Path, version: int) -> FileStudy: return FileStudy(config, FileStudyTree(Mock(), config)) @pytest.mark.parametrize("version", [810, 820]) - def test_apply(self, tmpdir: Path, command_context: CommandContext, version: int) -> None: + def test_apply(self, tmpdir: Path, command_context: CommandContext, version: int, db_session: Session) -> None: empty_study = self.make_study(tmpdir, version) study_version = empty_study.config.version + study_path = empty_study.config.study_path + study_id = empty_study.config.study_id + + raw_study = RawStudy(id=study_id, version=str(study_version), path=str(study_path)) + db_session.add(raw_study) + db_session.commit() # Create some areas areas = {transform_name_to_id(area, lower=True): area for area in ["Area_X", "Area_Y", "Area_Z"]} @@ -121,8 +128,8 @@ def test_apply(self, tmpdir: Path, command_context: CommandContext, version: int ######################################################################################## # Line ending of the `settings/scenariobuilder.dat` must be reset before checksum - reset_line_separator(empty_study.config.study_path.joinpath("settings/scenariobuilder.dat")) - hash_before_removal = dirhash(empty_study.config.study_path, "md5") + reset_line_separator(study_path.joinpath("settings/scenariobuilder.dat")) + hash_before_removal = dirhash(study_path, "md5") # Create a link between Area_X and Area_Z output = CreateLink( @@ -138,9 +145,25 @@ def test_apply(self, tmpdir: Path, command_context: CommandContext, version: int ).apply(study_data=empty_study) assert output.status, output.message + # Ensures that the DB isn't empty + ts_gen_properties = ( + db_session.query(LinksParametersTsGeneration) + .filter_by(study_id=study_id, area_from="area_x", area_to="area_z") + .all() + ) + assert len(ts_gen_properties) == 1 + output = RemoveLink( area1="area_x", area2="area_z", command_context=command_context, study_version=study_version ).apply(empty_study) assert output.status, output.message + # Ensures that the DB was emptied by the command + ts_gen_properties = ( + db_session.query(LinksParametersTsGeneration) + .filter_by(study_id=study_id, area_from="area_x", area_to="area_z") + .all() + ) + assert not ts_gen_properties + assert dirhash(empty_study.config.study_path, "md5") == hash_before_removal diff --git a/tests/variantstudy/model/command/test_update_link.py b/tests/variantstudy/model/command/test_update_link.py new file mode 100644 index 0000000000..351f314656 --- /dev/null +++ b/tests/variantstudy/model/command/test_update_link.py @@ -0,0 +1,107 @@ +# Copyright (c) 2025, RTE (https://www.rte-france.com) +# +# See AUTHORS.txt +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# SPDX-License-Identifier: MPL-2.0 +# +# This file is part of the Antares project. + +from antarest.core.serde.ini_reader import IniReader +from antarest.core.utils.fastapi_sqlalchemy import db +from antarest.study.model import LinksParametersTsGeneration, RawStudy +from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy +from antarest.study.storage.variantstudy.model.command.create_area import CreateArea +from antarest.study.storage.variantstudy.model.command.create_link import CreateLink +from antarest.study.storage.variantstudy.model.command.update_link import UpdateLink +from antarest.study.storage.variantstudy.model.command_context import CommandContext +from tests.db_statement_recorder import DBStatementRecorder +from tests.helpers import with_db_context + + +class TestUpdateLink: + @with_db_context + def test_apply(self, empty_study: FileStudy, command_context: CommandContext): + # ============================= + # SET UP + # ============================= + + study_version = empty_study.config.version + study_path = empty_study.config.study_path + study_id = empty_study.config.study_id + raw_study = RawStudy(id=study_id, version=str(study_version), path=str(study_path)) + db.session.add(raw_study) + db.session.commit() + + area1_id = "area1" + area2_id = "area2" + + CreateArea.model_validate( + {"area_name": area1_id, "command_context": command_context, "study_version": study_version} + ).apply(empty_study) + + CreateArea.model_validate( + {"area_name": area2_id, "command_context": command_context, "study_version": study_version} + ).apply(empty_study) + + # ============================= + # NOMINAL CASES + # ============================= + + # Link creation + parameters = { + "hurdles-cost": True, + "asset-type": "dc", + "link-width": 12, + "colorr": 120, + "unit_count": 56, + "law_planned": "geometric", + } + + command_parameters = { + "area1": area2_id, + "area2": area1_id, + "command_context": command_context, + "study_version": study_version, + } + creation_parameters = {"parameters": parameters, **command_parameters} + CreateLink.model_validate(creation_parameters).apply(study_data=empty_study) + + # Updating an Ini property + new_parameters = {"colorb": 35} + update_parameters = {"parameters": new_parameters, **command_parameters} + + with DBStatementRecorder(db.session.bind) as db_recorder: + UpdateLink.model_validate(update_parameters).apply(study_data=empty_study) + # We shouldn't call the DB as no DB parameter were given + assert len(db_recorder.sql_statements) == 0 + + # Asserts the ini file is well modified (new value + old values unmodified) + link = IniReader() + ini_path = study_path / "input" / "links" / area1_id / "properties.ini" + link_data = link.read(ini_path) + assert link_data[area2_id]["hurdles-cost"] == parameters["hurdles-cost"] + assert link_data[area2_id]["asset-type"] == parameters["asset-type"] + assert link_data[area2_id]["colorb"] == new_parameters["colorb"] + + # Updating a DB property + new_parameters = {"nominal_capacity": 111} + update_parameters = {"parameters": new_parameters, **command_parameters} + # Removes the ini file to show we don't need it as we're only updating the DB + ini_path.unlink() + UpdateLink.model_validate(update_parameters).apply(study_data=empty_study) + + # Checks the DB state. Old properties should remain the same and the new one should be updated + ts_gen_properties = ( + db.session.query(LinksParametersTsGeneration) + .filter_by(study_id=study_id, area_from=area1_id, area_to=area2_id) + .all() + ) + assert len(ts_gen_properties) == 1 + link_ts_gen_props = ts_gen_properties[0] + assert link_ts_gen_props.unit_count == parameters["unit_count"] + assert link_ts_gen_props.law_planned == parameters["law_planned"] + assert link_ts_gen_props.nominal_capacity == new_parameters["nominal_capacity"]