diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 8e08ce865c..89b899c5a3 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -9,5 +9,5 @@ jobs: commitlint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: wagoid/commitlint-github-action@v5 diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index 5603c5ffa2..25d2506003 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -15,9 +15,9 @@ jobs: steps: - name: Checkout github repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -37,7 +37,7 @@ jobs: node-version: [18.16.1] steps: - name: Checkout github repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 with: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 50e9abf4fe..86eba91403 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,4 +1,3 @@ -#file: noinspection YAMLSchemaValidation name: deploy on: push: @@ -12,11 +11,11 @@ jobs: strategy: max-parallel: 3 matrix: - os: [ windows-latest, ubuntu-20.04 ] + os: [ windows-latest, ubuntu-20.04, ubuntu-22.04] steps: - name: ๐Ÿ™ Checkout GitHub repo (+ download lfs dependencies) - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 @@ -40,7 +39,7 @@ jobs: NODE_OPTIONS: --max-old-space-size=8192 - name: ๐Ÿ Set up Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: python-version: 3.8 @@ -73,12 +72,12 @@ jobs: - name: ๐Ÿ“ฆ Archive Antares Desktop for Ubuntu # this is the only way to preserve file permission and symlinks - if: matrix.os == 'ubuntu-20.04' + if: matrix.os != 'windows-latest' run: zip -r --symlinks AntaresWeb.zip * working-directory: dist/package - name: ๐Ÿš€ Upload binaries - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: AntaresWeb-${{ matrix.os }}-pkg path: dist/package/AntaresWeb.zip diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fe19702948..1316ff07ca 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,9 +9,9 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Checkout github repo (+ download lfs dependencies) - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: 3.8 - name: Install dependencies @@ -42,9 +42,9 @@ jobs: steps: - name: Checkout github repo (+ download lfs dependencies) - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: 3.8 - name: Install dependencies @@ -60,7 +60,7 @@ jobs: sed -i 's/\/home\/runner\/work\/AntaREST\/AntaREST/\/github\/workspace/g' coverage.xml - name: Archive code coverage results if: matrix.os == 'ubuntu-20.04' - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: python-code-coverage-report path: coverage.xml @@ -72,7 +72,7 @@ jobs: os: [ ubuntu-20.04 ] steps: - name: Checkout github repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 with: @@ -94,9 +94,9 @@ jobs: runs-on: ubuntu-20.04 needs: [ python-test, npm-test ] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Download python coverage report - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: python-code-coverage-report - name: SonarCloud Scan diff --git a/.github/workflows/worker.yml b/.github/workflows/worker.yml new file mode 100644 index 0000000000..4839670d62 --- /dev/null +++ b/.github/workflows/worker.yml @@ -0,0 +1,37 @@ +name: worker +on: + push: + branches: + - "master" + - "worker/**" + +jobs: + binary: + runs-on: windows-latest + + steps: + - name: ๐Ÿ™ Checkout GitHub repo (+ download lfs dependencies) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: ๐Ÿ Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.8 + + - name: ๐Ÿ Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller==5.6.2 + + - name: ๐Ÿ“ฆ Packaging AntaresWebWorker + run: bash ./package_worker.sh + working-directory: scripts + + - name: ๐Ÿš€ Upload binary + uses: actions/upload-artifact@v4 + with: + name: AntaresWebWorker + path: dist/AntaresWebWorker.exe diff --git a/AntaresWebWorker.spec b/AntaresWebWorker.spec new file mode 100644 index 0000000000..8eeb1c1988 --- /dev/null +++ b/AntaresWebWorker.spec @@ -0,0 +1,45 @@ +# -*- mode: python ; coding: utf-8 -*- + +block_cipher = None + +a = Analysis( + ["antarest/worker/archive_worker_service.py"], + pathex=[], + binaries=[], + datas=[("resources", "resources")], + hiddenimports=["pythonjsonlogger.jsonlogger"], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) +pyz = PYZ( + a.pure, + a.zipped_data, + cipher=block_cipher, +) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name="AntaresWebWorker", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) \ No newline at end of file diff --git a/antarest/__init__.py b/antarest/__init__.py index cc584554d2..f4f11de153 100644 --- a/antarest/__init__.py +++ b/antarest/__init__.py @@ -7,9 +7,9 @@ # Standard project metadata -__version__ = "2.17.1" +__version__ = "2.17.2" __author__ = "RTE, Antares Web Team" -__date__ = "2024-06-10" +__date__ = "2024-06-19" # noinspection SpellCheckingInspection __credits__ = "(c) Rรฉseau de Transport de lโ€™ร‰lectricitรฉ (RTE)" diff --git a/antarest/launcher/adapters/slurm_launcher/slurm_launcher.py b/antarest/launcher/adapters/slurm_launcher/slurm_launcher.py index 714ce6d0e5..92e2755408 100644 --- a/antarest/launcher/adapters/slurm_launcher/slurm_launcher.py +++ b/antarest/launcher/adapters/slurm_launcher/slurm_launcher.py @@ -285,7 +285,7 @@ def _import_study_output( xpansion_mode: t.Optional[str] = None, log_dir: t.Optional[str] = None, ) -> t.Optional[str]: - if xpansion_mode is not None: + if xpansion_mode: self._import_xpansion_result(job_id, xpansion_mode) launcher_logs: t.Dict[str, t.List[Path]] = {} @@ -331,14 +331,6 @@ def _import_xpansion_result(self, job_id: str, xpansion_mode: str) -> None: ) output_path = unzipped_output_path - if (output_path / "updated_links").exists(): - logger.warning("Skipping updated links") - self.callbacks.append_after_log(job_id, "Skipping updated links") - else: - shutil.copytree( - self.local_workspace / STUDIES_OUTPUT_DIR_NAME / job_id / "input" / "links", - output_path / "updated_links", - ) if xpansion_mode == "r": shutil.copytree( self.local_workspace / STUDIES_OUTPUT_DIR_NAME / job_id / "user" / "expansion", diff --git a/antarest/study/business/advanced_parameters_management.py b/antarest/study/business/advanced_parameters_management.py index f18e47a71f..4801d5487a 100644 --- a/antarest/study/business/advanced_parameters_management.py +++ b/antarest/study/business/advanced_parameters_management.py @@ -4,6 +4,7 @@ from pydantic import validator from pydantic.types import StrictInt, StrictStr +from antarest.core.exceptions import InvalidFieldForVersionError from antarest.study.business.enum_ignore_case import EnumIgnoreCase from antarest.study.business.utils import GENERAL_DATA_PATH, FieldInfo, FormFieldsBaseModel, execute_or_add_commands from antarest.study.model import Study @@ -44,6 +45,7 @@ class ReserveManagement(EnumIgnoreCase): class UnitCommitmentMode(EnumIgnoreCase): FAST = "fast" ACCURATE = "accurate" + MILP = "milp" class SimulationCore(EnumIgnoreCase): @@ -236,6 +238,14 @@ def set_field_values(self, study: Study, field_values: AdvancedParamsFormFields) if value is not None: info = FIELDS_INFO[field_name] + # Handle the specific case of `milp` value that appeared in v8.8 + if ( + field_name == "unit_commitment_mode" + and value == UnitCommitmentMode.MILP + and int(study.version) < 880 + ): + raise InvalidFieldForVersionError("Unit commitment mode `MILP` only exists in v8.8+ studies") + commands.append( UpdateConfig( target=info["path"], diff --git a/antarest/study/business/area_management.py b/antarest/study/business/area_management.py index 8f0758d9dc..6c87d8cb80 100644 --- a/antarest/study/business/area_management.py +++ b/antarest/study/business/area_management.py @@ -381,7 +381,7 @@ def update_areas_props( if old_area.average_spilled_energy_cost != new_area.average_spilled_energy_cost: commands.append( UpdateConfig( - target=f"input/thermal/areas/spilledenergycost:{area_id}", + target=f"input/thermal/areas/spilledenergycost/{area_id}", data=new_area.average_spilled_energy_cost, command_context=command_context, ) diff --git a/antarest/study/business/link_management.py b/antarest/study/business/link_management.py index 375a539fd8..744401772a 100644 --- a/antarest/study/business/link_management.py +++ b/antarest/study/business/link_management.py @@ -130,10 +130,10 @@ def update_links_props( # Convert the DTO to a configuration object and update the configuration file. properties = LinkProperties(**new_link_dto.dict(by_alias=False)) - path = f"{_ALL_LINKS_PATH}/{area1}/properties" + path = f"{_ALL_LINKS_PATH}/{area1}/properties/{area2}" cmd = UpdateConfig( target=path, - data={area2: properties.to_config()}, + data=properties.to_config(), command_context=self.storage_service.variant_study_service.command_factory.command_context, ) commands.append(cmd) diff --git a/antarest/study/business/scenario_builder_management.py b/antarest/study/business/scenario_builder_management.py index 1c417eb586..0f7c94f7bb 100644 --- a/antarest/study/business/scenario_builder_management.py +++ b/antarest/study/business/scenario_builder_management.py @@ -1,9 +1,12 @@ +import enum import typing as t import typing_extensions as te from antarest.study.business.utils import execute_or_add_commands from antarest.study.model import Study +from antarest.study.storage.rawstudy.model.filesystem.config.ruleset_matrices import RulesetMatrices, TableForm +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.update_scenario_builder import UpdateScenarioBuilder @@ -22,6 +25,128 @@ Rulesets: te.TypeAlias = t.MutableMapping[str, Ruleset] +class ScenarioType(str, enum.Enum): + """ + Scenario type + + - LOAD: Load scenario + - THERMAL: Thermal cluster scenario + - HYDRO: Hydraulic scenario + - WIND: Wind scenario + - SOLAR: Solar scenario + - NTC: NTC scenario (link) + - RENEWABLE: Renewable scenario + - BINDING_CONSTRAINTS: Binding constraints scenario + - HYDRO_INITIAL_LEVEL: hydraulic Initial level scenario + - HYDRO_FINAL_LEVEL: hydraulic Final level scenario + - HYDRO_GENERATION_POWER: hydraulic Generation power scenario + """ + + LOAD = "load" + THERMAL = "thermal" + HYDRO = "hydro" + WIND = "wind" + SOLAR = "solar" + LINK = "ntc" + RENEWABLE = "renewable" + BINDING_CONSTRAINTS = "bindingConstraints" + HYDRO_INITIAL_LEVEL = "hydroInitialLevels" + HYDRO_FINAL_LEVEL = "hydroFinalLevels" + HYDRO_GENERATION_POWER = "hydroGenerationPower" + + def __str__(self) -> str: + """Return the string representation of the enum value.""" + return self.value + + +SYMBOLS_BY_SCENARIO_TYPES = { + ScenarioType.LOAD: "l", + ScenarioType.HYDRO: "h", + ScenarioType.WIND: "w", + ScenarioType.SOLAR: "s", + ScenarioType.THERMAL: "t", + ScenarioType.RENEWABLE: "r", + ScenarioType.LINK: "ntc", + ScenarioType.BINDING_CONSTRAINTS: "bc", + ScenarioType.HYDRO_INITIAL_LEVEL: "hl", + ScenarioType.HYDRO_FINAL_LEVEL: "hfl", + ScenarioType.HYDRO_GENERATION_POWER: "hgp", +} + + +def _get_ruleset_config( + file_study: FileStudy, + ruleset_name: str, + symbol: str = "", +) -> t.Dict[str, t.Union[int, float]]: + try: + suffix = f"/{symbol}" if symbol else "" + url = f"settings/scenariobuilder/{ruleset_name}{suffix}".split("/") + ruleset_cfg = t.cast(t.Dict[str, t.Union[int, float]], file_study.tree.get(url)) + except KeyError: + ruleset_cfg = {} + return ruleset_cfg + + +def _get_nb_years(file_study: FileStudy) -> int: + try: + # noinspection SpellCheckingInspection + url = "settings/generaldata/general/nbyears".split("/") + nb_years = t.cast(int, file_study.tree.get(url)) + except KeyError: + nb_years = 1 + return nb_years + + +def _get_active_ruleset_name(file_study: FileStudy, default_ruleset: str = "Default Ruleset") -> str: + """ + Get the active ruleset name stored in the configuration at the following path: + ``settings/generaldata.ini``, in the section "general", key "active-rules-scenario". + + This ruleset name must match a section name in the scenario builder configuration + at the following path: ``settings/scenariobuilder``. + + Args: + file_study: Object representing the study file + default_ruleset: Name of the default ruleset + + Returns: + The active ruleset name if found in the configuration, or the default ruleset name if missing. + """ + try: + url = "settings/generaldata/general/active-rules-scenario".split("/") + active_ruleset = t.cast(str, file_study.tree.get(url)) + except KeyError: + active_ruleset = default_ruleset + else: + # In some old studies, the active ruleset is stored in lowercase. + if not active_ruleset or active_ruleset.lower() == "default ruleset": + active_ruleset = default_ruleset + return active_ruleset + + +def _build_ruleset(file_study: FileStudy, symbol: str = "") -> RulesetMatrices: + ruleset_name = _get_active_ruleset_name(file_study) + nb_years = _get_nb_years(file_study) + ruleset_config = _get_ruleset_config(file_study, ruleset_name, symbol) + + # Create and populate the RulesetMatrices + areas = file_study.config.areas + groups = file_study.config.get_binding_constraint_groups() if file_study.config.version >= 870 else [] + scenario_types = {s: str(st) for st, s in SYMBOLS_BY_SCENARIO_TYPES.items()} + ruleset = RulesetMatrices( + nb_years=nb_years, + areas=areas, + links=((a1, a2) for a1 in areas for a2 in file_study.config.get_links(a1)), + thermals={a: file_study.config.get_thermal_ids(a) for a in areas}, + renewables={a: file_study.config.get_renewable_ids(a) for a in areas}, + groups=groups, + scenario_types=scenario_types, + ) + ruleset.update_rules(ruleset_config) + return ruleset + + class ScenarioBuilderManager: def __init__(self, storage_service: StudyStorageService) -> None: self.storage_service = storage_service @@ -79,6 +204,33 @@ def update_config(self, study: Study, rulesets: Rulesets) -> None: self.storage_service, ) + def get_scenario_by_type(self, study: Study, scenario_type: ScenarioType) -> TableForm: + symbol = SYMBOLS_BY_SCENARIO_TYPES[scenario_type] + file_study = self.storage_service.get_storage(study).get_raw(study) + ruleset = _build_ruleset(file_study, symbol) + ruleset.sort_scenarios() + + # Extract the table form for the given scenario type + table_form = ruleset.get_table_form(str(scenario_type), nan_value="") + return table_form + + def update_scenario_by_type(self, study: Study, table_form: TableForm, scenario_type: ScenarioType) -> TableForm: + file_study = self.storage_service.get_storage(study).get_raw(study) + ruleset = _build_ruleset(file_study) + ruleset.update_table_form(table_form, str(scenario_type), nan_value="") + ruleset.sort_scenarios() + + # Create the UpdateScenarioBuilder command + ruleset_name = _get_active_ruleset_name(file_study) + data = {ruleset_name: ruleset.get_rules(allow_nan=True)} + command_context = self.storage_service.variant_study_service.command_factory.command_context + update_scenario = UpdateScenarioBuilder(data=data, command_context=command_context) + execute_or_add_commands(study, file_study, [update_scenario], self.storage_service) + + # Extract the updated table form for the given scenario type + table_form = ruleset.get_table_form(str(scenario_type), nan_value="") + return table_form + def _populate_common(section: _Section, symbol: str, data: t.Mapping[str, t.Mapping[str, t.Any]]) -> None: for area, scenario_area in data.items(): diff --git a/antarest/study/storage/rawstudy/ini_reader.py b/antarest/study/storage/rawstudy/ini_reader.py index 84be7c099b..b35df2f0e0 100644 --- a/antarest/study/storage/rawstudy/ini_reader.py +++ b/antarest/study/storage/rawstudy/ini_reader.py @@ -1,8 +1,10 @@ +import dataclasses +import re import typing as t from abc import ABC, abstractmethod from pathlib import Path -from antarest.core.model import JSON, SUB_JSON +from antarest.core.model import JSON def convert_value(value: str) -> t.Union[str, int, float, bool]: @@ -22,34 +24,89 @@ def convert_value(value: str) -> t.Union[str, int, float, bool]: return value -def convert_obj(item: t.Any) -> SUB_JSON: - """Convert object to the appropriate type for JSON (scalar, dictionary or list).""" +@dataclasses.dataclass +class IniFilter: + """ + Filter sections and options in an INI file based on regular expressions. + + Attributes: + section_regex: A compiled regex for matching section names. + option_regex: A compiled regex for matching option names. + """ + + section_regex: t.Optional[t.Pattern[str]] = None + option_regex: t.Optional[t.Pattern[str]] = None + + @classmethod + def from_kwargs( + cls, + section: str = "", + option: str = "", + section_regex: t.Optional[t.Union[str, t.Pattern[str]]] = None, + option_regex: t.Optional[t.Union[str, t.Pattern[str]]] = None, + **_unused: t.Any, # ignore unknown options + ) -> "IniFilter": + """ + Create an instance from given filtering parameters. + + When using `section` or `option` parameters, an exact match is done. + Alternatively, one can use `section_regex` or `option_regex` to perform a full match using a regex. + + Args: + section: The section name to match (by default, all sections are matched) + option: The option name to match (by default, all options are matched) + section_regex: The regex for matching section names. + option_regex: The regex for matching option names. + _unused: Placeholder for any unknown options. + + Returns: + The newly created instance + """ + if section: + section_regex = re.compile(re.escape(section)) + if option: + option_regex = re.compile(re.escape(option)) + if isinstance(section_regex, str): + section_regex = re.compile(section_regex) if section_regex else None + if isinstance(option_regex, str): + option_regex = re.compile(option_regex) if option_regex else None + return cls(section_regex=section_regex, option_regex=option_regex) + + def select_section_option(self, section: str, option: str = "") -> bool: + """ + Check if a given section and option match the regular expressions. + + Args: + section: The section name to match. + option: The option name to match (optional). - if isinstance(item, dict): - return {key: convert_obj(value) for key, value in item.items()} - elif isinstance(item, list): - return [convert_obj(value) for value in item] - else: - return convert_value(item) + Returns: + Whether the section and option match their respective regular expressions. + """ + if self.section_regex and not self.section_regex.fullmatch(section): + return False + if self.option_regex and option and not self.option_regex.fullmatch(option): + return False + return True class IReader(ABC): """ - Init file Reader interface + File reader interface. """ @abstractmethod - def read(self, path: t.Any) -> JSON: + def read(self, path: t.Any, **kwargs: t.Any) -> JSON: """ Parse `.ini` file to json object. Args: path: Path to `.ini` file or file-like object. + kwargs: Additional options used for reading. Returns: Dictionary of parsed `.ini` file which can be converted to JSON. """ - raise NotImplementedError() class IniReader(IReader): @@ -90,6 +147,15 @@ def __init__(self, special_keys: t.Sequence[str] = (), section_name: str = "sett # List of keys which should be parsed as list. self._section_name = section_name + # Dictionary of parsed sections and options + self._curr_sections: t.Dict[str, t.Dict[str, t.Any]] = {} + + # Current section name used during paring + self._curr_section = "" + + # Current option name used during paring + self._curr_option = "" + def __repr__(self) -> str: # pragma: no cover """Return a string representation of the object.""" cls = self.__class__.__name__ @@ -98,15 +164,15 @@ def __repr__(self) -> str: # pragma: no cover section_name = getattr(self, "_section_name", "settings") return f"{cls}(special_keys={special_keys!r}, section_name={section_name!r})" - def read(self, path: t.Any) -> JSON: + def read(self, path: t.Any, **kwargs: t.Any) -> JSON: if isinstance(path, (Path, str)): try: with open(path, mode="r", encoding="utf-8") as f: - sections = self._parse_ini_file(f) + sections = self._parse_ini_file(f, **kwargs) except UnicodeDecodeError: # On windows, `.ini` files may use "cp1252" encoding with open(path, mode="r", encoding="cp1252") as f: - sections = self._parse_ini_file(f) + sections = self._parse_ini_file(f, **kwargs) except FileNotFoundError: # If the file is missing, an empty dictionary is returned. # This is required to mimic the behavior of `configparser.ConfigParser`. @@ -114,14 +180,14 @@ def read(self, path: t.Any) -> JSON: elif hasattr(path, "read"): with path: - sections = self._parse_ini_file(path) + sections = self._parse_ini_file(path, **kwargs) else: # pragma: no cover raise TypeError(repr(type(path))) - return t.cast(JSON, convert_obj(sections)) + return t.cast(JSON, sections) - def _parse_ini_file(self, ini_file: t.TextIO) -> JSON: + def _parse_ini_file(self, ini_file: t.TextIO, **kwargs: t.Any) -> JSON: """ Parse `.ini` file to JSON object. @@ -151,31 +217,91 @@ def _parse_ini_file(self, ini_file: t.TextIO) -> JSON: Args: ini_file: file or file-like object. + Keywords: + - section: The section name to match (by default, all sections are matched) + - option: The option name to match (by default, all options are matched) + - section_regex: The regex for matching section names. + - option_regex: The regex for matching option names. + Returns: Dictionary of parsed `.ini` file which can be converted to JSON. """ + ini_filter = IniFilter.from_kwargs(**kwargs) + # NOTE: This algorithm is 1.93x faster than configparser.ConfigParser - sections: t.Dict[str, t.Dict[str, t.Any]] = {} section_name = self._section_name + # reset the current values + self._curr_sections.clear() + self._curr_section = "" + self._curr_option = "" + for line in ini_file: line = line.strip() if not line or line.startswith(";") or line.startswith("#"): continue elif line.startswith("["): section_name = line[1:-1] - sections.setdefault(section_name, {}) + stop = self._handle_section(ini_filter, section_name) elif "=" in line: key, value = map(str.strip, line.split("=", 1)) - section = sections.setdefault(section_name, {}) - if key in self._special_keys: - section.setdefault(key, []).append(value) - else: - section[key] = value + stop = self._handle_option(ini_filter, section_name, key, value) else: raise ValueError(f"โ˜ โ˜ โ˜  Invalid line: {line!r}") - return sections + # Stop parsing if the filter don't match + if stop: + break + + return self._curr_sections + + def _handle_section(self, ini_filter: IniFilter, section: str) -> bool: + # state: a new section is found + match = ini_filter.select_section_option(section) + + if self._curr_section: + # state: option parsing is finished + if match: + self._append_section(section) + return False + # prematurely stop parsing if the filter don't match + return True + + if match: + self._append_section(section) + + # continue parsing to the next section + return False + + def _append_section(self, section: str) -> None: + self._curr_sections.setdefault(section, {}) + self._curr_section = section + self._curr_option = "" + + def _handle_option(self, ini_filter: IniFilter, section: str, key: str, value: str) -> bool: + # state: a new option is found (which may be a duplicate) + match = ini_filter.select_section_option(section, key) + + if self._curr_option: + if match: + self._append_option(section, key, value) + return False + # prematurely stop parsing if the filter don't match + return not ini_filter.select_section_option(section) + + if match: + self._append_option(section, key, value) + # continue parsing to the next option + return False + + def _append_option(self, section: str, key: str, value: str) -> None: + self._curr_sections.setdefault(section, {}) + values = self._curr_sections[section] + if key in self._special_keys: + values.setdefault(key, []).append(convert_value(value)) + else: + values[key] = convert_value(value) + self._curr_option = key class SimpleKeyValueReader(IniReader): @@ -183,7 +309,7 @@ class SimpleKeyValueReader(IniReader): Simple INI reader for "settings.ini" file which has no section. """ - def read(self, path: t.Any) -> JSON: + def read(self, path: t.Any, **kwargs: t.Any) -> JSON: """ Parse `.ini` file which has no section to JSON object. @@ -191,6 +317,7 @@ def read(self, path: t.Any) -> JSON: Args: path: Path to `.ini` file or file-like object. + kwargs: Additional options used for reading. Returns: Dictionary of parsed key/value pairs. diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/files.py b/antarest/study/storage/rawstudy/model/filesystem/config/files.py index bfa490dac0..70846f4d5f 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/config/files.py +++ b/antarest/study/storage/rawstudy/model/filesystem/config/files.py @@ -226,16 +226,14 @@ def _parse_bindings(root: Path) -> t.List[BindingConstraintDTO]: area_set.add(key.split(".", 1)[0]) group = bind.get("group", DEFAULT_GROUP) - - output_list.append( - BindingConstraintDTO( - id=bind["id"], - areas=area_set, - clusters=cluster_set, - time_step=time_step, - group=group, - ) + bc = BindingConstraintDTO( + id=bind["id"], + areas=area_set, + clusters=cluster_set, + time_step=time_step, + group=group, ) + output_list.append(bc) return output_list diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/model.py b/antarest/study/storage/rawstudy/model/filesystem/config/model.py index cf9e67167c..612c50e786 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/config/model.py +++ b/antarest/study/storage/rawstudy/model/filesystem/config/model.py @@ -116,6 +116,16 @@ def get_file(self) -> str: class BindingConstraintDTO(BaseModel): + """ + Object linked to `input/bindingconstraints/bindingconstraints.ini` information + + Attributes: + id: The ID of the binding constraint. + group: The group for the scenario of BC (optional, required since v8.7). + areas: List of area IDs on which the BC applies (links or clusters). + clusters: List of thermal cluster IDs on which the BC applies (format: "area.cluster"). + """ + id: str areas: t.Set[str] clusters: t.Set[str] diff --git a/antarest/study/storage/rawstudy/model/filesystem/config/ruleset_matrices.py b/antarest/study/storage/rawstudy/model/filesystem/config/ruleset_matrices.py new file mode 100644 index 0000000000..5e7e380fe0 --- /dev/null +++ b/antarest/study/storage/rawstudy/model/filesystem/config/ruleset_matrices.py @@ -0,0 +1,395 @@ +import typing as t + +import numpy as np +import pandas as pd +import typing_extensions as te + +SCENARIO_TYPES = { + "l": "load", + "h": "hydro", + "w": "wind", + "s": "solar", + "t": "thermal", + "r": "renewable", + "ntc": "link", + "bc": "binding-constraints", + "hl": "hydro-initial-levels", + "hfl": "hydro-final-levels", + "hgp": "hydro-generation-power", +} + +_Value: te.TypeAlias = t.Union[int, float] +_SimpleScenario: te.TypeAlias = pd.DataFrame +_ClusterScenario: te.TypeAlias = t.MutableMapping[str, pd.DataFrame] +_Scenario: te.TypeAlias = t.Union[_SimpleScenario, _ClusterScenario] +_ScenarioMapping: te.TypeAlias = t.MutableMapping[str, _Scenario] + +SimpleTableForm: te.TypeAlias = t.Dict[str, t.Dict[str, t.Union[int, float, str, None]]] +ClusterTableForm: te.TypeAlias = t.Dict[str, SimpleTableForm] +TableForm: te.TypeAlias = t.Union[SimpleTableForm, ClusterTableForm] + +_AREA_RELATED_SYMBOLS = "l", "h", "w", "s", "hgp" +_BINDING_CONSTRAINTS_RELATED_SYMBOLS = ("bc",) +_LINK_RELATED_SYMBOLS = ("ntc",) +_HYDRO_LEVEL_RELATED_SYMBOLS = "hl", "hfl" +_CLUSTER_RELATED_SYMBOLS = "t", "r" + + +# ======================================== +# Formating functions for matrix indexes +# ======================================== + + +def idx_area(area: str, /) -> str: + return area + + +def idx_link(area1: str, area2: str, /) -> str: + return f"{area1} / {area2}" + + +def idx_cluster(_: str, cluster: str, /) -> str: + return cluster + + +def idx_group(group: str, /) -> str: + return group + + +# ========================== +# Scenario Builder Ruleset +# ========================== + + +class RulesetMatrices: + """ + Scenario Builder Ruleset -- Manage rules of each scenario type as DataFrames. + + This class allows to manage the conversion of data from or to rules in INI format. + It can also convert the data to a table form (a dictionary of dictionaries) for the frontend. + """ + + def __init__( + self, + *, + nb_years: int, + areas: t.Iterable[str], + links: t.Iterable[t.Tuple[str, str]], + thermals: t.Mapping[str, t.Iterable[str]], + renewables: t.Mapping[str, t.Iterable[str]], + groups: t.Iterable[str], + scenario_types: t.Optional[t.Mapping[str, str]] = None, + ): + # List of Monte Carlo years + self.columns = [str(i) for i in range(nb_years)] + # Dictionaries used to manage case insensitivity + self.areas = {a.lower(): a for a in areas} + self.links = {(a1.lower(), a2.lower()): (a1, a2) for a1, a2 in links} + self.thermals = {a.lower(): {cl.lower(): cl for cl in clusters} for a, clusters in thermals.items()} + self.renewables = {a.lower(): {cl.lower(): cl for cl in clusters} for a, clusters in renewables.items()} + self.clusters_by_symbols = {"t": self.thermals, "r": self.renewables} # for easier access + self.groups = {g.lower(): g for g in groups} + # Dictionary used to convert symbols to scenario types + self.scenario_types = scenario_types or SCENARIO_TYPES + # Dictionary used to store the scenario matrices + self.scenarios: _ScenarioMapping = {} + self._setup() + + def __str__(self) -> str: + lines = [] + for symbol, scenario_type in self.scenario_types.items(): + lines.append(f"# {scenario_type}") + scenario = self.scenarios[scenario_type] + if isinstance(scenario, pd.DataFrame): + lines.append(scenario.to_string()) + lines.append("") + else: + for area, scenario in scenario.items(): + lines.append(f"## {scenario_type} in {area}") + lines.append(scenario.to_string()) + lines.append("") + return "\n".join(lines) + + def get_area_index(self) -> t.List[str]: + return [idx_area(area) for area in self.areas.values()] + + def get_link_index(self) -> t.List[str]: + return [idx_link(a1, a2) for a1, a2 in self.links.values()] + + def get_cluster_index(self, symbol: str, area: str) -> t.List[str]: + clusters = self.clusters_by_symbols[symbol][area.lower()] + return [idx_cluster(area, cluster) for cluster in clusters.values()] + + def get_group_index(self) -> t.List[str]: + return [idx_group(group) for group in self.groups.values()] + + def _setup(self) -> None: + """ + Prepare the scenario matrices for each scenario type. + """ + area_index = self.get_area_index() + group_index = self.get_group_index() + link_index = self.get_link_index() + for symbol, scenario_type in self.scenario_types.items(): + # Note: all DataFrames are initialized with NaN values, so the dtype is `float`. + if symbol in _AREA_RELATED_SYMBOLS: + self.scenarios[scenario_type] = pd.DataFrame(index=area_index, columns=self.columns, dtype=float) + elif symbol in _BINDING_CONSTRAINTS_RELATED_SYMBOLS: + self.scenarios[scenario_type] = pd.DataFrame(index=group_index, columns=self.columns, dtype=float) + elif symbol in _LINK_RELATED_SYMBOLS: + self.scenarios[scenario_type] = pd.DataFrame(index=link_index, columns=self.columns, dtype=float) + elif symbol in _HYDRO_LEVEL_RELATED_SYMBOLS: + self.scenarios[scenario_type] = pd.DataFrame(index=area_index, columns=self.columns, dtype=float) + elif symbol in _CLUSTER_RELATED_SYMBOLS: + # We only take the areas that are defined in the thermals and renewables dictionaries + # Keys are the names of the areas (and not the identifiers) + self.scenarios[scenario_type] = { + self.areas[area_id]: pd.DataFrame( + index=self.get_cluster_index(symbol, self.areas[area_id]), columns=self.columns, dtype=float + ) + for area_id, cluster in self.clusters_by_symbols[symbol].items() + if cluster + } + else: + raise NotImplementedError(f"Unknown symbol {symbol}") + + def sort_scenarios(self) -> None: + """ + Sort the indexes of the scenario matrices (case-insensitive). + """ + for symbol, scenario_type in self.scenario_types.items(): + scenario = self.scenarios[scenario_type] + if isinstance(scenario, pd.DataFrame): + scenario = scenario.sort_index(key=lambda x: x.str.lower()) + else: + scenario = {area: df.sort_index(key=lambda x: x.str.lower()) for area, df in scenario.items()} + self.scenarios[scenario_type] = scenario + + def update_rules(self, rules: t.Mapping[str, _Value]) -> None: + """ + Update the scenario matrices with the given rules read from an INI file. + + Args: + rules: Dictionary of rules with the following format + + :: + + { + "symbol,area_id,year": value, # load, hydro, wind, solar... + "symbol,area1_id,area2_id,year": value, # links + "symbol,area_id,cluster_id,year": value, # thermal and renewable clusters + "symbol,group_id,year": value, # binding constraints + } + """ + for key, value in rules.items(): + symbol, *parts = key.split(",") + scenario_type = self.scenario_types[symbol] + # Common values + area_id = parts[0].lower() # or group_id for BC + year = parts[2] if symbol in _LINK_RELATED_SYMBOLS else parts[1] + if symbol in _AREA_RELATED_SYMBOLS: + area = self.areas[area_id] + scenario = t.cast(pd.DataFrame, self.scenarios[scenario_type]) + scenario.at[idx_area(area), str(year)] = value + elif symbol in _LINK_RELATED_SYMBOLS: + area1 = self.areas[area_id] + area2 = self.areas[parts[1].lower()] + scenario = t.cast(pd.DataFrame, self.scenarios[scenario_type]) + scenario.at[idx_link(area1, area2), str(year)] = value + elif symbol in _HYDRO_LEVEL_RELATED_SYMBOLS: + area = self.areas[area_id] + scenario = t.cast(pd.DataFrame, self.scenarios[scenario_type]) + scenario.at[idx_area(area), str(year)] = value * 100 + elif symbol in _CLUSTER_RELATED_SYMBOLS: + area = self.areas[area_id] + clusters = self.clusters_by_symbols[symbol][area_id] + cluster = clusters[parts[2].lower()] + scenario = t.cast(pd.DataFrame, self.scenarios[scenario_type][area]) + scenario.at[idx_cluster(area, cluster), str(year)] = value + elif symbol in _BINDING_CONSTRAINTS_RELATED_SYMBOLS: + group = self.groups[area_id] + scenario = t.cast(pd.DataFrame, self.scenarios[scenario_type]) + scenario.at[idx_group(group), str(year)] = value + else: + raise NotImplementedError(f"Unknown symbol {symbol}") + + def get_rules(self, *, allow_nan: bool = False) -> t.Dict[str, _Value]: + """ + Get the rules from the scenario matrices in INI format. + + Args: + allow_nan: Allow NaN values if True. + + Returns: + Dictionary of rules with the following format + + :: + + { + "symbol,area_id,year": value, # load, hydro, wind, solar... + "symbol,area1_id,area2_id,year": value, # links + "symbol,area_id,cluster_id,year": value, # thermal and renewable clusters + "symbol,group_id,year": value, # binding constraints + } + """ + rules: t.Dict[str, _Value] = {} + for symbol, scenario_type in self.scenario_types.items(): + scenario = self.scenarios[scenario_type] + scenario_rules = self.get_scenario_rules(scenario, symbol, allow_nan=allow_nan) + rules.update(scenario_rules) + return rules + + def get_scenario_rules(self, scenario: _Scenario, symbol: str, *, allow_nan: bool = False) -> t.Dict[str, _Value]: + """ + Get the rules for a specific scenario matrix and symbol. + + Args: + scenario: Matrix or dictionary of matrices. + symbol: Rule symbol. + allow_nan: Allow NaN values if True. + + Returns: + Dictionary of rules. + """ + + def to_ts_number(v: t.Any) -> _Value: + """Convert value to TimeSeries number.""" + return np.nan if pd.isna(v) else int(v) + + def to_percent(v: t.Any) -> _Value: + """Convert value to percentage in range [0, 100].""" + return np.nan if pd.isna(v) else float(v) / 100 + + if symbol in _AREA_RELATED_SYMBOLS: + scenario_rules = { + f"{symbol},{area_id},{year}": to_ts_number(value) + for area_id, area in self.areas.items() + for year, value in scenario.loc[idx_area(area)].items() # type: ignore + if allow_nan or not pd.isna(value) + } + elif symbol in _LINK_RELATED_SYMBOLS: + scenario_rules = { + f"{symbol},{area1_id},{area2_id},{year}": to_ts_number(value) + for (area1_id, area2_id), (area1, area2) in self.links.items() + for year, value in scenario.loc[idx_link(area1, area2)].items() # type: ignore + if allow_nan or not pd.isna(value) + } + elif symbol in _HYDRO_LEVEL_RELATED_SYMBOLS: + scenario_rules = { + f"{symbol},{area_id},{year}": to_percent(value) + for area_id, area in self.areas.items() + for year, value in scenario.loc[idx_area(area)].items() # type: ignore + if allow_nan or not pd.isna(value) + } + elif symbol in _CLUSTER_RELATED_SYMBOLS: + clusters_mapping = self.clusters_by_symbols[symbol] + scenario_rules = { + f"{symbol},{area_id},{year},{cluster_id}": to_ts_number(value) + for area_id, clusters in clusters_mapping.items() + for cluster_id, cluster in clusters.items() + for year, value in scenario[self.areas[area_id]].loc[idx_cluster(self.areas[area_id], cluster)].items() + if allow_nan or not pd.isna(value) + } + elif symbol in _BINDING_CONSTRAINTS_RELATED_SYMBOLS: + scenario_rules = { + f"{symbol},{group_id},{year}": to_ts_number(value) + for group_id, group in self.groups.items() + for year, value in scenario.loc[idx_group(group)].items() # type: ignore + if allow_nan or not pd.isna(value) + } + else: + raise NotImplementedError(f"Unknown symbol {symbol}") + return scenario_rules + + def get_table_form(self, scenario_type: str, *, nan_value: t.Union[str, None] = "") -> TableForm: + """ + Get the scenario matrices in table form for the frontend. + + Args: + scenario_type: Scenario type. + nan_value: Value to replace NaNs. + + Returns: + Dictionary of dictionaries with the following format + + :: + + { + "area_id": { + "year": value, + ... + }, + ... + } + + For thermal and renewable clusters, the dictionary is nested: + + :: + + { + "area_id": { + "cluster_id": { + "year": value, + ... + }, + ... + }, + ... + } + """ + scenario = self.scenarios[scenario_type] + if isinstance(scenario, pd.DataFrame): + simple_scenario: _SimpleScenario = scenario.fillna(nan_value) + simple_table_form = simple_scenario.to_dict(orient="index") + return t.cast(SimpleTableForm, simple_table_form) + else: + cluster_scenario: _ClusterScenario = {area: df.fillna(nan_value) for area, df in scenario.items()} + cluster_table_form = {area: df.to_dict(orient="index") for area, df in cluster_scenario.items()} + return t.cast(ClusterTableForm, cluster_table_form) + + def set_table_form( + self, + table_form: TableForm, + scenario_type: str, + *, + nan_value: t.Union[str, None] = "", + ) -> None: + """ + Set the scenario matrix from table form data, for a specific scenario type. + + Args: + table_form: Simple or cluster table form data (see :meth:`get_table_form` for the format). + scenario_type: Scenario type. + nan_value: Value to replace NaNs. + """ + actual_scenario = self.scenarios[scenario_type] + if isinstance(actual_scenario, pd.DataFrame): + scenario = pd.DataFrame.from_dict(table_form, orient="index") + scenario = scenario.replace([None, nan_value], np.nan) + self.scenarios[scenario_type] = scenario + else: + self.scenarios[scenario_type] = { + area: pd.DataFrame.from_dict(df, orient="index").replace([None, nan_value], np.nan) + for area, df in table_form.items() + } + + def update_table_form(self, table_form: TableForm, scenario_type: str, *, nan_value: str = "") -> None: + """ + Update the scenario matrices from table form data (partial update). + + Args: + table_form: Simple or cluster table form data (see :meth:`get_table_form` for the format). + scenario_type: Scenario type. + nan_value: Value to replace NaNs. for instance: ``{"& psp x1": {"0": 10}}``. + """ + scenario = self.scenarios[scenario_type] + if isinstance(scenario, pd.DataFrame): + simple_table_form = t.cast(SimpleTableForm, table_form) + df = pd.DataFrame(simple_table_form).transpose().replace([None, nan_value], np.nan) + scenario.at[df.index, df.columns] = df + else: + cluster_table_form = t.cast(ClusterTableForm, table_form) + for area, simple_table_form in cluster_table_form.items(): + scenario = t.cast(pd.DataFrame, self.scenarios[scenario_type][area]) + df = pd.DataFrame(simple_table_form).transpose().replace([None, nan_value], np.nan) + scenario.at[df.index, df.columns] = df diff --git a/antarest/study/storage/rawstudy/model/filesystem/ini_file_node.py b/antarest/study/storage/rawstudy/model/filesystem/ini_file_node.py index d44c89f4f0..ba75363abd 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/ini_file_node.py +++ b/antarest/study/storage/rawstudy/model/filesystem/ini_file_node.py @@ -5,10 +5,10 @@ import logging import os import tempfile +import typing as t import zipfile from json import JSONDecodeError from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Union, cast from filelock import FileLock @@ -36,7 +36,7 @@ def __init__(self, config: FileStudyTreeConfig, message: str) -> None: super().__init__(f"INI File error '{relpath}': {message}") -def log_warning(f: Callable[..., Any]) -> Callable[..., Any]: +def log_warning(f: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: """ Decorator to suppress `UserWarning` exceptions by logging them as warnings. @@ -48,7 +48,7 @@ def log_warning(f: Callable[..., Any]) -> Callable[..., Any]: """ @functools.wraps(f) - def wrapper(*args: Any, **kwargs: Any) -> Any: + def wrapper(*args: t.Any, **kwargs: t.Any) -> t.Any: try: return f(*args, **kwargs) except UserWarning as w: @@ -63,9 +63,9 @@ def __init__( self, context: ContextServer, config: FileStudyTreeConfig, - types: Optional[Dict[str, Any]] = None, - reader: Optional[IReader] = None, - writer: Optional[IniWriter] = None, + types: t.Optional[t.Dict[str, t.Any]] = None, + reader: t.Optional[IReader] = None, + writer: t.Optional[IniWriter] = None, ): super().__init__(config) self.context = context @@ -76,11 +76,11 @@ def __init__( def _get( self, - url: Optional[List[str]] = None, + url: t.Optional[t.List[str]] = None, depth: int = -1, expanded: bool = False, get_node: bool = False, - ) -> Union[SUB_JSON, INode[SUB_JSON, SUB_JSON, JSON]]: + ) -> t.Union[SUB_JSON, INode[SUB_JSON, SUB_JSON, JSON]]: if get_node: return self @@ -89,15 +89,17 @@ def _get( if depth == 0: return {} + url = url or [] + kwargs = self._get_filtering_kwargs(url) if self.config.zip_path: with zipfile.ZipFile(self.config.zip_path, mode="r") as zipped_folder: inside_zip_path = self.config.path.relative_to(self.config.zip_path.with_suffix("")).as_posix() with io.TextIOWrapper(zipped_folder.open(inside_zip_path)) as f: - data = self.reader.read(f) + data = self.reader.read(f, **kwargs) else: - data = self.reader.read(self.path) + data = self.reader.read(self.path, **kwargs) if len(url) == 2: data = data[url[0]][url[1]] @@ -105,11 +107,34 @@ def _get( data = data[url[0]] else: data = {k: {} for k in data} if depth == 1 else data - return cast(SUB_JSON, data) + + return t.cast(SUB_JSON, data) + + # noinspection PyMethodMayBeStatic + def _get_filtering_kwargs(self, url: t.List[str]) -> t.Dict[str, str]: + """ + Extracts the filtering arguments from the URL components. + + Note: this method can be overridden in subclasses to provide additional filtering arguments. + + Args: + url: URL components [section_name, key_name]. + + Returns: + Keyword arguments used by the INI reader to filter the data. + """ + if len(url) > 2: + raise ValueError(f"Invalid URL: {url!r}") + elif len(url) == 2: + return {"section": url[0], "option": url[1]} + elif len(url) == 1: + return {"section": url[0]} + else: + return {} def get( self, - url: Optional[List[str]] = None, + url: t.Optional[t.List[str]] = None, depth: int = -1, expanded: bool = False, formatted: bool = True, @@ -120,13 +145,13 @@ def get( def get_node( self, - url: Optional[List[str]] = None, + url: t.Optional[t.List[str]] = None, ) -> INode[SUB_JSON, SUB_JSON, JSON]: output = self._get(url, get_node=True) assert isinstance(output, INode) return output - def save(self, data: SUB_JSON, url: Optional[List[str]] = None) -> None: + def save(self, data: SUB_JSON, url: t.Optional[t.List[str]] = None) -> None: self._assert_not_in_zipped_file() url = url or [] with FileLock( @@ -147,11 +172,11 @@ def save(self, data: SUB_JSON, url: Optional[List[str]] = None) -> None: elif len(url) == 1: info[url[0]] = obj else: - info = cast(JSON, obj) + info = t.cast(JSON, obj) self.writer.write(info, self.path) @log_warning - def delete(self, url: Optional[List[str]] = None) -> None: + def delete(self, url: t.Optional[t.List[str]] = None) -> None: """ Deletes the specified section or key from the INI file, or the entire INI file if no URL is provided. @@ -216,9 +241,9 @@ def delete(self, url: Optional[List[str]] = None) -> None: def check_errors( self, data: JSON, - url: Optional[List[str]] = None, + url: t.Optional[t.List[str]] = None, raising: bool = False, - ) -> List[str]: + ) -> t.List[str]: errors = [] for section, params in self.types.items(): if section not in data: @@ -240,9 +265,9 @@ def denormalize(self) -> None: def _validate_param( self, section: str, - params: Any, + params: t.Any, data: JSON, - errors: List[str], + errors: t.List[str], raising: bool, ) -> None: for param, typing in params.items(): diff --git a/antarest/study/storage/rawstudy/model/filesystem/json_file_node.py b/antarest/study/storage/rawstudy/model/filesystem/json_file_node.py index 80f607485d..e2ddfbab98 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/json_file_node.py +++ b/antarest/study/storage/rawstudy/model/filesystem/json_file_node.py @@ -15,7 +15,7 @@ class JsonReader(IReader): JSON file reader. """ - def read(self, path: t.Any) -> JSON: + def read(self, path: t.Any, **kwargs: t.Any) -> JSON: content: t.Union[str, bytes] if isinstance(path, (Path, str)): diff --git a/antarest/study/storage/rawstudy/model/filesystem/root/settings/scenariobuilder.py b/antarest/study/storage/rawstudy/model/filesystem/root/settings/scenariobuilder.py index 17aa0f5e74..8fde47960e 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/root/settings/scenariobuilder.py +++ b/antarest/study/storage/rawstudy/model/filesystem/root/settings/scenariobuilder.py @@ -1,3 +1,4 @@ +import re import typing as t import typing_extensions as te @@ -103,3 +104,38 @@ def _populate_hydro_final_level_rules(self, rules: _Rules) -> None: def _populate_hydro_generation_power_rules(self, rules: _Rules) -> None: for area_id in self.config.areas: rules[f"hgp,{area_id},0"] = _TSNumber + + def _get_filtering_kwargs(self, url: t.List[str]) -> t.Dict[str, str]: + # If the URL contains 2 elements, we can filter the options based on the generator type. + if len(url) == 2: + section, symbol = url + if re.fullmatch(r"\w+", symbol): + # Mutate the URL to get all values matching the generator type. + url[:] = [section] + return {"section": section, "option_regex": f"{symbol},.*"} + + # If the URL contains 3 elements, we can filter on the generator type and area (or group for BC). + elif len(url) == 3: + section, symbol, area = url + if re.fullmatch(r"\w+", symbol): + # Mutate the URL to get all values matching the generator type. + url[:] = [section] + area_re = re.escape(area) + return {"section": section, "option_regex": f"{symbol},{area_re},.*"} + + # If the URL contains 4 elements, we can filter on the generator type, area, and cluster. + elif len(url) == 4: + section, symbol, area, cluster = url + if re.fullmatch(r"\w+", symbol): + # Mutate the URL to get all values matching the generator type. + url[:] = [section] + if symbol in ("t", "r"): + area_re = re.escape(area) + cluster_re = re.escape(cluster) + return {"section": section, "option_regex": rf"{symbol},{area_re},\d+,{cluster_re}"} + elif symbol == "ntc": + area1_re = re.escape(area) + area2_re = re.escape(cluster) + return {"section": section, "option_regex": f"{symbol},{area1_re},{area2_re},.*"} + + return super()._get_filtering_kwargs(url) diff --git a/antarest/study/storage/variantstudy/business/utils_binding_constraint.py b/antarest/study/storage/variantstudy/business/utils_binding_constraint.py index 039a3b2252..f236cfbca3 100644 --- a/antarest/study/storage/variantstudy/business/utils_binding_constraint.py +++ b/antarest/study/storage/variantstudy/business/utils_binding_constraint.py @@ -23,16 +23,14 @@ def parse_bindings_coeffs_and_save_into_config( elif "." in k: clusters_set.add(k) areas_set.add(k.split(".")[0]) - - study_data_config.bindings.append( - BindingConstraintDTO( - id=bd_id, - areas=areas_set, - clusters=clusters_set, - time_step=time_step, - group=group, - ) + bc = BindingConstraintDTO( + id=bd_id, + group=group, + areas=areas_set, + clusters=clusters_set, + time_step=time_step, ) + study_data_config.bindings.append(bc) def remove_area_cluster_from_binding_constraints( 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 3e047ac02d..ee9162241d 100644 --- a/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py +++ b/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py @@ -1,5 +1,4 @@ import json -import typing import typing as t from abc import ABCMeta @@ -339,7 +338,6 @@ def apply_binding_constraint( self.coeffs or {}, group=group, ) - study_data.tree.save( binding_constraints, ["input", "bindingconstraints", "bindingconstraints"], @@ -462,7 +460,7 @@ def match(self, other: "ICommand", equal: bool = False) -> bool: return super().match(other, equal) -def remove_bc_from_scenario_builder(study_data: FileStudy, removed_groups: typing.Set[str]) -> None: +def remove_bc_from_scenario_builder(study_data: FileStudy, removed_groups: t.Set[str]) -> None: """ Update the scenario builder by removing the rows that correspond to the BC groups to remove. 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 0dcf9804f4..3f84ecd334 100644 --- a/antarest/study/storage/variantstudy/model/command/update_binding_constraint.py +++ b/antarest/study/storage/variantstudy/model/command/update_binding_constraint.py @@ -79,12 +79,13 @@ def _apply(self, study_data: FileStudy) -> CommandOutput: updated_cfg = binding_constraints[index] updated_cfg.update(obj) - updated_terms = set(self.coeffs) if self.coeffs else set() - - # Remove the terms not in the current update but existing in the config - terms_to_remove = {key for key in updated_cfg if ("%" in key or "." in key) and key not in updated_terms} - for term_id in terms_to_remove: - updated_cfg.pop(term_id, None) + excluded_fields = set(ICommand.__fields__) | {"id"} + updated_properties = self.dict(exclude=excluded_fields, exclude_none=True) + # This 2nd check is here to remove the last term. + if self.coeffs or updated_properties == {"coeffs": {}}: + # Remove terms which IDs contain a "%" or a "." in their name + term_ids = {k for k in updated_cfg if "%" in k or "." in k} + binding_constraints[index] = {k: v for k, v in updated_cfg.items() if k not in term_ids} return super().apply_binding_constraint(study_data, binding_constraints, index, self.id, old_groups=old_groups) diff --git a/antarest/study/storage/variantstudy/model/command/update_scenario_builder.py b/antarest/study/storage/variantstudy/model/command/update_scenario_builder.py index db6d2dd700..ff8e1311ac 100644 --- a/antarest/study/storage/variantstudy/model/command/update_scenario_builder.py +++ b/antarest/study/storage/variantstudy/model/command/update_scenario_builder.py @@ -1,5 +1,6 @@ import typing as t +import numpy as np from requests.structures import CaseInsensitiveDict from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfig @@ -62,7 +63,7 @@ def _apply(self, study_data: FileStudy) -> CommandOutput: if section: curr_section = curr_cfg.setdefault(section_name, {}) for key, value in section.items(): - if isinstance(value, (int, float)) and value != float("nan"): + if isinstance(value, (int, float)) and not np.isnan(value): curr_section[key] = value else: curr_section.pop(key, None) diff --git a/antarest/study/storage/variantstudy/repository.py b/antarest/study/storage/variantstudy/repository.py index d2c676c2eb..3f5ca51fdf 100644 --- a/antarest/study/storage/variantstudy/repository.py +++ b/antarest/study/storage/variantstudy/repository.py @@ -50,7 +50,7 @@ def get_children(self, parent_id: str) -> t.List[VariantStudy]: List of `VariantStudy` objects, ordered by creation date. """ q = self.session.query(VariantStudy).filter(Study.parent_id == parent_id) - q = q.order_by(Study.created_at.asc()) + q = q.order_by(Study.created_at.desc()) studies = t.cast(t.List[VariantStudy], q.all()) return studies diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index 613c8c3d44..e84029ff8f 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -54,7 +54,7 @@ from antarest.study.business.link_management import LinkInfoDTO from antarest.study.business.optimization_management import OptimizationFormFields from antarest.study.business.playlist_management import PlaylistColumns -from antarest.study.business.scenario_builder_management import Rulesets +from antarest.study.business.scenario_builder_management import Rulesets, ScenarioType from antarest.study.business.table_mode_management import TableDataDTO, TableModeType from antarest.study.business.thematic_trimming_field_infos import ThematicTrimmingFormFields from antarest.study.business.timeseries_config_management import TSFormFields @@ -65,6 +65,7 @@ BindingConstraintOperator, ) from antarest.study.storage.rawstudy.model.filesystem.config.model import transform_name_to_id +from antarest.study.storage.rawstudy.model.filesystem.config.ruleset_matrices import TableForm as SBTableForm logger = logging.getLogger(__name__) @@ -666,6 +667,88 @@ def get_scenario_builder_config( return study_service.scenario_builder_manager.get_config(study) + @bp.get( + path="/studies/{uuid}/config/scenariobuilder/{scenario_type}", + tags=[APITag.study_data], + summary="Get MC Scenario builder config", + response_model=t.Dict[str, SBTableForm], + ) + def get_scenario_builder_config_by_type( + uuid: str, + scenario_type: ScenarioType, + current_user: JWTUser = Depends(auth.get_current_user), + ) -> t.Dict[str, SBTableForm]: + """ + Retrieve the scenario matrix corresponding to a specified scenario type. + + The returned scenario matrix is structured as follows: + + ```json + { + "scenario_type": { + "area_id": { + "year": , + ... + }, + ... + }, + } + ``` + + For thermal and renewable scenarios, the format is: + + ```json + { + "scenario_type": { + "area_id": { + "cluster_id": { + "year": , + ... + }, + ... + }, + ... + }, + } + ``` + + For hydraulic levels scenarios, the format is: + + ```json + { + "scenario_type": { + "area_id": { + "year": , + ... + }, + ... + }, + } + ``` + + For binding constraints scenarios, the format is: + + ```json + { + "scenario_type": { + "group_name": { + "year": , + ... + }, + ... + }, + } + ``` + """ + logger.info( + f"Getting MC Scenario builder config for study {uuid} with scenario type filter: {scenario_type}", + extra={"user": current_user.id}, + ) + params = RequestParameters(user=current_user) + study = study_service.check_study_access(uuid, StudyPermissionType.READ, params) + table_form = study_service.scenario_builder_manager.get_scenario_by_type(study, scenario_type) + return {scenario_type: table_form} + @bp.put( path="/studies/{uuid}/config/scenariobuilder", tags=[APITag.study_data], @@ -684,6 +767,51 @@ def update_scenario_builder_config( study = study_service.check_study_access(uuid, StudyPermissionType.WRITE, params) study_service.scenario_builder_manager.update_config(study, data) + @bp.put( + path="/studies/{uuid}/config/scenariobuilder/{scenario_type}", + tags=[APITag.study_data], + summary="Set MC Scenario builder config", + response_model=t.Dict[str, SBTableForm], + ) + def update_scenario_builder_config_by_type( + uuid: str, + scenario_type: ScenarioType, + data: t.Dict[str, SBTableForm], + current_user: JWTUser = Depends(auth.get_current_user), + ) -> t.Dict[str, SBTableForm]: + """ + Update the scenario matrix corresponding to a specified scenario type. + + Args: + - `data`: partial scenario matrix using the following structure: + + ```json + { + "scenario_type": { + "area_id": { + "year": , + ... + }, + ... + }, + } + ``` + + > See the GET endpoint for the structure of the scenario matrix. + + Returns: + - The updated scenario matrix. + """ + logger.info( + f"Updating MC Scenario builder config for study {uuid} with scenario type filter: {scenario_type}", + extra={"user": current_user.id}, + ) + params = RequestParameters(user=current_user) + study = study_service.check_study_access(uuid, StudyPermissionType.WRITE, params) + table_form = data[scenario_type] + table_form = study_service.scenario_builder_manager.update_scenario_by_type(study, table_form, scenario_type) + return {scenario_type: table_form} + @bp.get( path="/studies/{uuid}/config/general/form", tags=[APITag.study_data], diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c4cfe1ee50..bdabc067c7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,6 +1,36 @@ Antares Web Changelog ===================== +v2.17.2 (2024-06-19) +-------------------- + +### Features + +* **ui-api:** add scenario builder v8.7 full support [`#2054`](https://github.com/AntaresSimulatorTeam/AntaREST/pull/2054) +* **api,ui-config:** add 'MILP' value option in 'Unit Commitment Mode' field for study >= v8.8 [`#2056`](https://github.com/AntaresSimulatorTeam/AntaREST/pull/2056) +* **outputs:** remove useless folder `updated_links` [`#2065`](https://github.com/AntaresSimulatorTeam/AntaREST/pull/2065) +* **ui:** add save button static at bottom and fix style issues [`#2068`](https://github.com/AntaresSimulatorTeam/AntaREST/pull/2068) +* **ui-theme:** increase scrollbar size [`#2069`](https://github.com/AntaresSimulatorTeam/AntaREST/pull/2069) +* **desktop:** add desktop version for ubuntu 22 [`#2072`](https://github.com/AntaresSimulatorTeam/AntaREST/pull/2072) + +### Bug Fixes + +* **variants:** display variants in reverse chronological order in the variants tree [`#2059`](https://github.com/AntaresSimulatorTeam/AntaREST/pull/2059) +* **table-mode:** do not alter existing links that are not updated [`#2055`](https://github.com/AntaresSimulatorTeam/AntaREST/pull/2055) +* **bc:** only remove terms when asked [`#2060`](https://github.com/AntaresSimulatorTeam/AntaREST/pull/2060) +* **table-mode:** correct the update of the `average_spilled_energy_cost` field in table mode [`#2062`](https://github.com/AntaresSimulatorTeam/AntaREST/pull/2062) +* **ui:** hide "upgrade" menu item for variant studies or studies with children [`#2063`](https://github.com/AntaresSimulatorTeam/AntaREST/pull/2063) +* **ui-commons:** display a popup to warn of unsaved modifications on Form [`#2071`](https://github.com/AntaresSimulatorTeam/AntaREST/pull/2071) + +### Continuous integration + +* **worker:** deploy AntaresWebWorker on its own [`#2066`](https://github.com/AntaresSimulatorTeam/AntaREST/pull/2066) +* **sonar:** bump github action download artifact [`#2070`](https://github.com/AntaresSimulatorTeam/AntaREST/pull/2070) + + +**Full Changelog**: https://github.com/AntaresSimulatorTeam/AntaREST/compare/v2.17.1...v2.17.2 + + v2.17.1 (2024-06-10) -------------------- diff --git a/scripts/package_antares_web.sh b/scripts/package_antares_web.sh index 98d9bc2db7..9a0efb5e15 100755 --- a/scripts/package_antares_web.sh +++ b/scripts/package_antares_web.sh @@ -3,7 +3,7 @@ # Antares Web Packaging -- Desktop Version # # This script is launch by the GitHub Workflow `.github/workflows/deploy.yml`. -# It builds the Desktop version of the Web Application and the Worker Application. +# It builds the Desktop version of the Web Application. # Make sure you run the `npm install` stage before running this script. set -e diff --git a/scripts/package_worker.sh b/scripts/package_worker.sh new file mode 100644 index 0000000000..686d131621 --- /dev/null +++ b/scripts/package_worker.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# AntaresWebWorker packaging +# +# This script is launch by the GitHub Workflow `.github/workflows/worker.yml`. +# It builds the AntaresWebWorker. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P) +PROJECT_DIR=$(dirname -- "${SCRIPT_DIR}") +DIST_DIR="${PROJECT_DIR}/dist" + +echo "INFO: Generating the Worker Application..." +pushd ${PROJECT_DIR} +pyinstaller --distpath ${DIST_DIR} AntaresWebWorker.spec +popd + +chmod +x "${DIST_DIR}/AntaresWebWorker" diff --git a/setup.py b/setup.py index dbe121af91..002035a113 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name="AntaREST", - version="2.17.1", + version="2.17.2", description="Antares Server", long_description=Path("README.md").read_text(encoding="utf-8"), long_description_content_type="text/markdown", diff --git a/sonar-project.properties b/sonar-project.properties index 4489e8d69d..4513c749b0 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -6,5 +6,5 @@ sonar.exclusions=antarest/gui.py,antarest/main.py sonar.python.coverage.reportPaths=coverage.xml sonar.python.version=3.8 sonar.javascript.lcov.reportPaths=webapp/coverage/lcov.info -sonar.projectVersion=2.17.1 +sonar.projectVersion=2.17.2 sonar.coverage.exclusions=antarest/gui.py,antarest/main.py,antarest/singleton_services.py,antarest/worker/archive_worker_service.py,webapp/**/* \ No newline at end of file diff --git a/tests/integration/study_data_blueprint/test_advanced_parameters.py b/tests/integration/study_data_blueprint/test_advanced_parameters.py new file mode 100644 index 0000000000..b126349335 --- /dev/null +++ b/tests/integration/study_data_blueprint/test_advanced_parameters.py @@ -0,0 +1,95 @@ +from http import HTTPStatus + +import pytest +from starlette.testclient import TestClient + +from antarest.core.tasks.model import TaskStatus +from tests.integration.utils import wait_task_completion + + +class TestAdvancedParametersForm: + """ + Test the end points related to advanced parameters. + + Those tests use the "examples/studies/STA-mini.zip" Study, + which contains the following areas: ["de", "es", "fr", "it"]. + """ + + def test_get_advanced_parameters_values( + self, + client: TestClient, + user_access_token: str, + study_id: str, + ): + """Check `get_advanced_parameters_form_values` end point""" + res = client.get( + f"/v1/studies/{study_id}/config/advancedparameters/form", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == HTTPStatus.OK, res.json() + actual = res.json() + expected = { + "accuracyOnCorrelation": "", + "dayAheadReserveManagement": "global", + "hydroHeuristicPolicy": "accommodate rule curves", + "hydroPricingMode": "fast", + "initialReservoirLevels": "cold start", + "numberOfCoresMode": "maximum", + "powerFluctuations": "free modulations", + "renewableGenerationModelling": "clusters", + "seedHydroCosts": 9005489, + "seedInitialReservoirLevels": 10005489, + "seedSpilledEnergyCosts": 7005489, + "seedThermalCosts": 8005489, + "seedTsgenHydro": 2005489, + "seedTsgenLoad": 1005489, + "seedTsgenSolar": 4005489, + "seedTsgenThermal": 3005489, + "seedTsgenWind": 5489, + "seedTsnumbers": 5005489, + "seedUnsuppliedEnergyCosts": 6005489, + "sheddingPolicy": "shave peaks", + "unitCommitmentMode": "fast", + } + assert actual == expected + + @pytest.mark.parametrize("study_version", [0, 880]) + def test_set_advanced_parameters_values( + self, client: TestClient, user_access_token: str, study_id: str, study_version: int + ): + """Check `set_advanced_parameters_values` end point""" + obj = {"initialReservoirLevels": "hot start"} + res = client.put( + f"/v1/studies/{study_id}/config/advancedparameters/form", + headers={"Authorization": f"Bearer {user_access_token}"}, + json=obj, + ) + assert res.status_code == HTTPStatus.OK, res.json() + actual = res.json() + assert actual is None + + if study_version: + res = client.put( + f"/v1/studies/{study_id}/upgrade", + headers={"Authorization": f"Bearer {user_access_token}"}, + params={"target_version": study_version}, + ) + assert res.status_code == 200, res.json() + + task_id = res.json() + task = wait_task_completion(client, user_access_token, task_id) + assert task.status == TaskStatus.COMPLETED, task + + obj = {"unitCommitmentMode": "milp"} + res = client.put( + f"/v1/studies/{study_id}/config/advancedparameters/form", + headers={"Authorization": f"Bearer {user_access_token}"}, + json=obj, + ) + if study_version: + assert res.status_code == HTTPStatus.OK, res.json() + else: + assert res.status_code == 422 + response = res.json() + assert response["exception"] == "InvalidFieldForVersionError" + assert response["description"] == "Unit commitment mode `MILP` only exists in v8.8+ studies" diff --git a/tests/integration/study_data_blueprint/test_binding_constraints.py b/tests/integration/study_data_blueprint/test_binding_constraints.py index fff973ae20..af537fcc7f 100644 --- a/tests/integration/study_data_blueprint/test_binding_constraints.py +++ b/tests/integration/study_data_blueprint/test_binding_constraints.py @@ -426,6 +426,23 @@ def test_lifecycle__nominal(self, client: TestClient, user_access_token: str, st ] assert constraint_terms == expected + # Update random field, shouldn't remove the term. + res = client.put( + f"v1/studies/{study_id}/bindingconstraints/{bc_id}", + json={"enabled": False}, + headers=user_headers, + ) + assert res.status_code == 200, res.json() + + res = client.get( + f"/v1/studies/{study_id}/bindingconstraints/{bc_id}", + headers=user_headers, + ) + assert res.status_code == 200, res.json() + binding_constraint = res.json() + constraint_terms = binding_constraint["terms"] + assert constraint_terms == expected + # ============================= # GENERAL EDITION # ============================= diff --git a/tests/integration/study_data_blueprint/test_table_mode.py b/tests/integration/study_data_blueprint/test_table_mode.py index 45ca2cc961..3ffbeb2b46 100644 --- a/tests/integration/study_data_blueprint/test_table_mode.py +++ b/tests/integration/study_data_blueprint/test_table_mode.py @@ -1,3 +1,4 @@ +import copy import typing as t import pytest @@ -24,7 +25,7 @@ class TestTableMode: def test_lifecycle__nominal( self, client: TestClient, user_access_token: str, study_id: str, study_version: int ) -> None: - user_headers = {"Authorization": f"Bearer {user_access_token}"} + client.headers = {"Authorization": f"Bearer {user_access_token}"} # In order to test the table mode for renewable clusters and short-term storage, # it is required that the study is either in version 8.1 for renewable energies @@ -35,7 +36,6 @@ def test_lifecycle__nominal( if study_version: res = client.put( f"/v1/studies/{study_id}/upgrade", - headers={"Authorization": f"Bearer {user_access_token}"}, params={"target_version": study_version}, ) assert res.status_code == 200, res.json() @@ -44,14 +44,15 @@ def test_lifecycle__nominal( task = wait_task_completion(client, user_access_token, task_id) assert task.status == TaskStatus.COMPLETED, task + # Create another link to test specific bug. + res = client.post(f"/v1/studies/{study_id}/links", json={"area1": "de", "area2": "it"}) + assert res.status_code in [200, 201], res.json() + # Table Mode - Area # ================= # Get the schema of the areas table - res = client.get( - "/v1/table-schema/areas", - headers=user_headers, - ) + res = client.get("/v1/table-schema/areas") assert res.status_code == 200, res.json() actual = res.json() assert set(actual["properties"]) == { @@ -83,7 +84,6 @@ def test_lifecycle__nominal( res = client.put( f"/v1/studies/{study_id}/table-mode/areas", - headers=user_headers, json={ "de": _de_values, "es": _es_values, @@ -147,18 +147,31 @@ def test_lifecycle__nominal( actual = res.json() assert actual == expected_areas - res = client.get(f"/v1/studies/{study_id}/table-mode/areas", headers=user_headers) + res = client.get(f"/v1/studies/{study_id}/table-mode/areas") assert res.status_code == 200, res.json() actual = res.json() assert actual == expected_areas + # Specific tests for averageSpilledEnergyCost and averageUnsuppliedEnergyCost + _de_values = { + "averageSpilledEnergyCost": 123, + "averageUnsuppliedEnergyCost": 456, + } + res = client.put( + f"/v1/studies/{study_id}/table-mode/areas", + json={"de": _de_values}, + ) + assert res.status_code == 200, res.json() + actual = res.json()["de"] + assert actual["averageSpilledEnergyCost"] == 123 + assert actual["averageUnsuppliedEnergyCost"] == 456 + # Table Mode - Links # ================== # Get the schema of the links table res = client.get( "/v1/table-schema/links", - headers=user_headers, ) assert res.status_code == 200, res.json() actual = res.json() @@ -179,7 +192,6 @@ def test_lifecycle__nominal( res = client.put( f"/v1/studies/{study_id}/table-mode/links", - headers=user_headers, json={ "de / fr": { "colorRgb": "#FFA500", @@ -226,6 +238,20 @@ def test_lifecycle__nominal( "transmissionCapacities": "ignore", "usePhaseShifter": False, }, + "de / it": { + "assetType": "ac", + "colorRgb": "#707070", + "comments": "", + "displayComments": True, + "filterSynthesis": "hourly, daily, weekly, monthly, annual", + "filterYearByYear": "hourly, daily, weekly, monthly, annual", + "hurdlesCost": False, + "linkStyle": "plain", + "linkWidth": 1, + "loopFlow": False, + "transmissionCapacities": "enabled", + "usePhaseShifter": False, + }, "es / fr": { "assetType": "ac", "colorRgb": "#FF6347", @@ -255,12 +281,16 @@ def test_lifecycle__nominal( "usePhaseShifter": False, }, } + # asserts actual equals expected without the non-updated link. actual = res.json() - assert actual == expected_links + expected_result = copy.deepcopy(expected_links) + del expected_result["de / it"] + assert actual == expected_result - res = client.get(f"/v1/studies/{study_id}/table-mode/links", headers=user_headers) + res = client.get(f"/v1/studies/{study_id}/table-mode/links") assert res.status_code == 200, res.json() actual = res.json() + # asserts the `de / it` link is not removed. assert actual == expected_links # Table Mode - Thermal Clusters @@ -269,7 +299,6 @@ def test_lifecycle__nominal( # Get the schema of the thermals table res = client.get( "/v1/table-schema/thermals", - headers=user_headers, ) assert res.status_code == 200, res.json() actual = res.json() @@ -326,7 +355,6 @@ def test_lifecycle__nominal( res = client.put( f"/v1/studies/{study_id}/table-mode/thermals", - headers=user_headers, json={ "de / 01_solar": _solar_values, "de / 02_wind_on": _wind_on_values, @@ -410,7 +438,6 @@ def test_lifecycle__nominal( res = client.get( f"/v1/studies/{study_id}/table-mode/thermals", - headers=user_headers, params={"columns": ",".join(["group", "unitCount", "nominalCapacity", "so2"])}, ) assert res.status_code == 200, res.json() @@ -474,7 +501,6 @@ def test_lifecycle__nominal( } res = client.post( f"/v1/studies/{study_id}/commands", - headers={"Authorization": f"Bearer {user_access_token}"}, json=[{"action": "update_config", "args": args}], ) assert res.status_code == 200, res.json() @@ -534,7 +560,6 @@ def test_lifecycle__nominal( for generator_id, generator in generators.items(): res = client.post( f"/v1/studies/{study_id}/areas/{area_id}/clusters/renewable", - headers=user_headers, json=generator, ) res.raise_for_status() @@ -542,7 +567,6 @@ def test_lifecycle__nominal( # Get the schema of the renewables table res = client.get( "/v1/table-schema/renewables", - headers=user_headers, ) assert res.status_code == 200, res.json() actual = res.json() @@ -561,7 +585,6 @@ def test_lifecycle__nominal( # Update some generators using the table mode res = client.put( f"/v1/studies/{study_id}/table-mode/renewables", - headers=user_headers, json={ "fr / Dieppe": {"enabled": False}, "fr / La Rochelle": {"enabled": True, "nominalCapacity": 3.1, "unitCount": 2}, @@ -572,7 +595,6 @@ def test_lifecycle__nominal( res = client.get( f"/v1/studies/{study_id}/table-mode/renewables", - headers=user_headers, params={"columns": ",".join(["group", "enabled", "unitCount", "nominalCapacity"])}, ) assert res.status_code == 200, res.json() @@ -595,7 +617,6 @@ def test_lifecycle__nominal( # Get the schema of the short-term storages table res = client.get( "/v1/table-schema/st-storages", - headers=user_headers, ) assert res.status_code == 200, res.json() actual = res.json() @@ -659,7 +680,6 @@ def test_lifecycle__nominal( for storage_id, storage in storages.items(): res = client.post( f"/v1/studies/{study_id}/areas/{area_id}/storages", - headers=user_headers, json=storage, ) res.raise_for_status() @@ -673,7 +693,6 @@ def test_lifecycle__nominal( res = client.put( f"/v1/studies/{study_id}/table-mode/st-storages", - headers=user_headers, json={ "fr / siemens": _fr_siemes_values, "fr / tesla": _fr_tesla_values, @@ -742,7 +761,6 @@ def test_lifecycle__nominal( res = client.get( f"/v1/studies/{study_id}/table-mode/st-storages", - headers=user_headers, params={ "columns": ",".join( [ @@ -793,7 +811,6 @@ def test_lifecycle__nominal( fr_id = "fr" res = client.post( f"/v1/studies/{study_id}/areas/{fr_id}/clusters/thermal", - headers=user_headers, json={ "name": "Cluster 1", "group": "Nuclear", @@ -812,7 +829,6 @@ def test_lifecycle__nominal( "time_step": "hourly", "operator": "less", }, - headers=user_headers, ) assert res.status_code == 200, res.json() @@ -826,14 +842,12 @@ def test_lifecycle__nominal( "comments": "This is a binding constraint", "filter_synthesis": "hourly, daily, weekly", }, - headers=user_headers, ) assert res.status_code == 200, res.json() # Get the schema of the binding constraints table res = client.get( "/v1/table-schema/binding-constraints", - headers=user_headers, ) assert res.status_code == 200, res.json() actual = res.json() @@ -861,7 +875,6 @@ def test_lifecycle__nominal( res = client.put( f"/v1/studies/{study_id}/table-mode/binding-constraints", - headers=user_headers, json={ "binding constraint 1": _bc1_values, "binding constraint 2": _bc2_values, @@ -897,7 +910,6 @@ def test_lifecycle__nominal( res = client.get( f"/v1/studies/{study_id}/table-mode/binding-constraints", - headers=user_headers, params={"columns": ""}, ) assert res.status_code == 200, res.json() @@ -910,8 +922,8 @@ def test_table_type_aliases(client: TestClient, user_access_token: str) -> None: """ Ensure that we can use the old table type aliases to get the schema of the tables. """ - user_headers = {"Authorization": f"Bearer {user_access_token}"} + client.headers = {"Authorization": f"Bearer {user_access_token}"} # do not use `pytest.mark.parametrize`, because it is too slow for table_type in ["area", "link", "cluster", "renewable", "binding constraint"]: - res = client.get(f"/v1/table-schema/{table_type}", headers=user_headers) + res = client.get(f"/v1/table-schema/{table_type}") assert res.status_code == 200, f"Failed to get schema for {table_type}: {res.json()}" diff --git a/tests/integration/variant_blueprint/test_variant_manager.py b/tests/integration/variant_blueprint/test_variant_manager.py index a0e4a68108..82fa7ab95c 100644 --- a/tests/integration/variant_blueprint/test_variant_manager.py +++ b/tests/integration/variant_blueprint/test_variant_manager.py @@ -57,8 +57,9 @@ def test_variant_manager( assert len(children["children"]) == 1 assert children["children"][0]["node"]["name"] == "Variant1" assert len(children["children"][0]["children"]) == 2 - assert children["children"][0]["children"][0]["node"]["name"] == "bar" - assert children["children"][0]["children"][1]["node"]["name"] == "baz" + # Variant children are sorted by creation date in reverse order + assert children["children"][0]["children"][0]["node"]["name"] == "baz" + assert children["children"][0]["children"][1]["node"]["name"] == "bar" # George creates a base study # He creates a variant from this study : assert that no command is created diff --git a/tests/launcher/test_slurm_launcher.py b/tests/launcher/test_slurm_launcher.py index 9476f86be8..a509342837 100644 --- a/tests/launcher/test_slurm_launcher.py +++ b/tests/launcher/test_slurm_launcher.py @@ -1,6 +1,5 @@ import os import random -import shutil import textwrap import uuid from argparse import Namespace @@ -400,13 +399,6 @@ def test_import_study_output(launcher_config, tmp_path) -> None: xpansion_test_file.write_text("world") output_dir = launcher_config.launcher.slurm.local_workspace / "OUTPUT" / "1" / "output" / "output_name" output_dir.mkdir(parents=True) - assert not (output_dir / "updated_links" / "something").exists() - assert not (output_dir / "updated_links" / "something").exists() - - slurm_launcher._import_study_output("1", "cpp") - assert (output_dir / "updated_links" / "something").exists() - assert (output_dir / "updated_links" / "something").read_text() == "hello" - shutil.rmtree(output_dir / "updated_links") slurm_launcher._import_study_output("1", "r") assert (output_dir / "results" / "something_else").exists() diff --git a/tests/storage/business/test_repository.py b/tests/storage/business/test_repository.py index 9719c65228..5da1d6ef33 100644 --- a/tests/storage/business/test_repository.py +++ b/tests/storage/business/test_repository.py @@ -14,7 +14,7 @@ def test_get_children(self, db_session: Session) -> None: """ Given a root study with children and a grandchild When getting the children of the root study - Then the children are returned in chronological order + Then the children are returned in reverse chronological order """ repository = VariantStudyRepository(cache_service=Mock(spec=ICache), session=db_session) @@ -41,8 +41,8 @@ def test_get_children(self, db_session: Session) -> None: # Ensure the root study has 2 children children = repository.get_children(parent_id=raw_study.id) - assert children == [variant1, variant2] - assert children[0].created_at < children[1].created_at + assert children == [variant2, variant1] + assert children[0].created_at > children[1].created_at # Ensure variants have no children children = repository.get_children(parent_id=variant1.id) @@ -50,15 +50,15 @@ def test_get_children(self, db_session: Session) -> None: children = repository.get_children(parent_id=variant2.id) assert children == [] - # Add a variant study between the two existing ones (in chronological order) + # Add a variant study between the two existing ones (in reverse chronological order) variant3 = VariantStudy(name="My Variant 3", parent_id=raw_study.id, created_at=day2) db_session.add(variant3) db_session.commit() # Ensure the root study has 3 children in chronological order children = repository.get_children(parent_id=raw_study.id) - assert children == [variant1, variant3, variant2] - assert children[0].created_at < children[1].created_at < children[2].created_at + assert children == [variant2, variant3, variant1] + assert children[0].created_at > children[1].created_at > children[2].created_at # Add a variant of a variant variant3a = VariantStudy(name="My Variant 3a", parent_id=variant3.id, created_at=day4) @@ -67,4 +67,4 @@ def test_get_children(self, db_session: Session) -> None: # Ensure the root study has the 3 same children children = repository.get_children(parent_id=raw_study.id) - assert children == [variant1, variant3, variant2] + assert children == [variant2, variant3, variant1] diff --git a/tests/storage/repository/antares_io/reader/test_ini_reader.py b/tests/storage/repository/antares_io/reader/test_ini_reader.py index fb87061ff7..4ff38a32c6 100644 --- a/tests/storage/repository/antares_io/reader/test_ini_reader.py +++ b/tests/storage/repository/antares_io/reader/test_ini_reader.py @@ -232,6 +232,86 @@ def test_read__sets(self) -> None: } assert actual == expected + def test_read__filtered_section(self, tmp_path) -> None: + path = Path(tmp_path) / "test.ini" + path.write_text( + textwrap.dedent( + """ + [part1] + foo = 5 + bar = hello + + [part2] + foo = 6 + bar = salut + + [other] + pi = 3.14 + """ + ) + ) + + reader = IniReader() + + # exact match + actual = reader.read(path, section="part1") + expected = {"part1": {"foo": 5, "bar": "hello"}} + assert actual == expected + + # regex match + actual = reader.read(path, section_regex="part.*") + expected = { + "part1": {"foo": 5, "bar": "hello"}, + "part2": {"foo": 6, "bar": "salut"}, + } + assert actual == expected + + def test_read__filtered_option(self, tmp_path) -> None: + path = Path(tmp_path) / "test.ini" + path.write_text( + textwrap.dedent( + """ + [part1] + foo = 5 + bar = hello + + [part2] + foo = 6 + bar = salut + + [other] + pi = 3.14 + """ + ) + ) + + reader = IniReader() + + # exact match + actual = reader.read(path, option="foo") + expected = {"part1": {"foo": 5}, "part2": {"foo": 6}, "other": {}} + assert actual == expected + + # regex match + actual = reader.read(path, option_regex="fo.*") + expected = {"part1": {"foo": 5}, "part2": {"foo": 6}, "other": {}} + assert actual == expected + + # exact match with section + actual = reader.read(path, section="part2", option="foo") + expected = {"part2": {"foo": 6}} + assert actual == expected + + # regex match with section + actual = reader.read(path, section_regex="part.*", option="foo") + expected = {"part1": {"foo": 5}, "part2": {"foo": 6}} + assert actual == expected + + # regex match with section and option + actual = reader.read(path, section_regex="part.*", option_regex=".*a.*") + expected = {"part1": {"bar": "hello"}, "part2": {"bar": "salut"}} + assert actual == expected + class TestSimpleKeyValueReader: def test_read(self) -> None: diff --git a/tests/storage/repository/filesystem/config/test_ruleset_matrices.py b/tests/storage/repository/filesystem/config/test_ruleset_matrices.py new file mode 100644 index 0000000000..40651bce59 --- /dev/null +++ b/tests/storage/repository/filesystem/config/test_ruleset_matrices.py @@ -0,0 +1,575 @@ +import typing as t + +import numpy as np +import pytest + +from antarest.study.storage.rawstudy.model.filesystem.config.ruleset_matrices import RulesetMatrices, TableForm + +SCENARIO_TYPES = { + "l": "load", + "h": "hydro", + "w": "wind", + "s": "solar", + "t": "thermal", + "r": "renewable", + "ntc": "link", + "bc": "bindingConstraints", + "hl": "hydroInitialLevels", + "hfl": "hydroFinalLevels", + "hgp": "hydroGenerationPower", +} + + +@pytest.fixture(name="ruleset") +def ruleset_fixture() -> RulesetMatrices: + return RulesetMatrices( + nb_years=4, + areas=["France", "Germany", "Italy"], + links=[("Germany", "France"), ("Italy", "France")], + thermals={"France": ["nuclear", "coal"], "Italy": ["nuclear", "fuel"], "Germany": ["gaz", "fuel"]}, + renewables={"France": ["wind offshore", "wind onshore"], "Germany": ["wind onshore"]}, + groups=["Main", "Secondary"], + scenario_types=SCENARIO_TYPES, + ) + + +class TestRulesetMatrices: + def test_ruleset__init(self, ruleset: RulesetMatrices) -> None: + assert ruleset.columns == ["0", "1", "2", "3"] + assert ruleset.scenarios["load"].shape == (3, 4) + assert ruleset.scenarios["load"].index.tolist() == ["France", "Germany", "Italy"] + assert ruleset.scenarios["hydro"].shape == (3, 4) + assert ruleset.scenarios["hydro"].index.tolist() == ["France", "Germany", "Italy"] + assert ruleset.scenarios["wind"].shape == (3, 4) + assert ruleset.scenarios["wind"].index.tolist() == ["France", "Germany", "Italy"] + assert ruleset.scenarios["solar"].shape == (3, 4) + assert ruleset.scenarios["solar"].index.tolist() == ["France", "Germany", "Italy"] + thermal = ruleset.scenarios["thermal"] + assert thermal["France"].shape == (2, 4) + assert thermal["France"].index.tolist() == ["nuclear", "coal"] + assert thermal["Italy"].shape == (2, 4) + assert thermal["Italy"].index.tolist() == ["nuclear", "fuel"] + assert thermal["Germany"].shape == (2, 4) + renewable = ruleset.scenarios["renewable"] + assert renewable["France"].shape == (2, 4) + assert renewable["France"].index.tolist() == ["wind offshore", "wind onshore"] + assert renewable["Germany"].shape == (1, 4) + assert renewable["Germany"].index.tolist() == ["wind onshore"] + assert ruleset.scenarios["link"].shape == (2, 4) + assert ruleset.scenarios["link"].index.tolist() == ["Germany / France", "Italy / France"] + assert ruleset.scenarios["bindingConstraints"].shape == (2, 4) + assert ruleset.scenarios["bindingConstraints"].index.tolist() == ["Main", "Secondary"] + assert ruleset.scenarios["hydroInitialLevels"].shape == (3, 4) + assert ruleset.scenarios["hydroInitialLevels"].index.tolist() == ["France", "Germany", "Italy"] + assert ruleset.scenarios["hydroFinalLevels"].shape == (3, 4) + assert ruleset.scenarios["hydroFinalLevels"].index.tolist() == ["France", "Germany", "Italy"] + assert ruleset.scenarios["hydroGenerationPower"].shape == (3, 4) + assert ruleset.scenarios["hydroGenerationPower"].index.tolist() == ["France", "Germany", "Italy"] + + @pytest.mark.parametrize( + "symbol, scenario_type", + [ + ("l", "load"), + ("h", "hydro"), + ("w", "wind"), + ("s", "solar"), + ("hgp", "hydroGenerationPower"), + ], + ) + def test_update_rules__load(self, ruleset: RulesetMatrices, symbol: str, scenario_type: str) -> None: + rules = { + f"{symbol},france,0": 1, + f"{symbol},germany,0": 2, + f"{symbol},italy,0": 3, + f"{symbol},france,1": 4, + f"{symbol},germany,1": 5, + f"{symbol},italy,1": 6, + f"{symbol},france,2": 7, + f"{symbol},germany,2": 8, + } + ruleset.update_rules(rules) + actual = ruleset.scenarios[scenario_type] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "France": {"0": 1, "1": 4, "2": 7, "3": "NaN"}, + "Germany": {"0": 2, "1": 5, "2": 8, "3": "NaN"}, + "Italy": {"0": 3, "1": 6, "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual_rules = ruleset.get_rules() + assert actual_rules == rules + for rule_id, ts_number in actual_rules.items(): + assert isinstance(ts_number, int) + + def test_update_rules__link(self, ruleset: RulesetMatrices) -> None: + rules = { + "ntc,germany,france,0": 1, + "ntc,italy,france,0": 2, + "ntc,germany,france,1": 3, + "ntc,italy,france,1": 4, + "ntc,germany,france,2": 5, + } + ruleset.update_rules(rules) + actual = ruleset.scenarios["link"] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "Germany / France": {"0": 1, "1": 3, "2": 5, "3": "NaN"}, + "Italy / France": {"0": 2, "1": 4, "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual_rules = ruleset.get_rules() + assert actual_rules == rules + for rule_id, ts_number in actual_rules.items(): + assert isinstance(ts_number, int) + + def test_update_rules__thermal(self, ruleset: RulesetMatrices) -> None: + rules = { + "t,france,0,nuclear": 1, + "t,france,0,coal": 2, + "t,italy,0,nuclear": 3, + "t,italy,0,fuel": 4, + "t,france,1,nuclear": 5, + "t,france,1,coal": 6, + "t,italy,1,nuclear": 7, + "t,italy,1,fuel": 8, + } + ruleset.update_rules(rules) + actual_map = ruleset.scenarios["thermal"] + + actual = actual_map["France"] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "coal": {"0": 2, "1": 6, "2": "NaN", "3": "NaN"}, + "nuclear": {"0": 1, "1": 5, "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual = actual_map["Italy"] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "fuel": {"0": 4, "1": 8, "2": "NaN", "3": "NaN"}, + "nuclear": {"0": 3, "1": 7, "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual = actual_map["Germany"] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "gaz": {"0": "NaN", "1": "NaN", "2": "NaN", "3": "NaN"}, + "fuel": {"0": "NaN", "1": "NaN", "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual_rules = ruleset.get_rules() + assert actual_rules == rules + for rule_id, ts_number in actual_rules.items(): + assert isinstance(ts_number, int) + + def test_update_rules__renewable(self, ruleset: RulesetMatrices) -> None: + rules = { + "r,france,0,wind offshore": 1, + "r,france,0,wind onshore": 2, + "r,germany,0,wind onshore": 3, + "r,france,1,wind offshore": 4, + "r,france,1,wind onshore": 5, + "r,germany,1,wind onshore": 6, + } + ruleset.update_rules(rules) + actual_map = ruleset.scenarios["renewable"] + + actual = actual_map["France"] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "wind offshore": {"0": 1, "1": 4, "2": "NaN", "3": "NaN"}, + "wind onshore": {"0": 2, "1": 5, "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual = actual_map["Germany"] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "wind onshore": {"0": 3, "1": 6, "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual_rules = ruleset.get_rules() + assert actual_rules == rules + for rule_id, ts_number in actual_rules.items(): + assert isinstance(ts_number, int) + + def test_update_rules__hydro(self, ruleset: RulesetMatrices) -> None: + rules = { + "h,france,0": 1, + "h,germany,0": 2, + "h,italy,0": 3, + "h,france,1": 4, + "h,germany,1": 5, + "h,italy,1": 6, + } + ruleset.update_rules(rules) + actual = ruleset.scenarios["hydro"] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "France": {"0": 1, "1": 4, "2": "NaN", "3": "NaN"}, + "Germany": {"0": 2, "1": 5, "2": "NaN", "3": "NaN"}, + "Italy": {"0": 3, "1": 6, "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual_rules = ruleset.get_rules() + assert actual_rules == rules + for rule_id, ts_number in actual_rules.items(): + assert isinstance(ts_number, int) + + def test_update_rules__hydro_generation_power(self, ruleset: RulesetMatrices) -> None: + rules = { + "hgp,france,0": 1, + "hgp,germany,0": 2, + "hgp,italy,0": 3, + "hgp,france,1": 4, + "hgp,germany,1": 5, + "hgp,italy,1": 6, + } + ruleset.update_rules(rules) + actual = ruleset.scenarios["hydroGenerationPower"] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "France": {"0": 1, "1": 4, "2": "NaN", "3": "NaN"}, + "Germany": {"0": 2, "1": 5, "2": "NaN", "3": "NaN"}, + "Italy": {"0": 3, "1": 6, "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual_rules = ruleset.get_rules() + assert actual_rules == rules + for rule_id, ts_number in actual_rules.items(): + assert isinstance(ts_number, int) + + def test_update_rules__binding_constraints(self, ruleset: RulesetMatrices) -> None: + rules = { + "bc,main,0": 1, + "bc,secondary,0": 2, + "bc,main,1": 3, + "bc,secondary,1": 4, + "bc,main,2": 5, + } + ruleset.update_rules(rules) + actual = ruleset.scenarios["bindingConstraints"] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "Main": {"0": 1, "1": 3, "2": 5, "3": "NaN"}, + "Secondary": {"0": 2, "1": 4, "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual_rules = ruleset.get_rules() + assert actual_rules == rules + for rule_id, ts_number in actual_rules.items(): + assert isinstance(ts_number, int) + + def test_update_rules__hydro_initial_levels(self, ruleset: RulesetMatrices) -> None: + rules = { + "hl,france,0": 0.1, + "hl,germany,0": 0.2, + "hl,italy,0": 0.3, + "hl,france,1": 0.4537, + "hl,germany,1": 0.5, + "hl,italy,1": 0.6, + } + ruleset.update_rules(rules) + actual = ruleset.scenarios["hydroInitialLevels"] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "France": {"0": 10, "1": 45.37, "2": "NaN", "3": "NaN"}, + "Germany": {"0": 20, "1": 50, "2": "NaN", "3": "NaN"}, + "Italy": {"0": 30, "1": 60, "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual_rules = ruleset.get_rules() + assert actual_rules == rules + for rule_id, ts_number in actual_rules.items(): + assert isinstance(ts_number, float) + + def test_update_rules__hydro_final_levels(self, ruleset: RulesetMatrices) -> None: + rules = { + "hfl,france,0": 0.1, + "hfl,germany,0": 0.2, + "hfl,italy,0": 0.3, + "hfl,france,1": 0.4, + "hfl,germany,1": 0.5, + "hfl,italy,1": 0.6, + } + ruleset.update_rules(rules) + actual = ruleset.scenarios["hydroFinalLevels"] + actual = actual.fillna("NaN").to_dict(orient="index") + expected = { + "France": {"0": 10, "1": 40, "2": "NaN", "3": "NaN"}, + "Germany": {"0": 20, "1": 50, "2": "NaN", "3": "NaN"}, + "Italy": {"0": 30, "1": 60, "2": "NaN", "3": "NaN"}, + } + assert actual == expected + + actual_rules = ruleset.get_rules() + assert actual_rules == rules + + def test_update_rules__invalid(self, ruleset: RulesetMatrices) -> None: + rules = { + "invalid,france,0": 1, + "invalid,germany,0": 2, + "invalid,italy,0": 3, + "invalid,france,1": 4, + "invalid,germany,1": 5, + "invalid,italy,1": 6, + } + with pytest.raises(KeyError): + ruleset.update_rules(rules) + assert ruleset.get_rules() == {} + + def test_set_table_form(self, ruleset: RulesetMatrices) -> None: + table_form = { + "load": { + "France": {"0": 1, "1": 2, "2": 3, "3": 4}, + "Germany": {"0": 5, "1": 6, "2": 7, "3": 8}, + "Italy": {"0": 9, "1": "", "2": "", "3": ""}, + }, + "hydro": { + "France": {"0": 5, "1": 6, "2": "", "3": 8}, + "Germany": {"0": 9, "1": 10, "2": 11, "3": 12}, + "Italy": {"0": 13, "1": "", "2": 15, "3": 16}, + }, + "wind": { + "France": {"0": 17, "1": 18, "2": 19, "3": 20}, + "Germany": {"0": 21, "1": 22, "2": 23, "3": 24}, + "Italy": {"0": 25, "1": 26, "2": 27, "3": 28}, + }, + "solar": { + "France": {"0": 29, "1": 30, "2": 31, "3": 32}, + "Germany": {"0": 33, "1": 34, "2": 35, "3": 36}, + "Italy": {"0": 37, "1": 38, "2": 39, "3": 40}, + }, + "thermal": { + "France": { + "nuclear": {"0": 41, "1": 42, "2": 43, "3": 44}, + "coal": {"0": 45, "1": 46, "2": 47, "3": 48}, + }, + "Germany": { + "gaz": {"0": 49, "1": 50, "2": 51, "3": 52}, + "fuel": {"0": 53, "1": 54, "2": 55, "3": 56}, + }, + "Italy": { + "nuclear": {"0": 57, "1": 58, "2": 59, "3": 60}, + "fuel": {"0": 61, "1": 62, "2": 63, "3": 64}, + }, + }, + "renewable": { + "France": { + "wind offshore": {"0": 65, "1": 66, "2": 67, "3": 68}, + "wind onshore": {"0": 69, "1": 70, "2": 71, "3": 72}, + }, + "Germany": { + "wind onshore": {"0": 73, "1": 74, "2": 75, "3": 76}, + }, + }, + "link": { + "Germany / France": {"0": 77, "1": 78, "2": 79, "3": 80}, + "Italy / France": {"0": 81, "1": 82, "2": 83, "3": 84}, + }, + "bindingConstraints": { + "Main": {"0": 85, "1": 86, "2": 87, "3": 88}, + "Secondary": {"0": 89, "1": 90, "2": 91, "3": 92}, + }, + "hydroInitialLevels": { + "France": {"0": 93, "1": 94, "2": 95, "3": 96}, + "Germany": {"0": 97, "1": 98, "2": 99, "3": 100}, + "Italy": {"0": 101, "1": 102, "2": 103, "3": 104}, + }, + "hydroFinalLevels": { + "France": {"0": 105, "1": 106, "2": 107, "3": 108}, + "Germany": {"0": 109, "1": 110, "2": 111, "3": 112}, + "Italy": {"0": 113, "1": 114, "2": 115, "3": 116}, + }, + "hydroGenerationPower": { + "France": {"0": 117, "1": 118, "2": 119, "3": 120}, + "Germany": {"0": 121, "1": 122, "2": 123, "3": 124}, + "Italy": {"0": 125, "1": 126, "2": 127, "3": 128}, + }, + } + for scenario_type, table in table_form.items(): + ruleset.set_table_form(table, scenario_type) + actual_rules = ruleset.get_rules() + expected = { + "bc,main,0": 85, + "bc,main,1": 86, + "bc,main,2": 87, + "bc,main,3": 88, + "bc,secondary,0": 89, + "bc,secondary,1": 90, + "bc,secondary,2": 91, + "bc,secondary,3": 92, + "h,france,0": 5, + "h,france,1": 6, + "h,france,3": 8, + "h,germany,0": 9, + "h,germany,1": 10, + "h,germany,2": 11, + "h,germany,3": 12, + "h,italy,0": 13, + "h,italy,2": 15, + "h,italy,3": 16, + "hfl,france,0": 1.05, + "hfl,france,1": 1.06, + "hfl,france,2": 1.07, + "hfl,france,3": 1.08, + "hfl,germany,0": 1.09, + "hfl,germany,1": 1.1, + "hfl,germany,2": 1.11, + "hfl,germany,3": 1.12, + "hfl,italy,0": 1.13, + "hfl,italy,1": 1.14, + "hfl,italy,2": 1.15, + "hfl,italy,3": 1.16, + "hgp,france,0": 117, + "hgp,france,1": 118, + "hgp,france,2": 119, + "hgp,france,3": 120, + "hgp,germany,0": 121, + "hgp,germany,1": 122, + "hgp,germany,2": 123, + "hgp,germany,3": 124, + "hgp,italy,0": 125, + "hgp,italy,1": 126, + "hgp,italy,2": 127, + "hgp,italy,3": 128, + "hl,france,0": 0.93, + "hl,france,1": 0.94, + "hl,france,2": 0.95, + "hl,france,3": 0.96, + "hl,germany,0": 0.97, + "hl,germany,1": 0.98, + "hl,germany,2": 0.99, + "hl,germany,3": 1.0, + "hl,italy,0": 1.01, + "hl,italy,1": 1.02, + "hl,italy,2": 1.03, + "hl,italy,3": 1.04, + "l,france,0": 1, + "l,france,1": 2, + "l,france,2": 3, + "l,france,3": 4, + "l,germany,0": 5, + "l,germany,1": 6, + "l,germany,2": 7, + "l,germany,3": 8, + "l,italy,0": 9, + "ntc,germany,france,0": 77, + "ntc,germany,france,1": 78, + "ntc,germany,france,2": 79, + "ntc,germany,france,3": 80, + "ntc,italy,france,0": 81, + "ntc,italy,france,1": 82, + "ntc,italy,france,2": 83, + "ntc,italy,france,3": 84, + "r,france,0,wind offshore": 65, + "r,france,0,wind onshore": 69, + "r,france,1,wind offshore": 66, + "r,france,1,wind onshore": 70, + "r,france,2,wind offshore": 67, + "r,france,2,wind onshore": 71, + "r,france,3,wind offshore": 68, + "r,france,3,wind onshore": 72, + "r,germany,0,wind onshore": 73, + "r,germany,1,wind onshore": 74, + "r,germany,2,wind onshore": 75, + "r,germany,3,wind onshore": 76, + "s,france,0": 29, + "s,france,1": 30, + "s,france,2": 31, + "s,france,3": 32, + "s,germany,0": 33, + "s,germany,1": 34, + "s,germany,2": 35, + "s,germany,3": 36, + "s,italy,0": 37, + "s,italy,1": 38, + "s,italy,2": 39, + "s,italy,3": 40, + "t,france,0,coal": 45, + "t,france,0,nuclear": 41, + "t,france,1,coal": 46, + "t,france,1,nuclear": 42, + "t,france,2,coal": 47, + "t,france,2,nuclear": 43, + "t,france,3,coal": 48, + "t,france,3,nuclear": 44, + "t,germany,0,fuel": 53, + "t,germany,0,gaz": 49, + "t,germany,1,fuel": 54, + "t,germany,1,gaz": 50, + "t,germany,2,fuel": 55, + "t,germany,2,gaz": 51, + "t,germany,3,fuel": 56, + "t,germany,3,gaz": 52, + "t,italy,0,fuel": 61, + "t,italy,0,nuclear": 57, + "t,italy,1,fuel": 62, + "t,italy,1,nuclear": 58, + "t,italy,2,fuel": 63, + "t,italy,2,nuclear": 59, + "t,italy,3,fuel": 64, + "t,italy,3,nuclear": 60, + "w,france,0": 17, + "w,france,1": 18, + "w,france,2": 19, + "w,france,3": 20, + "w,germany,0": 21, + "w,germany,1": 22, + "w,germany,2": 23, + "w,germany,3": 24, + "w,italy,0": 25, + "w,italy,1": 26, + "w,italy,2": 27, + "w,italy,3": 28, + } + assert actual_rules == expected + # fmt: off + assert ruleset.get_table_form("load") == table_form["load"] + assert ruleset.get_table_form("hydro") == table_form["hydro"] + assert ruleset.get_table_form("wind") == table_form["wind"] + assert ruleset.get_table_form("solar") == table_form["solar"] + assert ruleset.get_table_form("thermal") == table_form["thermal"] + assert ruleset.get_table_form("renewable") == table_form["renewable"] + assert ruleset.get_table_form("link") == table_form["link"] + assert ruleset.get_table_form("bindingConstraints") == table_form["bindingConstraints"] + assert ruleset.get_table_form("hydroInitialLevels") == table_form["hydroInitialLevels"] + assert ruleset.get_table_form("hydroFinalLevels") == table_form["hydroFinalLevels"] + assert ruleset.get_table_form("hydroGenerationPower") == table_form["hydroGenerationPower"] + # fmt: on + + with pytest.raises(KeyError): + ruleset.get_table_form("invalid") + + @pytest.mark.parametrize( + "table_form, expected", + [ + ({"France": {"0": 23}}, 23), + ({"France": {"0": None}}, np.nan), + ({"France": {"0": ""}}, np.nan), + ], + ) + @pytest.mark.parametrize("old_value", [12, None, np.nan, ""], ids=["int", "None", "NaN", "empty"]) + def test_update_table_form( + self, + ruleset: RulesetMatrices, + table_form: TableForm, + expected: float, + old_value: t.Union[int, float, str], + ) -> None: + ruleset.scenarios["load"].at["France", "0"] = old_value + ruleset.update_table_form(table_form, "load") + actual = ruleset.scenarios["load"].at["France", "0"] + assert np.isnan(expected) and np.isnan(actual) or expected == actual + actual_table_form = ruleset.get_table_form("load") + assert actual_table_form["France"]["0"] == ("" if np.isnan(expected) else expected) diff --git a/tests/storage/repository/filesystem/test_scenariobuilder.py b/tests/storage/repository/filesystem/test_scenariobuilder.py index 3abd3847c6..91b964e621 100644 --- a/tests/storage/repository/filesystem/test_scenariobuilder.py +++ b/tests/storage/repository/filesystem/test_scenariobuilder.py @@ -5,71 +5,85 @@ from antarest.study.storage.rawstudy.model.filesystem.config.thermal import ThermalConfig from antarest.study.storage.rawstudy.model.filesystem.root.settings.scenariobuilder import ScenarioBuilder -content = """ -[Default Ruleset] -l,de,0 = 1 -l,es,0 = 1 -l,fr,0 = 1 -l,it,0 = 1 -s,de,0 = 1 -s,es,0 = 1 -s,fr,0 = 1 -s,it,0 = 1 -h,de,0 = 1 -h,es,0 = 1 -h,fr,0 = 1 -h,it,0 = 1 -w,de,0 = 1 -w,es,0 = 1 -w,fr,0 = 1 -w,it,0 = 1 -t,de,0,01_solar = 1 -t,de,0,02_wind_on = 1 -t,de,0,03_wind_off = 1 -t,de,0,04_res = 1 -t,de,0,05_nuclear = 1 -t,de,0,06_coal = 1 -t,de,0,07_gas = 1 -t,de,0,08_non-res = 1 -t,de,0,09_hydro_pump = 1 -t,es,0,01_solar = 1 -t,es,0,02_wind_on = 1 -t,es,0,03_wind_off = 1 -t,es,0,04_res = 1 -t,es,0,05_nuclear = 1 -t,es,0,06_coal = 1 -t,es,0,07_gas = 1 -t,es,0,08_non-res = 1 -t,es,0,09_hydro_pump = 1 -t,fr,0,01_solar = 1 -t,fr,0,02_wind_on = 1 -t,fr,0,03_wind_off = 1 -t,fr,0,04_res = 1 -t,fr,0,05_nuclear = 1 -t,fr,0,06_coal = 1 -t,fr,0,07_gas = 1 -t,fr,0,08_non-res = 1 -t,fr,0,09_hydro_pump = 1 -t,it,0,01_solar = 1 -t,it,0,02_wind_on = 1 -t,it,0,03_wind_off = 1 -t,it,0,04_res = 1 -t,it,0,05_nuclear = 1 -t,it,0,06_coal = 1 -t,it,0,07_gas = 1 -t,it,0,08_non-res = 1 -t,it,0,09_hydro_pump = 1 -# since v8.7 -bc,group a,0 = 1 -bc,group a,1 = 2 -bc,group b,0 = 2 -bc,group b,1 = 1 -""" - - -def test_get(tmp_path: Path): +RULES = { + "h,de,0": 1, + "h,es,0": 1, + "h,fr,0": 1, + "h,it,0": 1, + "l,de,0": 1, + "l,es,0": 1, + "l,fr,0": 1, + "l,it,0": 1, + "s,de,0": 1, + "s,es,0": 1, + "s,fr,0": 1, + "s,it,0": 1, + "ntc,de,fr,0": 34, + "ntc,de,fr,1": 45, + "ntc,de,it,0": 56, + "ntc,de,it,1": 67, + "t,de,0,01_solar": 11, + "t,de,0,02_wind_on": 12, + "t,de,0,03_wind_off": 13, + "t,de,0,04_res": 14, + "t,de,0,05_nuclear": 15, + "t,de,0,06_coal": 16, + "t,de,0,07_gas": 17, + "t,de,0,08_non-res": 18, + "t,de,0,09_hydro_pump": 19, + "t,es,0,01_solar": 21, + "t,es,0,02_wind_on": 22, + "t,es,0,03_wind_off": 23, + "t,es,0,04_res": 24, + "t,es,0,05_nuclear": 25, + "t,es,0,06_coal": 26, + "t,es,0,07_gas": 27, + "t,es,0,08_non-res": 28, + "t,es,0,09_hydro_pump": 29, + "t,fr,0,01_solar": 31, + "t,fr,1,01_solar": 31, + "t,fr,0,02_wind_on": 32, + "t,fr,1,02_wind_on": 32, + "t,fr,0,03_wind_off": 33, + "t,fr,1,03_wind_off": 33, + "t,fr,0,04_res": 34, + "t,fr,1,04_res": 34, + "t,fr,0,05_nuclear": 35, + "t,fr,1,05_nuclear": 35, + "t,fr,0,06_coal": 36, + "t,fr,1,06_coal": 36, + "t,fr,0,07_gas": 37, + "t,fr,1,07_gas": 37, + "t,fr,0,08_non-res": 38, + "t,fr,1,08_non-res": 38, + "t,fr,0,09_hydro_pump": 39, + "t,it,0,01_solar": 41, + "t,it,0,02_wind_on": 42, + "t,it,0,03_wind_off": 43, + "t,it,0,04_res": 44, + "t,it,0,05_nuclear": 45, + "t,it,0,06_coal": 46, + "t,it,0,07_gas": 47, + "t,it,0,08_non-res": 48, + "t,it,0,09_hydro_pump": 49, + "w,de,0": 1, + "w,es,0": 1, + "w,fr,0": 1, + "w,it,0": 1, + # since v8.7 + "bc,group a,0": 1, + "bc,group a,1": 2, + "bc,group b,0": 2, + "bc,group b,1": 1, +} + + +def test_get(tmp_path: Path) -> None: path = tmp_path / "file.ini" - path.write_text(content) + with open(path, mode="w") as f: + print("[Default Ruleset]", file=f) + for key, value in RULES.items(): + print(f"{key} = {value}", file=f) thermals = [ ThermalConfig(id="01_solar", name="01_solar", enabled=True), @@ -107,10 +121,54 @@ def test_get(tmp_path: Path): ), ) - assert node.get(["Default Ruleset", "t,it,0,09_hydro_pump"]) == 1 + actual = node.get() + assert actual == {"Default Ruleset": RULES} + + actual = node.get(["Default Ruleset"]) + assert actual == RULES + + assert node.get(["Default Ruleset", "t,it,0,09_hydro_pump"]) == 49 # since v8.7 assert node.get(["Default Ruleset", "bc,group a,0"]) == 1 assert node.get(["Default Ruleset", "bc,group a,1"]) == 2 assert node.get(["Default Ruleset", "bc,group b,0"]) == 2 assert node.get(["Default Ruleset", "bc,group b,1"]) == 1 + + actual = node.get(["Default Ruleset", "w,de,0"]) + assert actual == 1 + + # We can also filter the data by generator type + actual = node.get(["Default Ruleset", "s"]) + assert actual == {"s,de,0": 1, "s,es,0": 1, "s,fr,0": 1, "s,it,0": 1} + + # We can filter the data by generator type and area (or group for BC) + actual = node.get(["Default Ruleset", "t", "fr"]) + expected = { + "t,fr,0,01_solar": 31, + "t,fr,0,02_wind_on": 32, + "t,fr,0,03_wind_off": 33, + "t,fr,0,04_res": 34, + "t,fr,0,05_nuclear": 35, + "t,fr,0,06_coal": 36, + "t,fr,0,07_gas": 37, + "t,fr,0,08_non-res": 38, + "t,fr,0,09_hydro_pump": 39, + "t,fr,1,01_solar": 31, + "t,fr,1,02_wind_on": 32, + "t,fr,1,03_wind_off": 33, + "t,fr,1,04_res": 34, + "t,fr,1,05_nuclear": 35, + "t,fr,1,06_coal": 36, + "t,fr,1,07_gas": 37, + "t,fr,1,08_non-res": 38, + } + assert actual == expected + + # We can filter the data by generator type, area and cluster + actual = node.get(["Default Ruleset", "t", "fr", "01_solar"]) + assert actual == {"t,fr,0,01_solar": 31, "t,fr,1,01_solar": 31} + + # We can filter the data by link type + actual = node.get(["Default Ruleset", "ntc", "de", "fr"]) + assert actual == {"ntc,de,fr,0": 34, "ntc,de,fr,1": 45} diff --git a/tests/storage/test_model.py b/tests/storage/test_model.py index 4986073713..d17d6c89a4 100644 --- a/tests/storage/test_model.py +++ b/tests/storage/test_model.py @@ -55,5 +55,5 @@ def test_file_study_tree_config_dto(): enr_modelling="aggregated", ) config_dto = FileStudyTreeConfigDTO.from_build_config(config) - assert sorted(list(config_dto.dict().keys()) + ["cache"]) == sorted(list(config.__dict__.keys())) + assert sorted(list(config_dto.dict()) + ["cache"]) == sorted(list(config.__dict__)) assert config_dto.to_build_config() == config diff --git a/webapp/.eslintrc.cjs b/webapp/.eslintrc.cjs index c39a0165bc..8a4e508acb 100644 --- a/webapp/.eslintrc.cjs +++ b/webapp/.eslintrc.cjs @@ -45,10 +45,10 @@ module.exports = { ], curly: "error", "jsdoc/no-defaults": "off", - "jsdoc/require-hyphen-before-param-description": "warn", "jsdoc/require-jsdoc": "off", - "jsdoc/tag-lines": ["warn", "any", { "startLines": 1 }], // Expected 1 line after block description - "no-console": "error", + "jsdoc/require-hyphen-before-param-description": "warn", + "jsdoc/tag-lines": ["warn", "any", { startLines: 1 }], // Expected 1 line after block description + "no-console": "warn", "no-param-reassign": [ "error", { diff --git a/webapp/package-lock.json b/webapp/package-lock.json index c7e11dc01b..3b2f24d6c6 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -1,12 +1,12 @@ { "name": "antares-web", - "version": "2.17.1", + "version": "2.17.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "antares-web", - "version": "2.17.1", + "version": "2.17.2", "dependencies": { "@emotion/react": "11.11.1", "@emotion/styled": "11.11.0", diff --git a/webapp/package.json b/webapp/package.json index 0438e2a827..a53b548a9f 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -1,6 +1,6 @@ { "name": "antares-web", - "version": "2.17.1", + "version": "2.17.2", "private": true, "type": "module", "scripts": { diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index aa1b90a044..e1e1d053d3 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -123,6 +123,7 @@ "maintenance.error.maintenanceError": "Unable to retrieve maintenance status", "form.submit.error": "Error while submitting", "form.submit.inProgress": "The form is being submitted. Are you sure you want to leave the page?", + "form.changeNotSaved": "The form has not been saved. Are you sure you want to leave the page?", "form.asyncDefaultValues.error": "Failed to get values", "form.field.required": "Field required", "form.field.duplicate": "Value already exists", @@ -314,22 +315,19 @@ "study.configuration.general.yearByYear": "Year-by-year", "study.configuration.general.mcScenario": "MC Scenario", "study.configuration.general.mcScenarioBuilder": "MC Scenario builder", - "study.configuration.general.mcScenarioBuilder.activeRuleset": "Active ruleset:", "study.configuration.general.mcScenarioBuilder.year": "Year", "study.configuration.general.mcScenarioBuilder.tab.load": "Load", - "study.configuration.general.mcScenarioBuilder.tab.thermal": "Thermal", + "study.configuration.general.mcScenarioBuilder.tab.thermal": "Thermals", "study.configuration.general.mcScenarioBuilder.tab.hydro": "Hydro", "study.configuration.general.mcScenarioBuilder.tab.wind": "Wind", "study.configuration.general.mcScenarioBuilder.tab.solar": "Solar", - "study.configuration.general.mcScenarioBuilder.tab.ntc": "NTC", - "study.configuration.general.mcScenarioBuilder.tab.hydroLevels": "Hydro Levels", - "study.configuration.general.mcScenarioBuilder.dialog.delete.text": "Are you sure you want to delete '{{0}}' ruleset?", - "study.configuration.general.mcScenarioBuilder.error.table": "'{{0}}' table not updated", - "study.configuration.general.mcScenarioBuilder.error.ruleset.rename": "'{{0}}' ruleset not renamed", - "study.configuration.general.mcScenarioBuilder.error.ruleset.add": "'{{0}}' ruleset not added", - "study.configuration.general.mcScenarioBuilder.error.ruleset.delete": "'{{0}}' ruleset not deleted", - "study.configuration.general.mcScenarioBuilder.error.ruleset.duplicate": "'{{0}}' ruleset not duplicated", - "study.configuration.general.mcScenarioBuilder.error.ruleset.changeActive": "Active ruleset has not been changed", + "study.configuration.general.mcScenarioBuilder.tab.renewable": "Renewables", + "study.configuration.general.mcScenarioBuilder.tab.ntc": "Links", + "study.configuration.general.mcScenarioBuilder.tab.hydroInitialLevels": "Hydro Initial Levels", + "study.configuration.general.mcScenarioBuilder.tab.bindingConstraints": "Binding Constraints", + "study.configuration.general.mcScenarioBuilder.noConfig.error": "There is no valid configuration available.", + "study.configuration.general.mcScenarioBuilder.update.error": "Failed to update scenario '{{type}}'.", + "study.configuration.general.mcScenarioBuilder.get.error":"Failed to fetch configuration for the selected scenario: '{{type}}'.", "study.configuration.general.mcScenarioPlaylist": "MC Scenario playlist", "study.configuration.general.mcScenarioPlaylist.action.enableAll": "Enable all", "study.configuration.general.mcScenarioPlaylist.action.disableAll": "Disable all", @@ -368,6 +366,7 @@ "study.configuration.optimization.simplexOptimizationRange": "Simplex optimization range", "study.configuration.adequacyPatch.tab.general": "General", "study.configuration.adequacyPatch.tab.perimeter": "Perimeter", + "study.configuration.adequacyPatch.legend.operatingParameters": "Operating parameters", "study.configuration.adequacyPatch.legend.localMatchingRule": "Local matching rule", "study.configuration.adequacyPatch.legend.curtailmentSharing": "Curtailment sharing", "study.configuration.adequacyPatch.legend.advanced": "Advanced", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 61a701a773..f14b30405a 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -123,6 +123,7 @@ "maintenance.error.maintenanceError": "Impossible de rรฉcupรฉrer le status de maintenance de l'application", "form.submit.error": "Erreur lors de la soumission", "form.submit.inProgress": "Le formulaire est en cours de soumission. Etes-vous sรปr de vouloir quitter la page ?", + "form.changeNotSaved": "Le formulaire n'a pas รฉtรฉ sauvegardรฉ. Etes-vous sรปr de vouloir quitter la page ?", "form.asyncDefaultValues.error": "Impossible d'obtenir les valeurs", "form.field.required": "Champ requis", "form.field.duplicate": "Cette valeur existe dรฉjร ", @@ -314,22 +315,19 @@ "study.configuration.general.yearByYear": "Year-by-year", "study.configuration.general.mcScenario": "MC Scenario", "study.configuration.general.mcScenarioBuilder": "MC Scenario builder", - "study.configuration.general.mcScenarioBuilder.activeRuleset": "Ruleset actif :", "study.configuration.general.mcScenarioBuilder.year": "Annรฉe", "study.configuration.general.mcScenarioBuilder.tab.load": "Conso", - "study.configuration.general.mcScenarioBuilder.tab.thermal": "Clus. Thermiques", + "study.configuration.general.mcScenarioBuilder.tab.thermal": "Thermiques", "study.configuration.general.mcScenarioBuilder.tab.hydro": "Hydro", "study.configuration.general.mcScenarioBuilder.tab.wind": "ร‰olien", "study.configuration.general.mcScenarioBuilder.tab.solar": "Solaire", - "study.configuration.general.mcScenarioBuilder.tab.ntc": "NTC", - "study.configuration.general.mcScenarioBuilder.tab.hydroLevels": "Hydro Levels", - "study.configuration.general.mcScenarioBuilder.dialog.delete.text": "รŠtes-vous sรปr de vouloir supprimer la ruleset '{{0}}' ?", - "study.configuration.general.mcScenarioBuilder.error.table": "La table '{{0}}' n'a pas รฉtรฉ mise ร  jour", - "study.configuration.general.mcScenarioBuilder.error.ruleset.rename": "La ruleset '{{0}}' n'a pas รฉtรฉ renommรฉe", - "study.configuration.general.mcScenarioBuilder.error.ruleset.add": "La ruleset '{{0}}' n'a pas รฉtรฉ ajoutรฉe", - "study.configuration.general.mcScenarioBuilder.error.ruleset.delete": "La ruleset '{{0}}' n'a pas รฉtรฉ supprimรฉe", - "study.configuration.general.mcScenarioBuilder.error.ruleset.duplicate": "La ruleset '{{0}}' n'a pas รฉtรฉ dupliquรฉe", - "study.configuration.general.mcScenarioBuilder.error.ruleset.changeActive": "La ruleset active n'a pas รฉtรฉ changรฉe", + "study.configuration.general.mcScenarioBuilder.tab.renewable": "Renouvelables", + "study.configuration.general.mcScenarioBuilder.tab.ntc": "Liens", + "study.configuration.general.mcScenarioBuilder.tab.hydroInitialLevels": "Niveaux Hydro Initial", + "study.configuration.general.mcScenarioBuilder.tab.bindingConstraints": "Contraintes Couplantes", + "study.configuration.general.mcScenarioBuilder.noConfig.error": "Il n'y a aucune configuration valide disponible.", + "study.configuration.general.mcScenarioBuilder.update.error": "ร‰chec de la mise ร  jour du scรฉnario '{{type}}'.", + "study.configuration.general.mcScenarioBuilder.get.error": "ร‰chec de la rรฉcupรฉration de la configuration pour le scรฉnario sรฉlectionnรฉ : '{{type}}'.", "study.configuration.general.mcScenarioPlaylist": "MC Scenario playlist", "study.configuration.general.mcScenarioPlaylist.action.enableAll": "Activer tous", "study.configuration.general.mcScenarioPlaylist.action.disableAll": "Dรฉsactiver tous", @@ -368,6 +366,7 @@ "study.configuration.optimization.simplexOptimizationRange": "Simplex optimization range", "study.configuration.adequacyPatch.tab.general": "Gรฉnรฉral", "study.configuration.adequacyPatch.tab.perimeter": "Pรฉrimรจtre", + "study.configuration.adequacyPatch.legend.operatingParameters": "Paramรจtres de fonctionnement", "study.configuration.adequacyPatch.legend.localMatchingRule": "Rรจgle de correspondance locale", "study.configuration.adequacyPatch.legend.curtailmentSharing": "Partage de rรฉduction", "study.configuration.adequacyPatch.legend.advanced": "Avancรฉe", diff --git a/webapp/src/components/App/Singlestudy/NavHeader/index.tsx b/webapp/src/components/App/Singlestudy/NavHeader/index.tsx index 4a2f134418..8518e8112e 100644 --- a/webapp/src/components/App/Singlestudy/NavHeader/index.tsx +++ b/webapp/src/components/App/Singlestudy/NavHeader/index.tsx @@ -64,6 +64,8 @@ function NavHeader({ const isLatestVersion = study?.version === latestVersion; const isManaged = !!study?.managed; const isArchived = !!study?.archived; + const isVariant = study?.type === "variantstudy"; + const hasChildren = childrenTree && childrenTree.children.length > 0; //////////////////////////////////////////////////////////////// // Event Handlers @@ -154,7 +156,7 @@ function NavHeader({ key: "study.upgrade", icon: UpgradeIcon, action: () => setOpenUpgradeDialog(true), - condition: !isArchived && !isLatestVersion, + condition: !isArchived && !isLatestVersion && !isVariant && !hasChildren, }, { key: "global.export", diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/AdequacyPatch/Fields.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/AdequacyPatch/Fields.tsx index ebb63fb13b..a5687c6a14 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/AdequacyPatch/Fields.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/AdequacyPatch/Fields.tsx @@ -17,7 +17,12 @@ function Fields() { return ( -
+
); } diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/Fields.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/Fields.tsx index 96d313437d..8c2482b7f2 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/Fields.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/Fields.tsx @@ -16,16 +16,16 @@ import { UNIT_COMMITMENT_MODE_OPTIONS, SIMULATION_CORES_OPTIONS, RENEWABLE_GENERATION_OPTIONS, + UnitCommitmentMode, } from "./utils"; +import { useOutletContext } from "react-router"; +import { StudyMetadata } from "../../../../../../common/types"; -interface Props { - version: number; -} - -function Fields(props: Props) { +function Fields() { const [t] = useTranslation(); const { control } = useFormContextPlus(); - const { version } = props; + const { study } = useOutletContext<{ study: StudyMetadata }>(); + const studyVersion = Number(study.version); //////////////////////////////////////////////////////////////// // JSX @@ -178,7 +178,11 @@ function Fields(props: Props) { /> v !== UnitCommitmentMode.MILP || studyVersion >= 880, + ).map((v) => + v === UnitCommitmentMode.MILP ? { label: "MILP", value: v } : v, + )} name="unitCommitmentMode" control={control} /> @@ -188,7 +192,7 @@ function Fields(props: Props) { name="numberOfCoresMode" control={control} /> - {version >= 810 && ( + {studyVersion >= 810 && ( , - ) => { - const values = { ...data.dirtyValues }; + const handleSubmit = ({ + dirtyValues, + }: SubmitHandlerPlus) => { + return setAdvancedParamsFormFields(study.id, dirtyValues); + }; - // Get a comma separated string from accuracyOnCorrelation array as expected by the api - if (values.accuracyOnCorrelation) { - values.accuracyOnCorrelation = ( - values.accuracyOnCorrelation as unknown as string[] - ).join(", "); + const handleSubmitSuccessful = ({ + dirtyValues: { renewableGenerationModelling }, + }: SubmitHandlerPlus) => { + if (renewableGenerationModelling) { + dispatch( + updateStudySynthesis({ + id: study.id, + changes: { enr_modelling: renewableGenerationModelling }, + }), + ); } - - return setAdvancedParamsFormFields(study.id, values).then(() => { - if (values.renewableGenerationModelling) { - dispatch( - updateStudySynthesis({ - id: study.id, - changes: { enr_modelling: values.renewableGenerationModelling }, - }), - ); - } - }); }; //////////////////////////////////////////////////////////////// @@ -54,9 +49,10 @@ function AdvancedParameters() { defaultValues: () => getAdvancedParamsFormFields(study.id), }} onSubmit={handleSubmit} + onSubmitSuccessful={handleSubmitSuccessful} enableUndoRedo > - + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/utils.ts b/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/utils.ts index 551cb4a083..4a8b69e918 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/AdvancedParameters/utils.ts @@ -1,3 +1,4 @@ +import { DeepPartial } from "react-hook-form"; import { StudyMetadata } from "../../../../../../common/types"; import client from "../../../../../../services/api/client"; @@ -41,9 +42,11 @@ enum ReserveManagement { Global = "global", } -enum UnitCommitmentMode { +export enum UnitCommitmentMode { Fast = "fast", Accurate = "accurate", + // Since v8.8 + MILP = "milp", } enum SimulationCore { @@ -82,7 +85,7 @@ export const RENEWABLE_GENERATION_OPTIONS = Object.values( //////////////////////////////////////////////////////////////// export interface AdvancedParamsFormFields { - accuracyOnCorrelation: string; + accuracyOnCorrelation: string[]; dayAheadReserveManagement: string; hydroHeuristicPolicy: string; hydroPricingMode: string; @@ -105,26 +108,47 @@ export interface AdvancedParamsFormFields { unitCommitmentMode: string; } +type AdvancedParamsFormFields_RAW = Omit< + AdvancedParamsFormFields, + "accuracyOnCorrelation" +> & { + accuracyOnCorrelation: string; +}; + +//////////////////////////////////////////////////////////////// +// API +//////////////////////////////////////////////////////////////// + function makeRequestURL(studyId: StudyMetadata["id"]): string { return `v1/studies/${studyId}/config/advancedparameters/form`; } export async function getAdvancedParamsFormFields( studyId: StudyMetadata["id"], -): Promise { - const res = await client.get(makeRequestURL(studyId)); - - // Get array of values from accuracyOnCorrelation string as expected for the SelectFE component - const accuracyOnCorrelation = res.data.accuracyOnCorrelation - .split(/\s*,\s*/) - .filter((v: string) => v.trim()); - - return { ...res.data, accuracyOnCorrelation }; +) { + const { data } = await client.get( + makeRequestURL(studyId), + ); + + return { + ...data, + accuracyOnCorrelation: data.accuracyOnCorrelation + .split(",") + .map((v) => v.trim()) + .filter(Boolean), + } as AdvancedParamsFormFields; } -export function setAdvancedParamsFormFields( +export async function setAdvancedParamsFormFields( studyId: StudyMetadata["id"], - values: Partial, -): Promise { - return client.put(makeRequestURL(studyId), values); + values: DeepPartial, +) { + const { accuracyOnCorrelation, ...rest } = values; + const newValues: Partial = rest; + + if (accuracyOnCorrelation) { + newValues.accuracyOnCorrelation = accuracyOnCorrelation.join(", "); + } + + await client.put(makeRequestURL(studyId), newValues); } diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/Fields.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/Fields.tsx index 0fe6af285b..3bd79f0c94 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/Fields.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/Fields.tsx @@ -186,7 +186,7 @@ function Fields(props: Props) { /> ; - setConfig: React.Dispatch>; - reloadConfig: VoidFunction; - activeRuleset: string; - setActiveRuleset: (ruleset: string) => void; - studyId: StudyMetadata["id"]; -} - -export default createContext({} as CxType); diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Rulesets.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Rulesets.tsx deleted file mode 100644 index 6eefad1392..0000000000 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Rulesets.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import { InputLabel, IconButton, Box, Button } from "@mui/material"; -import EditIcon from "@mui/icons-material/Edit"; -import DeleteIcon from "@mui/icons-material/Delete"; -import { useTranslation } from "react-i18next"; -import { useContext, useState } from "react"; -import * as RA from "ramda-adjunct"; -import { LoadingButton } from "@mui/lab"; -import FileCopyIcon from "@mui/icons-material/FileCopy"; -import AddIcon from "@mui/icons-material/Add"; -import SelectFE from "../../../../../../../common/fieldEditors/SelectFE"; -import ConfigContext from "./ConfigContext"; -import StringFE from "../../../../../../../common/fieldEditors/StringFE"; -import Form from "../../../../../../../common/Form"; -import { SubmitHandlerPlus } from "../../../../../../../common/Form/types"; -import { updateScenarioBuilderConfig } from "./utils"; -import ConfirmationDialog from "../../../../../../../common/dialogs/ConfirmationDialog"; -import useEnqueueErrorSnackbar from "../../../../../../../../hooks/useEnqueueErrorSnackbar"; - -type SubmitHandlerType = SubmitHandlerPlus<{ name: string }>; - -function Rulesets() { - const { - config, - setConfig, - reloadConfig, - activeRuleset, - setActiveRuleset, - studyId, - } = useContext(ConfigContext); - const { t } = useTranslation(); - const [openForm, setOpenForm] = useState<"add" | "rename" | "">(""); - const [confirmDelete, setConfirmDelete] = useState(false); - const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); - const allowDelete = activeRuleset && Object.keys(config).length > 1; - - //////////////////////////////////////////////////////////////// - // Event Handlers - //////////////////////////////////////////////////////////////// - - const handleRename = ({ values: { name } }: SubmitHandlerType) => { - setOpenForm(""); - setConfig( - (prev) => RA.renameKeys({ [activeRuleset]: name }, prev) as typeof prev, - ); - setActiveRuleset(name); - - return updateScenarioBuilderConfig(studyId, { - [activeRuleset]: "", - [name]: activeRuleset, - }).catch((err) => { - reloadConfig(); - - throw new Error( - t( - "study.configuration.general.mcScenarioBuilder.error.ruleset.rename", - { 0: activeRuleset }, - ), - { cause: err }, - ); - }); - }; - - const handleAdd = ({ values: { name } }: SubmitHandlerType) => { - setOpenForm(""); - setConfig((prev) => ({ [name]: {}, ...prev })); - setActiveRuleset(name); - - return updateScenarioBuilderConfig(studyId, { - [name]: {}, - }).catch((err) => { - reloadConfig(); - - throw new Error( - t("study.configuration.general.mcScenarioBuilder.error.ruleset.add", { - 0: name, - }), - { cause: err }, - ); - }); - }; - - const handleDelete = () => { - const { [activeRuleset]: ignore, ...newConfig } = config; - setConfig(newConfig); - setActiveRuleset(Object.keys(newConfig)[0] || ""); - setConfirmDelete(false); - - updateScenarioBuilderConfig(studyId, { - [activeRuleset]: "", - }).catch((err) => { - reloadConfig(); - - enqueueErrorSnackbar( - t( - "study.configuration.general.mcScenarioBuilder.error.ruleset.delete", - { 0: activeRuleset }, - ), - err, - ); - }); - }; - - const handleDuplicate = () => { - const newRulesetName = `${activeRuleset} Copy`; - setConfig((prev) => ({ [newRulesetName]: prev[activeRuleset], ...prev })); - setActiveRuleset(newRulesetName); - - updateScenarioBuilderConfig(studyId, { - [newRulesetName]: activeRuleset, - }).catch((err) => { - reloadConfig(); - - enqueueErrorSnackbar( - t( - "study.configuration.general.mcScenarioBuilder.error.ruleset.duplicate", - { 0: activeRuleset }, - ), - err, - ); - }); - }; - - //////////////////////////////////////////////////////////////// - // JSX - //////////////////////////////////////////////////////////////// - - return ( - <> - - - {t("study.configuration.general.mcScenarioBuilder.activeRuleset")} - - {openForm ? ( -
- {({ control, formState: { isDirty, isSubmitting } }) => ( - <> - { - return !Object.keys(config).find( - (ruleset) => - v === ruleset && - (openForm === "add" || v !== activeRuleset), - ); - }, - }} - /> - - {t(`button.${openForm}`)} - - - - )} - - ) : ( - <> - { - setActiveRuleset(event.target.value as string); - }} - /> - setOpenForm("add")}> - - - setOpenForm("rename")} - disabled={!activeRuleset} - > - - - - - - setConfirmDelete(true)} - disabled={!allowDelete} - > - - - - )} -
- {confirmDelete && ( - setConfirmDelete(false)} - alert="warning" - > - {t( - "study.configuration.general.mcScenarioBuilder.dialog.delete.text", - { 0: activeRuleset }, - )} - - )} - - ); -} - -export default Rulesets; diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Table.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Table.tsx new file mode 100644 index 0000000000..6ad04bdcfd --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Table.tsx @@ -0,0 +1,79 @@ +import { useTranslation } from "react-i18next"; +import TableForm from "../../../../../../../common/TableForm"; +import { + GenericScenarioConfig, + ScenarioType, + ClustersHandlerReturn, + updateScenarioBuilderConfig, +} from "./utils"; +import { SubmitHandlerPlus } from "../../../../../../../common/Form/types"; +import SimpleContent from "../../../../../../../common/page/SimpleContent"; +import useEnqueueErrorSnackbar from "../../../../../../../../hooks/useEnqueueErrorSnackbar"; +import { toError } from "../../../../../../../../utils/fnUtils"; +import { useOutletContext } from "react-router"; +import { StudyMetadata } from "../../../../../../../../common/types"; + +interface Props { + config: GenericScenarioConfig | ClustersHandlerReturn; + type: ScenarioType; + areaId?: string; +} + +function Table({ config, type, areaId }: Props) { + const { t } = useTranslation(); + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); + const { study } = useOutletContext<{ study: StudyMetadata }>(); + + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// + + const handleSubmit = async ({ dirtyValues }: SubmitHandlerPlus) => { + const updatedScenario = { + [type]: + (type === "thermal" || type === "renewable") && areaId + ? { [areaId]: dirtyValues } + : dirtyValues, + }; + + try { + await updateScenarioBuilderConfig(study.id, updatedScenario, type); + } catch (error) { + enqueueErrorSnackbar( + t("study.configuration.general.mcScenarioBuilder.update.error", { + type, + }), + toError(error), + ); + } + }; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + if (Object.keys(config).length === 0) { + return ; + } + + return ( + + `${t("study.configuration.general.mcScenarioBuilder.year")} ${ + index + 1 + }`, + className: "htCenter", + }} + onSubmit={handleSubmit} + /> + ); +} + +export default Table; diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/index.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/index.tsx index 83abfb6a17..e6499f591a 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/index.tsx @@ -1,92 +1,46 @@ import { TabContext, TabList, TabListProps, TabPanel } from "@mui/lab"; -import { Box, Button, Tab } from "@mui/material"; -import { useMemo, useState } from "react"; +import { Box, Button, Tab, Skeleton } from "@mui/material"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { StudyMetadata } from "../../../../../../../../common/types"; -import usePromise from "../../../../../../../../hooks/usePromise"; import BasicDialog from "../../../../../../../common/dialogs/BasicDialog"; -import Rulesets from "./Rulesets"; -import Table from "./tabs/Table"; -import { - ACTIVE_SCENARIO_PATH, - getScenarioBuilderConfig, - ScenarioBuilderConfig, - TABS_DATA, -} from "./utils"; -import ConfigContext from "./ConfigContext"; -import Thermal from "./tabs/Thermal"; +import Table from "./Table"; +import { getScenarioConfigByType, SCENARIOS, ScenarioType } from "./utils"; import UsePromiseCond from "../../../../../../../common/utils/UsePromiseCond"; -import { - editStudy, - getStudyData, -} from "../../../../../../../../services/api/study"; -import useEnqueueErrorSnackbar from "../../../../../../../../hooks/useEnqueueErrorSnackbar"; +import withAreas from "./withAreas"; +import usePromiseWithSnackbarError from "../../../../../../../../hooks/usePromiseWithSnackbarError"; interface Props { study: StudyMetadata; open: boolean; onClose: VoidFunction; - nbYears: number; } -function ScenarioBuilderDialog(props: Props) { - const { study, open, onClose, nbYears } = props; - const [currentTab, setCurrentTab] = useState(TABS_DATA[0][0]); - const [activeRuleset, setActiveRuleset] = useState(""); - const [config, setConfig] = useState({}); - const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); - const { t } = useTranslation(); - - const res = usePromise(async () => { - const config = await getScenarioBuilderConfig(study.id); - setConfig(config); - - try { - const activeRuleset = await getStudyData( - study.id, - ACTIVE_SCENARIO_PATH, - ); - - if (!config[activeRuleset]) { - throw new Error(); - } +// HOC that provides areas menu, for particular cases. (e.g thermals) +const EnhancedTable = withAreas(Table); - setActiveRuleset(activeRuleset); - } catch { - setActiveRuleset(""); - } - }, [study.id]); - - const cxValue = useMemo( - () => ({ - config, - setConfig, - reloadConfig: res.reload, - activeRuleset, - setActiveRuleset: (ruleset: string) => { - setActiveRuleset(ruleset); +function ScenarioBuilderDialog({ study, open, onClose }: Props) { + const { t } = useTranslation(); + const [selectedScenario, setSelectedScenario] = useState( + SCENARIOS[0], + ); - editStudy(ruleset, study.id, ACTIVE_SCENARIO_PATH).catch((err) => { - setActiveRuleset(""); - enqueueErrorSnackbar( - t( - "study.configuration.general.mcScenarioBuilder.error.ruleset.changeActive", - ), - err, - ); - }); - }, - studyId: study.id, - }), - [activeRuleset, config, enqueueErrorSnackbar, res.reload, study.id, t], + const config = usePromiseWithSnackbarError( + () => getScenarioConfigByType(study.id, selectedScenario), + { + errorMessage: t( + "study.configuration.general.mcScenarioBuilder.noConfig.error", + ), + }, ); //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// - const handleTabChange: TabListProps["onChange"] = (_, newValue) => { - setCurrentTab(newValue); + const handleScenarioChange: TabListProps["onChange"] = (_, type) => { + setSelectedScenario(type); + config.reload(); }; //////////////////////////////////////////////////////////////// @@ -99,55 +53,42 @@ function ScenarioBuilderDialog(props: Props) { open={open} onClose={onClose} actions={} - maxWidth="md" + maxWidth="xl" fullWidth - PaperProps={{ - // TODO: add `maxHeight` and `fullHeight` in BasicDialog` - sx: { height: "calc(100% - 64px)", maxHeight: "900px" }, + contentProps={{ + sx: { p: 1, height: "95vh", width: 1 }, }} > - ( - - - {activeRuleset && ( - - - - {TABS_DATA.map(([name]) => ( - - ))} - - - {TABS_DATA.map(([name, sym]) => ( - - {name === "thermal" ? ( - - ) : ( - - )} - - ))} - - )} - - )} - /> + + + + {SCENARIOS.map((type) => ( + + ))} + + + {SCENARIOS.map((type) => ( + + } + ifPending={() => ( + + )} + /> + + ))} + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/tabs/Table.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/tabs/Table.tsx deleted file mode 100644 index 61fc35ffe2..0000000000 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/tabs/Table.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { useContext, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import * as R from "ramda"; -import { Path } from "ramda"; -import { LinkElement } from "../../../../../../../../../common/types"; -import useStudySynthesis, { - UseStudySynthesisProps, -} from "../../../../../../../../../redux/hooks/useStudySynthesis"; -import { - getArea, - getAreas, - getLinks, -} from "../../../../../../../../../redux/selectors"; -import TableForm from "../../../../../../../../common/TableForm"; -import ConfigContext from "../ConfigContext"; -import { updateScenarioBuilderConfig } from "../utils"; -import { SubmitHandlerPlus } from "../../../../../../../../common/Form/types"; - -type ElementList = Array<{ - id: string; - name: string; - // In link - label?: LinkElement["label"]; -}>; - -type RowValues = Record; - -type TableData = Record; - -type RowType = "area" | "thermal" | "link"; - -interface Props { - nbYears: number; - symbol: string; - rowType: RowType; - areaId?: string; -} - -function Table(props: Props) { - const { nbYears, symbol, rowType, areaId } = props; - const { config, setConfig, reloadConfig, activeRuleset, studyId } = - useContext(ConfigContext); - const { t } = useTranslation(); - - const valuesFromConfig = R.path( - [activeRuleset, symbol, rowType === "thermal" && areaId].filter( - Boolean, - ) as Path, - config, - ) as TableData; - - const { data: areasOrLinksOrThermals = [] } = useStudySynthesis({ - studyId, - selector: R.cond< - [string], - UseStudySynthesisProps["selector"] - >([ - [R.equals("area"), () => getAreas], - [R.equals("link"), () => getLinks], - [ - R.equals("thermal"), - () => (state, studyId) => - areaId ? getArea(state, studyId, areaId)?.thermals : undefined, - ], - ])(rowType), - }); - - const defaultValues = useMemo(() => { - const emptyCols = Array.from({ length: nbYears }).reduce( - (acc: RowValues, _, index) => { - acc[String(index)] = ""; - return acc; - }, - {}, - ); - - return areasOrLinksOrThermals.reduce((acc: TableData, { id }) => { - acc[id] = { - ...emptyCols, - ...valuesFromConfig?.[id], - }; - return acc; - }, {}); - }, [areasOrLinksOrThermals, nbYears, valuesFromConfig]); - - //////////////////////////////////////////////////////////////// - // Event Handlers - //////////////////////////////////////////////////////////////// - - const handleSubmit = ({ dirtyValues }: SubmitHandlerPlus) => { - const newData = { - [activeRuleset]: { - [symbol]: - rowType === "thermal" && areaId - ? { [areaId]: dirtyValues } - : dirtyValues, - }, - }; - - setConfig(R.mergeDeepLeft(newData)); - - return updateScenarioBuilderConfig(studyId, newData).catch((err) => { - reloadConfig(); - - throw new Error( - t("study.configuration.general.mcScenarioBuilder.error.table", { - 0: `${activeRuleset}.${symbol}`, - }), - { cause: err }, - ); - }); - }; - - //////////////////////////////////////////////////////////////// - // JSX - //////////////////////////////////////////////////////////////// - - return ( - { - const item = areasOrLinksOrThermals.find(({ id }) => row.id === id); - return item ? item.label || item.name : String(row.id); - }, - colHeaders: (index) => - `${t("study.configuration.general.mcScenarioBuilder.year")} ${ - index + 1 - }`, - stretchH: "all", - className: "htCenter", - }} - onSubmit={handleSubmit} - /> - ); -} - -export default Table; diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/tabs/Thermal.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/tabs/Thermal.tsx deleted file mode 100644 index f84cf81a78..0000000000 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/tabs/Thermal.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { useContext, useEffect, useMemo, useState } from "react"; -import useStudySynthesis from "../../../../../../../../../redux/hooks/useStudySynthesis"; -import { getAreas } from "../../../../../../../../../redux/selectors"; -import { isSearchMatching } from "../../../../../../../../../utils/stringUtils"; -import PropertiesView from "../../../../../../../../common/PropertiesView"; -import SplitLayoutView from "../../../../../../../../common/SplitLayoutView"; -import UsePromiseCond from "../../../../../../../../common/utils/UsePromiseCond"; -import ListElement from "../../../../../common/ListElement"; -import ConfigContext from "../ConfigContext"; -import Table from "./Table"; - -interface Props { - nbYears: number; -} - -function Thermal(props: Props) { - const { nbYears } = props; - const { studyId } = useContext(ConfigContext); - const res = useStudySynthesis({ studyId, selector: getAreas }); - const [selectedAreaId, setSelectedAreaId] = useState(""); - const [searchValue, setSearchValue] = useState(""); - - const filteredAreas = useMemo( - () => - res.data?.filter(({ name }) => isSearchMatching(searchValue, name)) || [], - [res.data, searchValue], - ); - - useEffect(() => { - setSelectedAreaId(filteredAreas.length > 0 ? filteredAreas[0].id : ""); - }, [filteredAreas]); - - //////////////////////////////////////////////////////////////// - // JSX - //////////////////////////////////////////////////////////////// - - return ( - ( - setSelectedAreaId(id)} - /> - } - onSearchFilterChange={setSearchValue} - /> - } - right={ -
- } - /> - )} - /> - ); -} - -export default Thermal; diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/utils.ts b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/utils.ts index bf1de4b145..d59661fe60 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/utils.ts @@ -2,35 +2,227 @@ import { AxiosResponse } from "axios"; import { StudyMetadata } from "../../../../../../../../common/types"; import client from "../../../../../../../../services/api/client"; -export const TABS_DATA: Array<[string, string]> = [ - ["load", "l"], - ["thermal", "t"], - ["hydro", "h"], - ["wind", "w"], - ["solar", "s"], - ["ntc", "ntc"], - ["hydroLevels", "hl"], -]; +//////////////////////////////////////////////////////////////// +// Constants +//////////////////////////////////////////////////////////////// -export const ACTIVE_SCENARIO_PATH = - "settings/generaldata/general/active-rules-scenario"; +export const SCENARIOS = [ + "load", + "thermal", + "hydro", + "wind", + "solar", + "ntc", + "renewable", + "hydroInitialLevels", + // "hydroFinalLevels", since v9.2 + // "hydroGenerationPower", since v9.1 + "bindingConstraints", +] as const; -export type ScenarioBuilderConfig = Record; +export type ScenarioType = (typeof SCENARIOS)[number]; -function makeRequestURL(studyId: StudyMetadata["id"]): string { - return `v1/studies/${studyId}/config/scenariobuilder`; +//////////////////////////////////////////////////////////////// +// Types +//////////////////////////////////////////////////////////////// + +/** + * Represents yearly configuration values, which can be either a numerical value or an uninitialized (rand) value represented as an empty string. + * + * @example + * { "0": 120, "1": "", "2": 150 } + */ +export type YearlyValues = number | ""; + +/** + * Maps area identifiers to their configuration, each configuration being a series of values or uninitialized (rand) values. + * + * @example + * { "Area1": { "0": 10, "1": 20, "2": 15, "3": "", "4": 50 } } + */ +export type AreaConfig = Record; + +/** + * Maps cluster identifiers to their configurations within an area, similar to AreaConfig but used at the cluster level. + * + * @example + * { "Cluster1": { "0": 5, "1": "", "2": 20, "3": 30, "4": "" } } + */ +export type ClusterConfig = Record; + +/** + * Represents configuration for multiple clusters within each area. + * + * @example + * { + * "Area1": { + * "Cluster1": { "0": 10, "1": "", "2": 30 }, + * "Cluster2": { "0": 5, "1": 25, "2": "" } + * } + * } + */ +export type ClustersConfig = Record; + +/** + * General configuration format for scenarios using single areas as elements. + * Each scenario type maps to its specific areas configuration. + * + * @example + * { + * "load": { + * "Area1": { "0": 15, "1": 255, "2": "", "3": "", "4": "", "5": "" }, + * "Area2": { "0": 15, "1": 255, "2": "", "3": "", "4": "", "5": "" } + * } + * } + */ +export type GenericScenarioConfig = Record; + +/** + * Full configuration format for scenarios involving multiple clusters per area. + * + * @example + * { + * "thermal": { + * "Area1": { + * "Cluster1": { "0": 10, "1": "", "2": 30 }, + * "Cluster2": { "0": 5, "1": 25, "2": "" } + * } + * } + * } + */ +export type ClustersScenarioConfig = Record; + +export interface ClustersHandlerReturn { + areas: string[]; + clusters: Record; +} + +// General structure for ruleset configurations covering all scenarios. +export interface ScenarioConfig { + load?: GenericScenarioConfig; + thermal?: ClustersScenarioConfig; + hydro?: GenericScenarioConfig; + wind?: GenericScenarioConfig; + solar?: GenericScenarioConfig; + ntc?: GenericScenarioConfig; + renewable?: ClustersScenarioConfig; + hydroInitialLevels?: GenericScenarioConfig; + bindingConstraints?: GenericScenarioConfig; } -export async function getScenarioBuilderConfig( +type NonNullableRulesetConfig = { + [K in keyof ScenarioConfig]-?: NonNullable; +}; + +type ConfigHandler = (config: T) => U; + +export interface HandlerReturnTypes { + load: GenericScenarioConfig; + thermal: ClustersHandlerReturn; + hydro: GenericScenarioConfig; + wind: GenericScenarioConfig; + solar: GenericScenarioConfig; + ntc: GenericScenarioConfig; + renewable: ClustersHandlerReturn; + hydroInitialLevels?: GenericScenarioConfig; + bindingConstraints: GenericScenarioConfig; +} + +const handlers: { + [K in keyof NonNullableRulesetConfig]: ConfigHandler< + NonNullableRulesetConfig[K], + HandlerReturnTypes[K] + >; +} = { + load: handleGenericConfig, + thermal: handleClustersConfig, + hydro: handleGenericConfig, + wind: handleGenericConfig, + solar: handleGenericConfig, + ntc: handleGenericConfig, + renewable: handleClustersConfig, + hydroInitialLevels: handleGenericConfig, + bindingConstraints: handleGenericConfig, +}; + +/** + * Handles generic scenario configurations by reducing key-value pairs into a single object. + * + * @param config - The initial scenario configuration object. + * @returns The processed configuration object. + */ +function handleGenericConfig( + config: GenericScenarioConfig, +): GenericScenarioConfig { + return Object.entries(config).reduce( + (acc, [areaId, yearlyValue]) => { + acc[areaId] = yearlyValue; + return acc; + }, + {}, + ); +} + +/** + * Processes clusters based configurations to separate areas and clusters. + * + * @param config - The initial clusters based scenario configuration. + * @returns Object containing separated areas and cluster configurations. + */ +function handleClustersConfig( + config: ClustersScenarioConfig, +): ClustersHandlerReturn { + return Object.entries(config).reduce( + (acc, [areaId, clusterConfig]) => { + acc.areas.push(areaId); + acc.clusters[areaId] = clusterConfig; + return acc; + }, + { areas: [], clusters: {} }, + ); +} + +/** + * Retrieves and processes the configuration for a specific scenario within a ruleset. + * + * @param config - Full configuration mapping by ruleset. + * @param scenario - The specific scenario type to retrieve. + * @returns The processed configuration or undefined if not found. + */ +export function getConfigByScenario( + config: ScenarioConfig, + scenario: K, +): HandlerReturnTypes[K] | undefined { + const scenarioConfig = config[scenario]; + + if (!scenarioConfig) { + return undefined; + } + + return handlers[scenario](scenarioConfig); +} + +//////////////////////////////////////////////////////////////// +// API +//////////////////////////////////////////////////////////////// + +export async function getScenarioConfigByType( studyId: StudyMetadata["id"], -): Promise { - const res = await client.get(makeRequestURL(studyId)); + scenarioType: ScenarioType, +) { + const res = await client.get( + `v1/studies/${studyId}/config/scenariobuilder/${scenarioType}`, + ); return res.data; } -export async function updateScenarioBuilderConfig( +export function updateScenarioBuilderConfig( studyId: StudyMetadata["id"], - data: Partial, -): Promise> { - return client.put(makeRequestURL(studyId), data); + data: Partial, + scenarioType: ScenarioType, +) { + return client.put>( + `v1/studies/${studyId}/config/scenariobuilder/${scenarioType}`, + data, + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/withAreas.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/withAreas.tsx new file mode 100644 index 0000000000..2862d14c13 --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/withAreas.tsx @@ -0,0 +1,120 @@ +import { ComponentType, useEffect, useMemo, useState } from "react"; +import { Box } from "@mui/material"; +import SplitView from "../../../../../../../common/SplitView"; +import PropertiesView from "../../../../../../../common/PropertiesView"; +import ListElement from "../../../../common/ListElement"; +import { + GenericScenarioConfig, + HandlerReturnTypes, + ScenarioType, + ClustersHandlerReturn, + getConfigByScenario, + ScenarioConfig, +} from "./utils"; + +interface ScenarioTableProps { + type: ScenarioType; + config: ScenarioConfig; + areaId?: string; +} + +// If the configuration contains areas/clusters. +function hasAreas( + config: HandlerReturnTypes[keyof HandlerReturnTypes], +): config is ClustersHandlerReturn { + return ( + config !== undefined && + "areas" in config && + Array.isArray(config.areas) && + config.areas.every((area) => typeof area === "string") + ); +} + +function withAreas( + Component: ComponentType< + ScenarioTableProps & { + config: GenericScenarioConfig | ClustersHandlerReturn; + } + >, +) { + return function TableWithAreas({ + type, + config, + ...props + }: ScenarioTableProps) { + const [selectedAreaId, setSelectedAreaId] = useState(""); + const [areas, setAreas] = useState([]); + const [configByArea, setConfigByArea] = useState< + GenericScenarioConfig | ClustersHandlerReturn + >({}); + + const scenarioConfig = useMemo( + () => getConfigByScenario(config, type), + [config, type], + ); + + useEffect(() => { + if (scenarioConfig && hasAreas(scenarioConfig)) { + setAreas(scenarioConfig.areas); + + // Set selected area ID only if it hasn't been selected yet or current selection is not valid anymore. + if (!selectedAreaId || !scenarioConfig.areas.includes(selectedAreaId)) { + setSelectedAreaId(scenarioConfig.areas[0]); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scenarioConfig]); + + useEffect(() => { + if (scenarioConfig && hasAreas(scenarioConfig) && selectedAreaId) { + setConfigByArea(scenarioConfig.clusters[selectedAreaId]); + } + }, [selectedAreaId, scenarioConfig]); + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + // The regular case where no clusters nested data. + if (!areas.length && scenarioConfig) { + return ( + + ); + } + + return ( + + ({ + id: areaId, + name: `${areaId}`, + }))} + currentElement={selectedAreaId} + currentElementKeyToTest="id" + setSelectedItem={({ id }) => setSelectedAreaId(id)} + /> + } + /> + + + + + ); + }; +} + +export default withAreas; diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx index e942d31134..312abef7d6 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx @@ -1,6 +1,6 @@ import { useOutletContext } from "react-router"; import * as R from "ramda"; -import { useRef, useState } from "react"; +import { useState } from "react"; import { StudyMetadata } from "../../../../../../common/types"; import Form from "../../../../../common/Form"; import Fields from "./Fields"; @@ -14,16 +14,12 @@ import { SetDialogStateType, setGeneralFormFields, } from "./utils"; -import { - SubmitHandlerPlus, - UseFormReturnPlus, -} from "../../../../../common/Form/types"; +import { SubmitHandlerPlus } from "../../../../../common/Form/types"; import ScenarioBuilderDialog from "./dialogs/ScenarioBuilderDialog"; -function GeneralParameters() { +function General() { const { study } = useOutletContext<{ study: StudyMetadata }>(); const [dialog, setDialog] = useState(""); - const apiRef = useRef>(); //////////////////////////////////////////////////////////////// // Event Handlers @@ -54,7 +50,6 @@ function GeneralParameters() { key={study.id} config={{ defaultValues: () => getGeneralFormFields(study.id) }} onSubmit={handleSubmit} - apiRef={apiRef} enableUndoRedo > @@ -77,7 +72,6 @@ function GeneralParameters() { open study={study} onClose={handleCloseDialog} - nbYears={apiRef?.current?.getValues("nbYears") || 0} /> ), ], @@ -96,4 +90,4 @@ function GeneralParameters() { ); } -export default GeneralParameters; +export default General; diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/index.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/index.tsx index cc4d673cdd..96bc285952 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/index.tsx @@ -1,4 +1,3 @@ -import { Paper } from "@mui/material"; import * as R from "ramda"; import { useMemo, useState } from "react"; import { useOutletContext } from "react-router"; @@ -13,6 +12,7 @@ import Optimization from "./Optimization"; import TimeSeriesManagement from "./TimeSeriesManagement"; import TableMode from "../../../../common/TableMode"; import SplitView from "../../../../common/SplitView"; +import ViewWrapper from "../../../../common/page/ViewWrapper"; function Configuration() { const { study } = useOutletContext<{ study: StudyMetadata }>(); @@ -40,6 +40,7 @@ function Configuration() { return ( + {/* Left */} } /> - - + {/* Right */} + {R.cond([ [R.equals(0), () => ], [R.equals(1), () => ], @@ -108,7 +109,7 @@ function Configuration() { ), ], ])(tabList[currentTabIndex].id)} - + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/AreasTab.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/AreasTab.tsx index cb54e2fb30..0bc26081dc 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/AreasTab.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/AreasTab.tsx @@ -1,6 +1,5 @@ import { useEffect, useMemo } from "react"; import { useLocation, useNavigate, useOutletContext } from "react-router-dom"; -import { Paper } from "@mui/material"; import { useTranslation } from "react-i18next"; import { StudyMetadata } from "../../../../../../common/types"; import TabWrapper from "../../TabWrapper"; @@ -83,23 +82,7 @@ function AreasTab({ renewablesClustering }: Props) { })); }, [study.id, areaId, renewablesClustering, t, study.version]); - return ( - - - - ); + return ; } export default AreasTab; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/InflowStructure/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/InflowStructure/index.tsx index d5f38785b8..79bef831a5 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/InflowStructure/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/InflowStructure/index.tsx @@ -38,7 +38,8 @@ function InflowStructure() { onSubmit={handleSubmit} miniSubmitButton enableUndoRedo - sx={{ display: "flex", alignItems: "center", ".Form__Footer": { p: 0 } }} + hideFooterDivider + sx={{ flexDirection: "row", alignItems: "center" }} > {({ control }) => ( getManagementOptionsFormFields(studyId, areaId), }} onSubmit={handleSubmit} - sx={{ pb: 2 }} enableUndoRedo > diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/index.tsx index e8a19a7335..cca7f9519a 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/index.tsx @@ -2,7 +2,6 @@ import { useMemo } from "react"; import { useOutletContext } from "react-router"; import { StudyMetadata } from "../../../../../../../common/types"; import TabWrapper from "../../../TabWrapper"; -import { Root } from "./style"; import useAppSelector from "../../../../../../../redux/hooks/useAppSelector"; import { getCurrentAreaId } from "../../../../../../../redux/selectors"; @@ -37,11 +36,7 @@ function Hydro() { // JSX //////////////////////////////////////////////////////////////// - return ( - - - - ); + return ; } export default Hydro; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Form.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Form.tsx index 30dc1bc7b7..fe39be2fc8 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Form.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Form.tsx @@ -18,7 +18,7 @@ import { getCurrentAreaId } from "../../../../../../../redux/selectors"; import useNavigateOnCondition from "../../../../../../../hooks/useNavigateOnCondition"; import { nameToId } from "../../../../../../../services/utils"; -function RenewablesForm() { +function Renewables() { const { t } = useTranslation(); const { study } = useOutletContext<{ study: StudyMetadata }>(); const navigate = useNavigate(); @@ -51,37 +51,43 @@ function RenewablesForm() { //////////////////////////////////////////////////////////////// return ( - + -
- - - - + +
+ + + + +
); } -export default RenewablesForm; +export default Renewables; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Form.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Form.tsx index 40aa166664..38a4bb2bc2 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Form.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Form.tsx @@ -15,7 +15,7 @@ import Matrix from "./Matrix"; import useNavigateOnCondition from "../../../../../../../hooks/useNavigateOnCondition"; import { nameToId } from "../../../../../../../services/utils"; -function StorageForm() { +function Storages() { const { t } = useTranslation(); const { study } = useOutletContext<{ study: StudyMetadata }>(); const navigate = useNavigate(); @@ -63,39 +63,45 @@ function StorageForm() { //////////////////////////////////////////////////////////////// return ( - + -
- - - - + +
+ + + + +
); } -export default StorageForm; +export default Storages; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Form.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Form.tsx index 810338b581..e37f4749dd 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Form.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Form.tsx @@ -49,34 +49,40 @@ function ThermalForm() { //////////////////////////////////////////////////////////////// return ( - + -
- - - - + +
+ + + + +
); diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/index.tsx index d5926b6e63..8868838934 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/index.tsx @@ -1,4 +1,3 @@ -import { Box } from "@mui/material"; import { useOutletContext } from "react-router"; import { StudyMetadata } from "../../../../../../common/types"; import SimpleContent from "../../../../../common/page/SimpleContent"; @@ -14,6 +13,7 @@ import { setCurrentArea } from "../../../../../../redux/ducks/studySyntheses"; import useAppSelector from "../../../../../../redux/hooks/useAppSelector"; import UsePromiseCond from "../../../../../common/utils/UsePromiseCond"; import SplitView from "../../../../../common/SplitView"; +import ViewWrapper from "../../../../../common/page/ViewWrapper"; function Areas() { const { study } = useOutletContext<{ study: StudyMetadata }>(); @@ -38,14 +38,14 @@ function Areas() { return ( - - - - + {/* Left */} + + {/* Right */} + @@ -58,7 +58,7 @@ function Areas() { ) } /> - + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/index.tsx index d889ae3173..43dd656e8a 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/index.tsx @@ -113,14 +113,14 @@ function BindingConstView({ constraintId }: Props) { height: 1, display: "flex", flexDirection: "column", - overflow: "hidden", + overflow: "auto", }} > ( <> - +