diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 80925bf7..3d555cef 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -14,6 +14,13 @@ All notable changes to GemsPy are documented here. what a heuristic reads/writes via `models[].heuristics` in `optim-config.yml`. +### Fixed +- **Parameter time/scenario dependency is validated when the system is + resolved** (#258) - a component parameter may declare the same dependency as + its model or narrow it (`true` -> `false` on either axis), never extend it. +- **Clearer data-shape errors** - the "exactly one column/row" and "float value + is expected" errors now name the offending component and parameter. + ## [0.1.3] - 2026-07-24 ### Added diff --git a/docs/user-guide/inputs.md b/docs/user-guide/inputs.md index f5101705..d70db92d 100644 --- a/docs/user-guide/inputs.md +++ b/docs/user-guide/inputs.md @@ -3,8 +3,6 @@ GemsPy supports two loading strategies: a directory-based approach that reads an entire study at once, and a file-by-file approach for programmatic control. ---- - ## Directory-based loading (recommended) When your study follows the standard directory layout, use `load_study()`: @@ -67,6 +65,7 @@ database = build_data_base(input_system, Path(series_dir)) `build_data_base()` reads all timeseries files referenced by the system (`.txt` or `.csv`) from `series_dir`. + ### Assembling a Study Once you have `system` and `database`, wrap them in a `Study`: diff --git a/src/gems_craft/study/data.py b/src/gems_craft/study/data.py index 80118f1f..9d85582a 100644 --- a/src/gems_craft/study/data.py +++ b/src/gems_craft/study/data.py @@ -17,6 +17,8 @@ import numpy as np import pandas as pd +from gems_craft.expression.indexing_structure import IndexingStructure + if TYPE_CHECKING: from gems_craft.study.scenario_builder import ScenarioBuilder @@ -48,9 +50,17 @@ def get_value( raise NotImplementedError() @abstractmethod + def indexing_structure(self) -> "IndexingStructure": + """Axes along which this data actually varies.""" + def check_requirement(self, time: bool, scenario: bool) -> bool: - """Check if the data structure meets certain requirements.""" - pass + """Whether this data may be used for a parameter declared ``(time, scenario)``. + + Data may vary along fewer axes than the parameter declares — it is then + broadcast over the missing ones — but never along more. + """ + own = self.indexing_structure() + return (time or not own.time) and (scenario or not own.scenario) @dataclass(frozen=True) @@ -64,10 +74,8 @@ def get_value( ) -> float: return self.value - def check_requirement(self, time: bool, scenario: bool) -> bool: - if not isinstance(self, ConstantData): - raise ValueError("Invalid data type for ConstantData") - return True + def indexing_structure(self) -> "IndexingStructure": + return IndexingStructure(time=False, scenario=False) @dataclass(frozen=True) @@ -90,10 +98,8 @@ def get_value( ) return result - def check_requirement(self, time: bool, scenario: bool) -> bool: - if not isinstance(self, TimeSeriesData): - raise ValueError("Invalid data type for TimeSeriesData") - return time + def indexing_structure(self) -> "IndexingStructure": + return IndexingStructure(time=True, scenario=False) @dataclass(frozen=True) @@ -119,10 +125,8 @@ def get_value( ) return result - def check_requirement(self, time: bool, scenario: bool) -> bool: - if not isinstance(self, ScenarioSeriesData): - raise ValueError("Invalid data type for ScenarioSeriesData") - return scenario + def indexing_structure(self) -> "IndexingStructure": + return IndexingStructure(time=False, scenario=True) @dataclass(frozen=True) @@ -142,10 +146,8 @@ def get_value( raise KeyError("Time scenario data requires a scenario index.") return self.time_scenario_series.values[np.ix_(np.asarray(timestep), scenario)] - def check_requirement(self, time: bool, scenario: bool) -> bool: - if not isinstance(self, TimeScenarioSeriesData): - raise ValueError("Invalid data type for TimeScenarioSeriesData") - return time and scenario + def indexing_structure(self) -> "IndexingStructure": + return IndexingStructure(time=True, scenario=True) def load_ts_from_file( @@ -178,19 +180,31 @@ def load_ts_from_file( ) -def dataframe_to_time_series(ts_dataframe: pd.DataFrame) -> pd.Series: +def dataframe_to_time_series( + ts_dataframe: pd.DataFrame, context: str = "" +) -> pd.Series: if ts_dataframe.shape[1] != 1: raise ValueError( - f"Could not convert input data to time series data. Expect data series with exactly one column, got shape {ts_dataframe.shape}" + f"Could not convert input data to time series data{context}. " + f"A time-dependent, scenario-independent parameter expects exactly one " + f"column (one timeseries), got shape {ts_dataframe.shape}. " + f"To use several timeseries, declare the parameter scenario-dependent " + f"(the model must declare it scenario-dependent too)." ) return ts_dataframe.iloc[:, 0] -def dataframe_to_scenario_series(ts_dataframe: pd.DataFrame) -> np.ndarray: +def dataframe_to_scenario_series( + ts_dataframe: pd.DataFrame, context: str = "" +) -> np.ndarray: """Return a 1-D numpy array of floats indexed by 0-based column index.""" if ts_dataframe.shape[0] != 1: raise ValueError( - f"Could not convert input data to scenario series data. Expect data series with exactly one line, got shape {ts_dataframe.shape}" + f"Could not convert input data to scenario series data{context}. " + f"A scenario-dependent, time-independent parameter expects exactly one " + f"row, got shape {ts_dataframe.shape}. " + f"To use time-varying data, declare the parameter time-dependent " + f"(the model must declare it time-dependent too)." ) return ts_dataframe.iloc[0, :].to_numpy(dtype=float) diff --git a/src/gems_craft/study/resolve_components.py b/src/gems_craft/study/resolve_components.py index ced3457b..86a0908c 100644 --- a/src/gems_craft/study/resolve_components.py +++ b/src/gems_craft/study/resolve_components.py @@ -95,6 +95,8 @@ def _resolve_component( f"{missing_params}." ) + _check_parameter_dependencies(component, model) + properties = _resolve_properties_raw_to_dict(component.properties, component.id) missing = sorted(k for k in model.properties if k not in properties) if missing: @@ -113,6 +115,49 @@ def _resolve_component( ) +def _format_dependency(time: bool, scenario: bool) -> str: + return f"time-dependent: {str(time).lower()}, scenario-dependent: {str(scenario).lower()}" + + +def _check_parameter_dependencies(component: ComponentSchema, model: Model) -> None: + """Check that each parameter's dependency does not exceed the model's. + + A component may narrow what the model declares — supplying a constant for a + time-dependent parameter, or a single timeseries shared by every scenario for + a scenario-dependent one — but it may not add an axis the model does not + declare, since the model's expressions are written against that structure. + + Parameters the model does not declare carry no dependency constraint and are + skipped; the missing-parameter check in :func:`_resolve_component` already + catches misspelled ids. + """ + for param in component.parameters or []: + declared = model.parameters.get(param.id) + if declared is None: + continue + + extra_axes = [ + axis + for axis, in_system, in_model in ( + ("time", param.time_dependent, declared.structure.time), + ("scenario", param.scenario_dependent, declared.structure.scenario), + ) + if in_system and not in_model + ] + if extra_axes: + raise ValueError( + f"Component {component.id!r} (model {model.id!r}), parameter " + f"{param.id!r}: cannot declare the parameter " + f"{' and '.join(f'{a}-dependent' for a in extra_axes)} because the " + f"model does not. The model declares " + f"[{_format_dependency(declared.structure.time, declared.structure.scenario)}], " + f"the system declares " + f"[{_format_dependency(param.time_dependent, param.scenario_dependent)}]. " + f"A component may only narrow the model's declared dependency, " + f"never extend it." + ) + + def _resolve_port_refs( connection: PortConnectionsSchema, all_components: List[Component], @@ -169,6 +214,7 @@ def build_data_base( param.scenario_dependent, param.value, timeseries_dir, + context=f" for component {comp.id!r}, parameter {param.id!r}", ) database.add_data(comp.id, param.id, param_value, scenario_group=group) @@ -180,22 +226,26 @@ def _build_data( scenario_dependent: bool, param_value: Union[float, str], timeseries_dir: Optional[Path], + context: str = "", ) -> AbstractDataStructure: if isinstance(param_value, str): ts_data = load_ts_from_file(param_value, timeseries_dir) if time_dependent and scenario_dependent: return TimeScenarioSeriesData(ts_data) elif time_dependent: - return TimeSeriesData(dataframe_to_time_series(ts_data)) + return TimeSeriesData(dataframe_to_time_series(ts_data, context)) elif scenario_dependent: - return ScenarioSeriesData(dataframe_to_scenario_series(ts_data)) + return ScenarioSeriesData(dataframe_to_scenario_series(ts_data, context)) else: raise ValueError( - f"A float value is expected for constant data, got {param_value}" + f"A float value is expected for constant data{context}, " + f"got the timeseries name {param_value!r}. Declare the parameter " + f"time-dependent and/or scenario-dependent to read it from a file." ) else: if time_dependent or scenario_dependent: raise ValueError( - f"A timeseries name is expected for time or scenario dependent data, got {param_value}" + f"A timeseries name is expected for time or scenario dependent " + f"data{context}, got {param_value}." ) return ConstantData(float(param_value)) diff --git a/src/gems_craft/study/study.py b/src/gems_craft/study/study.py index 32080be0..d4aa8f28 100644 --- a/src/gems_craft/study/study.py +++ b/src/gems_craft/study/study.py @@ -21,6 +21,11 @@ from gems_craft.study.system import Component, System +def _format_axes(time: bool, scenario: bool) -> str: + axes = [name for name, on in (("time", time), ("scenario", scenario)) if on] + return " and ".join(axes) if axes else "no axis" + + @dataclass class Study: """ @@ -69,12 +74,17 @@ def check_consistency(self) -> None: for component in self.system.components: for param in component.model.parameters.values(): data_structure = self.database.get_data(component.id, param.name) + declared = param.structure if not data_structure.check_requirement( - component.model.parameters[param.name].structure.time, - component.model.parameters[param.name].structure.scenario, + declared.time, declared.scenario ): + actual = data_structure.indexing_structure() raise ValueError( f"Data inconsistency for component: {component.id}, " - f"parameter: {param.name}. Requirement not met." + f"parameter: {param.name}. The data varies along " + f"[{_format_axes(actual.time, actual.scenario)}] but the model " + f"{component.model.id!r} declares the parameter " + f"[{_format_axes(declared.time, declared.scenario)}]. Data may " + f"vary along fewer axes than the model declares, never more." ) diff --git a/tests/e2e/functional/test_parameter_dependency_narrowing.py b/tests/e2e/functional/test_parameter_dependency_narrowing.py new file mode 100644 index 00000000..29620ecc --- /dev/null +++ b/tests/e2e/functional/test_parameter_dependency_narrowing.py @@ -0,0 +1,242 @@ +# Copyright (c) 2024, RTE (https://www.rte-france.com) +# +# See AUTHORS.txt +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# SPDX-License-Identifier: MPL-2.0 +# +# This file is part of the Antares project. +"""End-to-end tests for per-component parameter dependency narrowing. + +A model parameter declares the *maximum* dependency (time, scenario); a +component may declare the same or less. These tests solve a small system for +each allowed combination and check that the data actually used by the solver +follows the *component's* declaration: the parameter is constant along every +axis the component declared independent, and the objective value reflects that. +""" + +import io +from pathlib import Path +from typing import Tuple + +import numpy as np +import pytest + +from gems_craft.model.parsing import parse_yaml_library +from gems_craft.model.resolve_library import resolve_library +from gems_craft.study.parsing import parse_yaml_system +from gems_craft.study.resolve_components import build_data_base, resolve_system +from gems_craft.study.study import Study +from gems_runner.simulation import TimeBlock, build_problem +from gems_runner.simulation.optimization import OptimizationProblem + +_LIBRARY = """\ +library: + id: basic + port-types: + - id: flow + fields: [ {id: flow} ] + models: + - id: node + ports: [ {id: p, type: flow} ] + binding-constraints: + - id: balance + expression: sum_connections(p.flow) = 0 + - id: gen + parameters: + - id: cost + time-dependent: %(model_time)s + scenario-dependent: %(model_scenario)s + variables: + - id: g + lower-bound: 0 + upper-bound: 1000 + ports: [ {id: p, type: flow} ] + port-field-definitions: + - port: p + field: flow + definition: g + objective-contributions: + - id: operational + expression: expec(sum(cost * g)) + - id: load + parameters: + - id: d + time-dependent: true + scenario-dependent: true + ports: [ {id: p, type: flow} ] + port-field-definitions: + - port: p + field: flow + definition: -d +""" + +_SYSTEM = """\ +system: + id: narrowing + components: + - id: N + model: basic.node + - id: G + model: basic.gen + parameters: + - id: cost + time-dependent: %(component_time)s + scenario-dependent: %(component_scenario)s + value: %(cost_value)s + - id: D + model: basic.load + parameters: + - id: d + time-dependent: true + scenario-dependent: true + value: demand + connections: + - component1: G + port1: p + component2: N + port2: p + - component1: D + port1: p + component2: N + port2: p +""" + +# Demand is time x scenario: rows are timesteps, columns are scenarios. +_DEMAND = "10 20\n30 40\n" + +# Cost series, one file per shape the "cost" parameter may take. +_COST_SERIES = { + "cost_time": "5\n7\n", # T x 1 + "cost_scenario": "5 7\n", # 1 x S + "cost_time_scenario": "5 7\n9 11\n", # T x S +} + + +@pytest.fixture +def series_files(tmp_path: Path) -> Path: + """Write the data-series files used by the tests, return their directory.""" + (tmp_path / "demand.txt").write_text(_DEMAND) + for name, content in _COST_SERIES.items(): + (tmp_path / f"{name}.txt").write_text(content) + return tmp_path + + +def _build( + series_dir: Path, + model_dependency: Tuple[bool, bool], + component_dependency: Tuple[bool, bool], + cost_value: str, +) -> OptimizationProblem: + """Resolve and build the problem for one dependency combination.""" + + def yaml_bool(value: bool) -> str: + return "true" if value else "false" + + library = parse_yaml_library( + io.StringIO( + _LIBRARY + % { + "model_time": yaml_bool(model_dependency[0]), + "model_scenario": yaml_bool(model_dependency[1]), + } + ) + ) + input_system = parse_yaml_system( + io.StringIO( + _SYSTEM + % { + "component_time": yaml_bool(component_dependency[0]), + "component_scenario": yaml_bool(component_dependency[1]), + "cost_value": cost_value, + } + ) + ) + system = resolve_system(input_system, resolve_library([library])) + database = build_data_base(input_system, series_dir) + return build_problem(Study(system, database), TimeBlock(1, [0, 1]), [0, 1]) + + +def _cost_values(problem: OptimizationProblem) -> np.ndarray: + """The cost data as the solver sees it, broadcast to dims (time, scenario). + + The demand parameter is always time- and scenario-dependent, so it supplies + the full (time, scenario) grid to broadcast the cost against. + """ + cost = problem.param_arrays[("basic.gen", "cost")].sel(component="G") + grid = problem.param_arrays[("basic.load", "d")].sel(component="D") + return np.asarray(cost.broadcast_like(grid).transpose("time", "scenario").values) + + +# --------------------------------------------------------------------------- +# The component's declaration prevails over the model's +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "model_dependency, component_dependency, cost_value, expected_objective", + [ + # Model declares both axes; the component narrows to fewer and fewer. + ((True, True), (True, True), "cost_time_scenario", 450.0), + ((True, True), (True, False), "cost_time", 320.0), + ((True, True), (False, True), "cost_scenario", 310.0), + ((True, True), (False, False), "3", 150.0), + # Model declares one axis; the component keeps it, then drops it. + ((False, True), (False, True), "cost_scenario", 310.0), + ((False, True), (False, False), "3", 150.0), + ((True, False), (True, False), "cost_time", 320.0), + ((True, False), (False, False), "3", 150.0), + ], +) +def test_component_dependency_prevails_over_model_dependency( + series_files: Path, + model_dependency: Tuple[bool, bool], + component_dependency: Tuple[bool, bool], + cost_value: str, + expected_objective: float, +) -> None: + """Data varies only along the axes the *component* declares. + + The generator must cover the demand exactly, so the objective is the + scenario-average of ``sum_t(cost[t, s] * demand[t, s])``. Each expected + value below is only reachable if ``cost`` is broadcast over the axes the + component declared independent. + """ + problem = _build(series_files, model_dependency, component_dependency, cost_value) + problem.solve(solver_name="highs") + + assert problem.termination_condition == "optimal" + assert problem.objective_value == pytest.approx(expected_objective) + + cost = _cost_values(problem) # dims (time, scenario) + component_time, component_scenario = component_dependency + + if not component_time: + assert np.all(cost == cost[0, :][np.newaxis, :]), "cost varies along time" + if not component_scenario: + assert np.all(cost == cost[:, 0][:, np.newaxis]), "cost varies along scenario" + if component_time: + assert cost[0, 0] != cost[1, 0], "cost is expected to vary along time" + if component_scenario: + assert cost[0, 0] != cost[0, 1], "cost is expected to vary along scenario" + + +def test_narrowing_does_not_change_the_problem_shape(series_files: Path) -> None: + """Narrowing changes values, not the shape of the optimization problem. + + The parameter array keeps the dimensions declared by the *model*, so a + narrowed component yields the same variables and constraints as a + non-narrowed one — only the numbers differ. + """ + full = _build(series_files, (True, True), (True, True), "cost_time_scenario") + narrowed = _build(series_files, (True, True), (False, False), "3") + + assert ( + full.param_arrays[("basic.gen", "cost")].dims + == narrowed.param_arrays[("basic.gen", "cost")].dims + ) + assert len(full.linopy_model.variables) == len(narrowed.linopy_model.variables) + assert len(full.linopy_model.constraints) == len(narrowed.linopy_model.constraints) diff --git a/tests/unittests/gems_craft/data/test_data.py b/tests/unittests/gems_craft/data/test_data.py index 6b9a4e09..13f31acc 100644 --- a/tests/unittests/gems_craft/data/test_data.py +++ b/tests/unittests/gems_craft/data/test_data.py @@ -19,7 +19,9 @@ import pandas as pd import pytest +from gems_craft.expression.indexing_structure import IndexingStructure from gems_craft.study.data import ( + AbstractDataStructure, ConstantData, DataBase, ScenarioSeriesData, @@ -86,6 +88,18 @@ def test_dataframe_to_time_series_multi_column_raises() -> None: dataframe_to_time_series(df) +def test_dataframe_to_time_series_multi_column_error_hints_at_scenarios() -> None: + """The error points at the flag that allows several timeseries.""" + df = pd.DataFrame([[1.0, 2.0], [3.0, 4.0]]) + with pytest.raises(ValueError) as excinfo: + dataframe_to_time_series(df, context=" for component 'G', parameter 'cost'") + + message = str(excinfo.value) + assert "for component 'G', parameter 'cost'" in message + assert "got shape (2, 2)" in message + assert "declare the parameter scenario-dependent" in message + + # --------------------------------------------------------------------------- # dataframe_to_scenario_series # --------------------------------------------------------------------------- @@ -100,10 +114,76 @@ def test_dataframe_to_scenario_series_single_row() -> None: def test_dataframe_to_scenario_series_multi_row_raises() -> None: """Raises ValueError when the DataFrame has more than one row.""" df = pd.DataFrame([[1.0, 2.0], [3.0, 4.0]]) - with pytest.raises(ValueError, match="exactly one line"): + with pytest.raises(ValueError, match="exactly one row"): dataframe_to_scenario_series(df) +def test_dataframe_to_scenario_series_multi_row_error_hints_at_time() -> None: + """The error points at the flag that allows time-varying data.""" + df = pd.DataFrame([[1.0, 2.0], [3.0, 4.0]]) + with pytest.raises(ValueError) as excinfo: + dataframe_to_scenario_series(df, context=" for component 'G', parameter 'cost'") + + message = str(excinfo.value) + assert "for component 'G', parameter 'cost'" in message + assert "got shape (2, 2)" in message + assert "declare the parameter time-dependent" in message + + +# --------------------------------------------------------------------------- +# Data structures declare the axes they vary along +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "data, expected_time, expected_scenario", + [ + (ConstantData(1.0), False, False), + (TimeSeriesData(pd.Series([1.0, 2.0])), True, False), + (ScenarioSeriesData(np.array([1.0, 2.0])), False, True), + (TimeScenarioSeriesData(pd.DataFrame([[1.0, 2.0], [3.0, 4.0]])), True, True), + ], +) +def test_data_structure_reports_its_axes( + data: AbstractDataStructure, expected_time: bool, expected_scenario: bool +) -> None: + assert data.indexing_structure() == IndexingStructure( + expected_time, expected_scenario + ) + + +@pytest.mark.parametrize( + "data", + [ + ConstantData(1.0), + TimeSeriesData(pd.Series([1.0, 2.0])), + ScenarioSeriesData(np.array([1.0, 2.0])), + TimeScenarioSeriesData(pd.DataFrame([[1.0, 2.0], [3.0, 4.0]])), + ], +) +def test_check_requirement_accepts_data_varying_along_fewer_axes( + data: AbstractDataStructure, +) -> None: + """Data may always be used for a parameter declared along both axes.""" + assert data.check_requirement(time=True, scenario=True) + + +@pytest.mark.parametrize( + "data, time, scenario", + [ + (TimeSeriesData(pd.Series([1.0, 2.0])), False, True), + (ScenarioSeriesData(np.array([1.0, 2.0])), True, False), + (TimeScenarioSeriesData(pd.DataFrame([[1.0, 2.0]])), True, False), + (TimeScenarioSeriesData(pd.DataFrame([[1.0, 2.0]])), False, True), + (TimeScenarioSeriesData(pd.DataFrame([[1.0, 2.0]])), False, False), + ], +) +def test_check_requirement_rejects_data_varying_along_extra_axes( + data: AbstractDataStructure, time: bool, scenario: bool +) -> None: + assert not data.check_requirement(time=time, scenario=scenario) + + # --------------------------------------------------------------------------- # Data structure get_value error paths # --------------------------------------------------------------------------- diff --git a/tests/unittests/gems_craft/system/test_data_consistency.py b/tests/unittests/gems_craft/system/test_data_consistency.py index 400a1990..97795705 100644 --- a/tests/unittests/gems_craft/system/test_data_consistency.py +++ b/tests/unittests/gems_craft/system/test_data_consistency.py @@ -334,6 +334,81 @@ def test_requirements_consistency_scenario_varying_parameter_with_correct_data_p Study(system, database).check_consistency() +@pytest.mark.parametrize( + "cost_data", + [ + ConstantData(30), + TimeSeriesData(pd.Series({TimeIndex(0): 100, TimeIndex(1): 50})), + ], +) +def test_requirements_consistency_time_varying_parameter_with_narrower_data_passes( + mock_generator_with_fixed_scenario_time_varying_param: Model, + cost_data: Union[ConstantData, TimeSeriesData], +) -> None: + """A time-dependent parameter accepts constant data, broadcast over time.""" + node = Component(model=NODE_BALANCE_MODEL, id="1") + gen = create_component( + model=mock_generator_with_fixed_scenario_time_varying_param, id="G" + ) + + database = DataBase() + database.add_data("G", "p_max", ConstantData(100)) + database.add_data("G", "cost", cost_data) + system = System("test") + system.add_component(node) + system.add_component(gen) + system.connect(PortRef(gen, "balance_port"), PortRef(node, "balance_port")) + + # No ValueError should be raised + Study(system, database).check_consistency() + + +def test_requirements_consistency_scenario_varying_parameter_with_constant_data_passes( + mock_generator_with_scenario_varying_fixed_time_param: Model, +) -> None: + """A scenario-dependent parameter accepts one constant shared by all scenarios.""" + node = Component(model=NODE_BALANCE_MODEL, id="1") + gen = create_component( + model=mock_generator_with_scenario_varying_fixed_time_param, id="G" + ) + + database = DataBase() + database.add_data("G", "p_max", ConstantData(100)) + database.add_data("G", "cost", ConstantData(30)) + system = System("test") + system.add_component(node) + system.add_component(gen) + system.connect(PortRef(gen, "balance_port"), PortRef(node, "balance_port")) + + # No ValueError should be raised + Study(system, database).check_consistency() + + +def test_consistency_error_names_data_and_model_structures( + mock_generator_with_scenario_varying_fixed_time_param: Model, +) -> None: + """The error reports how the data varies and what the model declares.""" + node = Component(model=NODE_BALANCE_MODEL, id="1") + gen = create_component( + model=mock_generator_with_scenario_varying_fixed_time_param, id="G" + ) + + database = DataBase() + database.add_data("G", "p_max", ConstantData(100)) + database.add_data("G", "cost", TimeSeriesData(pd.Series({TimeIndex(0): 100}))) + system = System("test") + system.add_component(node) + system.add_component(gen) + system.connect(PortRef(gen, "balance_port"), PortRef(node, "balance_port")) + + with pytest.raises(ValueError) as excinfo: + Study(system, database).check_consistency() + + message = str(excinfo.value) + assert "The data varies along [time]" in message + assert "declares the parameter [scenario]" in message + + def test_load_data_from_txt() -> None: txt_file = "gen-costs" diff --git a/tests/unittests/gems_craft/system_parsing/test_components_parsing.py b/tests/unittests/gems_craft/system_parsing/test_components_parsing.py index 7173a3fe..79309075 100644 --- a/tests/unittests/gems_craft/system_parsing/test_components_parsing.py +++ b/tests/unittests/gems_craft/system_parsing/test_components_parsing.py @@ -355,6 +355,125 @@ def test_resolve_component_missing_declared_parameter_raises() -> None: resolve_system(system, resolve_library([lib])) +# --- parameter time/scenario dependency: the component may only narrow --- + +_LIB_WITH_PARAMETER_DEPENDENCY = """\ +library: + id: basic + models: + - id: generator + parameters: + - id: cost + time-dependent: %(time)s + scenario-dependent: %(scenario)s +""" + +_SYSTEM_WITH_PARAMETER_DEPENDENCY = """\ +system: + components: + - id: G + model: basic.generator + parameters: + - id: cost + time-dependent: %(time)s + scenario-dependent: %(scenario)s + value: %(value)s +""" + + +def _resolve_with_dependencies( + model_dependency: tuple[bool, bool], + component_dependency: tuple[bool, bool], + value: str = "cost_series", +) -> None: + def as_yaml(dependency: tuple[bool, bool]) -> dict[str, str]: + return { + "time": "true" if dependency[0] else "false", + "scenario": "true" if dependency[1] else "false", + } + + lib = parse_yaml_library( + io.StringIO(_LIB_WITH_PARAMETER_DEPENDENCY % as_yaml(model_dependency)) + ) + system = parse_yaml_system( + io.StringIO( + _SYSTEM_WITH_PARAMETER_DEPENDENCY + % {**as_yaml(component_dependency), "value": value} + ) + ) + resolve_system(system, resolve_library([lib])) + + +@pytest.mark.parametrize( + "model_dependency, component_dependency, value", + [ + # Same dependency as the model. + ((True, True), (True, True), "cost_series"), + ((True, False), (True, False), "cost_series"), + ((False, True), (False, True), "cost_series"), + ((False, False), (False, False), "30"), + # Narrower than the model. + ((True, True), (True, False), "cost_series"), + ((True, True), (False, True), "cost_series"), + ((True, True), (False, False), "30"), + ((True, False), (False, False), "30"), + ((False, True), (False, False), "30"), + ], +) +def test_resolve_component_parameter_dependency_narrowing_ok( + model_dependency: tuple[bool, bool], + component_dependency: tuple[bool, bool], + value: str, +) -> None: + """A component may declare the model's dependency, or less.""" + _resolve_with_dependencies(model_dependency, component_dependency, value) + + +@pytest.mark.parametrize( + "model_dependency, component_dependency, extra_axis", + [ + ((False, True), (True, True), "time"), + ((False, True), (True, False), "time"), + ((True, False), (False, True), "scenario"), + ((True, False), (True, True), "scenario"), + ((False, False), (True, False), "time"), + ((False, False), (False, True), "scenario"), + ((False, False), (True, True), "time-dependent and scenario"), + ], +) +def test_resolve_component_parameter_dependency_extension_raises( + model_dependency: tuple[bool, bool], + component_dependency: tuple[bool, bool], + extra_axis: str, +) -> None: + """A component may not add an axis of variation the model does not declare.""" + with pytest.raises( + ValueError, + match=( + rf"Component 'G' \(model 'basic.generator'\), parameter 'cost': cannot " + rf"declare the parameter {extra_axis}-dependent because the model does not" + ), + ): + _resolve_with_dependencies(model_dependency, component_dependency) + + +def test_resolve_component_parameter_dependency_error_names_both_declarations() -> None: + """The error reports the model's and the system's declarations.""" + with pytest.raises(ValueError) as excinfo: + _resolve_with_dependencies((False, True), (True, True)) + + message = str(excinfo.value) + assert ( + "The model declares [time-dependent: false, scenario-dependent: true]" + in message + ) + assert ( + "the system declares [time-dependent: true, scenario-dependent: true]" + in message + ) + assert "only narrow" in message + + def test_resolve_component_extra_undeclared_property_allowed() -> None: lib = parse_yaml_library(io.StringIO(_LIB_WITH_MODEL_PROPERTIES)) system = parse_yaml_system(io.StringIO("""\