diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 33e98a9c..985c616c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to GemsPy are documented here. ## [Unreleased] ### Changed +- **Sequential mode carry-over length can now be controlled by the user through the parameter `carry-over-length` (default: `block-overlap`). This also fixes an incorrect stitching for `block-overlap >= 2`, where the previous hardcoded behaviour pinned block + *N+1*'s first timestep to block *N*'s **last** timestep — a different absolute timestep. - **linopy upgraded to `>=0.9.0`** - the minimum supported Python version rises to **3.11** accordingly (linopy 0.9 requires Python >= 3.11). diff --git a/docs/user-guide/optim-config.md b/docs/user-guide/optim-config.md index 9cc8ef82..8e5733f9 100644 --- a/docs/user-guide/optim-config.md +++ b/docs/user-guide/optim-config.md @@ -41,7 +41,8 @@ solver-options: resolution: mode: sequential-subproblems # see section below block-length: 168 # one week (in timesteps) - block-overlap: 0 + block-overlap: 24 # consecutive blocks share one day + carry-over-length: 24 # optional; omitted → defaults to block-overlap # Per-model configuration (optional) models: @@ -220,7 +221,8 @@ optimisation subproblems. |---|---|---|---| | `mode` | str | `"frontal"` | Resolution strategy (see below) | | `block-length` | int | — | Timesteps per window; required for windowed modes | -| `block-overlap` | int | `0` | Extra overlap timesteps between consecutive blocks | +| `block-overlap` | int | `0` | Sequential mode only (rejected in other modes): shared timesteps between consecutive blocks; must satisfy `0 <= block-overlap < block-length` | +| `carry-over-length` | int | `block-overlap` | Sequential mode only (rejected in other modes): how many of the shared timesteps are pinned to the previous block's values; must satisfy `0 <= carry-over-length <= block-overlap` | ### `frontal` (default) @@ -236,18 +238,92 @@ Produces globally optimal results. ### `sequential-subproblems` -The horizon is split into non-overlapping (or slightly overlapping) windows of -`block-length` timesteps. Blocks are solved **one after the other**; the state -of inter-block dynamics (e.g. storage level) is carried over from one block to -the next. +The horizon is split into windows of `block-length` timesteps, each starting +`block-length - block-overlap` timesteps after the previous one. Blocks are +solved **one after the other**; the state of inter-block dynamics (e.g. storage +level) is carried over from one block to the next by pinning the leading +timesteps of each block to the values the previous block already computed. ~~~ yaml resolution: mode: sequential-subproblems - block-length: 168 # one week - block-overlap: 0 + block-length: 168 # one week + block-overlap: 24 # one day shared between consecutive blocks + carry-over-length: 24 # optional; omitted → defaults to block-overlap (full pin) + # 0 is legal and explicit: overlap solved twice, no stitching ~~~ +Three parameters shape the stitching between consecutive blocks. Illustrative +example with `block-length: 10`, `block-overlap: 4`, `carry-over-length: 3` +(a partial pin, so all three parameters are visible at once): + +~~~ text +abs t 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 +Block N 0 1 2 3 4 5 6 7 8 9 + └──────────────────────────────────────┘ + block-length = 10 + +Block N+1 0 1 2 3 4 5 6 7 8 9 + └──────────────────────────────────────┘ + block-length = 10 + + |------------| overlap = 4 (t=6..9: solved by BOTH blocks) + |========| carry-over = 3 (t=6..8: PINNED to block N's value) + ^ t=9: still shared, but free in N+1 (re-optimized) +~~~ + +Reading it: + +- **`block-length`** — width of each block's own window (10 for both here). +- **`block-overlap`** — how far block *N+1*'s start reaches back into block + *N*'s window (4 → t=6..9 exist in both solves). The overlap gives block + *N+1* real historical values for lag-dependent constraints (e.g. a storage + balance using `soc[t-1]`, or min up/down durations spanning several hours). +- **`carry-over-length`** — how many of those *shared* leading timesteps of + block *N+1* get hard-pinned (`var[t] == value from block N`) to block *N*'s + already-solved values, counted from the earliest shared timestep (t=6), not + from t=9. Here `carry-over-length: 3 < overlap: 4`, so t=6,7,8 are frozen + but t=9 is left free — an MPC-style partial pin where the optimizer may + revise the tail of the overlap with more lookback context. + +Defaults and special values: + +- **Omitted** `carry-over-length` resolves to `block-overlap`: the whole + overlap zone is pinned. This is the right default when the overlap exists + to provide history for lag-dependent constraints without re-litigating + decisions the previous block already made. +- **Explicit `carry-over-length: 0`** is legal and distinct from omitting the + field: blocks overlap for lag-constraint history, but no timestep is pinned + — block *N+1* re-solves the whole overlap window independently. +- Validation requires `0 <= carry-over-length <= block-overlap` (and + `0 <= block-overlap < block-length`), with no special case at + `block-overlap: 0`. + +Overlapping timesteps appear once per block in the simulation table, tagged +with the `block` column — nothing is lost or silently merged. Downstream +tooling decides which block's version of a shared timestep is authoritative; +`carry-over-length` only controls how much two consecutive blocks may +*disagree* on that shared window. + +**What the carry-over pins.** The mechanism is plain *variable fixing*: for +block *N+1*, every time-dependent variable whose block-relative timestep falls +in `[0, carry-over-length[` is fixed to the value block *N* computed for the +**same absolute timestep**. Two consequences are worth spelling out: + +- It is **not** an initial-condition mechanism. Block *N+1*'s problem is not + given the value of the timestep *preceding* its window, so a `t-1` time-shift + operator at the block's first timestep still resolves against that block's own + border condition (cyclic by default) rather than reaching into block *N*. +- It applies to **all** time-dependent variables of all models, not only + state-like ones such as a storage level. Finer, per-model granularity can be + added later if a use case needs it. + +**Time-independent** variables (`structure.time = False`, e.g. an investment +capacity) are never carried over — nothing links their values across blocks, so +each block sizes them independently. Sequential mode is therefore not suited to +investment problems; use `frontal` or `benders-decomposition` for those. + + ### `parallel-subproblems` diff --git a/src/gems_craft/optim_config/parsing.py b/src/gems_craft/optim_config/parsing.py index 69631e44..a94fc82b 100644 --- a/src/gems_craft/optim_config/parsing.py +++ b/src/gems_craft/optim_config/parsing.py @@ -149,10 +149,14 @@ class ResolutionMode(str, Enum): BENDERS_DECOMPOSITION = "benders-decomposition" +_SEQUENTIAL_ONLY_FIELDS = ("block_overlap", "carry_over_length") + + class ResolutionConfig(ModifiedBaseModel): mode: ResolutionMode = ResolutionMode.FRONTAL block_length: Optional[int] = None block_overlap: int = 0 + carry_over_length: Optional[int] = None @model_validator(mode="after") def _block_length_required_for_windowed_modes(self) -> "ResolutionConfig": @@ -164,6 +168,66 @@ def _block_length_required_for_windowed_modes(self) -> "ResolutionConfig": raise ValueError(f"'block_length' is required for mode '{self.mode.value}'") return self + @model_validator(mode="after") + def _reject_sequential_only_fields(self) -> "ResolutionConfig": + """'block-overlap' and 'carry-over-length' steer the stitching of + consecutive blocks, which only exists in sequential mode. Reject them + elsewhere instead of dropping them silently. The check is on the keys + the user actually wrote (``model_fields_set``), so an explicit + 'block-overlap: 0' is rejected too.""" + if self.mode == ResolutionMode.SEQUENTIAL_SUBPROBLEMS: + return self + declared = [ + name for name in _SEQUENTIAL_ONLY_FIELDS if name in self.model_fields_set + ] + if declared: + keys = ", ".join(f"'{name.replace('_', '-')}'" for name in declared) + plural = len(declared) > 1 + raise ValueError( + f"{keys} only appl{'y' if plural else 'ies'} to mode " + f"'{ResolutionMode.SEQUENTIAL_SUBPROBLEMS.value}', but mode is " + f"'{self.mode.value}'; remove {'them' if plural else 'it'} " + f"or switch mode" + ) + return self + + @model_validator(mode="after") + def _validate_block_overlap(self) -> "ResolutionConfig": + if self.block_overlap < 0: + raise ValueError(f"'block-overlap' must be >= 0, got {self.block_overlap}") + if self.block_length is not None and self.block_overlap >= self.block_length: + raise ValueError( + f"'block-overlap' ({self.block_overlap}) must be < 'block-length' " + f"({self.block_length})" + ) + return self + + @model_validator(mode="after") + def _validate_carry_over_length(self) -> "ResolutionConfig": + if self.carry_over_length is not None: + if self.carry_over_length < 0: + raise ValueError( + f"'carry-over-length' must be >= 0, got {self.carry_over_length}" + ) + if self.carry_over_length > self.block_overlap: + raise ValueError( + f"'carry-over-length' ({self.carry_over_length}) must be <= " + f"'block-overlap' ({self.block_overlap})" + ) + return self + + @property + def effective_carry_over_length(self) -> int: + """Resolved carry-over length: explicit value if set, else full pin of + the overlap zone (``block_overlap``). ``0`` is a legal explicit value, + distinct from "unset", meaning blocks overlap for lag-constraint + history but are not stitched at all.""" + return ( + self.carry_over_length + if self.carry_over_length is not None + else self.block_overlap + ) + class TimeScopeConfig(ModifiedBaseModel): first_time_step: int = 0 diff --git a/src/gems_runner/session/session.py b/src/gems_runner/session/session.py index 9dc14849..66e56918 100644 --- a/src/gems_runner/session/session.py +++ b/src/gems_runner/session/session.py @@ -91,6 +91,7 @@ def _run_sequential(self) -> SimulationTable: cfg = self.optim_config.resolution block_length: int = cfg.block_length # type: ignore[assignment] block_overlap: int = cfg.block_overlap + carry_over_length: int = cfg.effective_carry_over_length tables: List[SimulationTable] = [] for scenario_id in self.scenario_ids: @@ -111,10 +112,16 @@ def _run_sequential(self) -> SimulationTable: initial_values=carry_over or None, ) tables.append(table) + # Block N and block N+1 share `block_overlap` absolute + # timesteps: block N's local indices `block_length - overlap + # ...` are block N+1's local indices `0 ...`. + delta = block_length - block_overlap + t_start += delta carry_over = self._extract_carry_over( - problem, local_index=len(timesteps) - 1 + problem, + local_start=delta, + length=carry_over_length, ) - t_start += block_length - block_overlap block_id += 1 return self._reduce(tables) @@ -240,17 +247,33 @@ def _reduce(self, tables: List[SimulationTable]) -> SimulationTable: @staticmethod def _extract_carry_over( problem: OptimizationProblem, - local_index: int, + local_start: int, + length: int, ) -> Dict[Tuple[str, str], xr.DataArray]: - """Extract variable values at *local_index* for use as initial values in the next block.""" + """Extract variable values over *length* timesteps starting at *local_start*. + + The returned arrays keep a ``time`` dimension re-indexed to + ``0 .. length-1`` so they align with the leading timesteps of the next + block's variables. The window is clamped to the solved block's actual + horizon (a truncated final block can be shorter than ``block_length``), + so fewer than *length* values may be carried over. + + Only variables carrying a ``time`` dimension are extracted. + Time-independent variables (e.g. an investment capacity) are + deliberately left free in every block, so each block re-optimizes them + independently. + """ carry_over: Dict[Tuple[str, str], xr.DataArray] = {} - if problem.linopy_model.solution is None: + if length <= 0 or problem.linopy_model.solution is None: return carry_over for (model, var_name), linopy_var in problem._linopy_vars.items(): if "time" in linopy_var.dims: sol_da = problem.get_variable_solution(model, var_name) if sol_da is not None: - carry_over[(model, var_name)] = sol_da.isel( - time=local_index, drop=True + window = sol_da.isel(time=slice(local_start, local_start + length)) + if window.sizes["time"] == 0: + continue + carry_over[(model, var_name)] = window.assign_coords( + time=list(range(window.sizes["time"])) ) return carry_over diff --git a/src/gems_runner/simulation/optimization.py b/src/gems_runner/simulation/optimization.py index 6eb4ff61..ab64789a 100644 --- a/src/gems_runner/simulation/optimization.py +++ b/src/gems_runner/simulation/optimization.py @@ -467,13 +467,35 @@ def get_variable_solution( # --------------------------------------------------------------------------- +def _validate_initial_values( + initial_values: Optional[Dict[Tuple[str, str], xr.DataArray]], +) -> Dict[Tuple[str, str], xr.DataArray]: + """Check the carry-over contract on *initial_values* and return them. + + Every value must carry a ``time`` dimension indexed ``0 .. k-1`` so that it + aligns with the leading timesteps of the block being built. + """ + values = initial_values or {} + for (mk, var_name), init_val in values.items(): + if "time" not in init_val.dims: + raise ValueError( + f"initial_values[{mk!r}, {var_name!r}] must carry a 'time' " + f"dimension indexed 0..k-1; got dims {tuple(init_val.dims)}" + ) + return values + + class _OptimizationProblemBuilder: """ - Builds the linopy problem in 4 phases: + Builds the linopy problem in 5 phases: 1. Build parameter DataArrays for all models. 2. Create all linopy Variables (uses param arrays for bounds). 3. Build port arrays via incidence matrices. 4. Add constraints and objectives to the linopy model. + 5. Add the carry-over constraints of *initial_values* (sequential mode): + each time-dependent variable is *fixed*, over the block's first ``k`` + timesteps, to the value the previous block computed for the same + absolute timestep. Time-independent variables are never pinned. """ def __init__( @@ -492,7 +514,7 @@ def __init__( self.scenario_ids = scenario_ids self._location_filter = location_filter self._oob_filter = oob_filter - self._initial_values = initial_values or {} + self._initial_values = _validate_initial_values(initial_values) self.block_length = len(block.timesteps) self.time_coord = list(range(self.block_length)) @@ -534,15 +556,24 @@ def build(self) -> OptimizationProblem: model, port_arrays_for_model, total_obj ) - # Phase 5: carry-over constraints (sequential mode only) + # Phase 5: carry-over constraints (sequential mode only). + # Only time-dependent variables are pinned: time-independent ones + # (structure.time = False, e.g. an investment capacity) are deliberately + # left free in every block — see the user guide, `sequential-subproblems`. for (mk, var_name), init_val in self._initial_values.items(): linopy_var = self.linopy_vars.get((mk, var_name)) - if linopy_var is not None and "time" in linopy_var.dims: - safe = f"{mk}__{var_name}".replace("-", "_") - self.linopy_model.add_constraints( - linopy_var.isel(time=0) == init_val, # type: ignore[arg-type] - name=f"carry_over__{safe}", - ) + if linopy_var is None or "time" not in linopy_var.dims: + continue + # Pin the first len(init_val.time) timesteps, clamped to this + # block's horizon (a truncated final block can be shorter than the + # carried window). + pin_length = min(init_val.sizes["time"], self.block_length) + safe = f"{mk}__{var_name}".replace("-", "_") + self.linopy_model.add_constraints( + linopy_var.isel(time=slice(0, pin_length)) + == init_val.isel(time=slice(0, pin_length)), # type: ignore[arg-type] + name=f"carry_over__{safe}", + ) # Extract constant objective contribution (linopy cannot hold pure constants). objective_constant = 0.0 @@ -992,9 +1023,14 @@ def build_problem( problem_name: Label for the linopy model. initial_values: - Optional carry-over values keyed by ``(model_id, var_name)``. For - each entry a constraint ``var[time=0] == value`` is added, overriding - the cyclic border condition for the first timestep. + Optional carry-over values keyed by ``(model_id, var_name)``. Each + value must be an ``xr.DataArray`` carrying a ``time`` dimension of + length ``k`` indexed ``0 .. k-1``; constraints + ``var[time=i] == value[i]`` are then added for the block's first ``k`` + timesteps, overriding the cyclic border condition on that window. A + value without a ``time`` dimension raises ``ValueError`` before the + problem is built. Entries whose variable is time-independent, or is + absent from this block, are ignored. """ study.check_consistency() diff --git a/tests/e2e/functional/test_sequential_carry_over_length.py b/tests/e2e/functional/test_sequential_carry_over_length.py new file mode 100644 index 00000000..7c28af7c --- /dev/null +++ b/tests/e2e/functional/test_sequential_carry_over_length.py @@ -0,0 +1,247 @@ +# 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. + +""" +E2E test: multi-timestep carry-over in sequential mode. + +Reuses the rolling_horizon_suboptimality study (generator p_max=2/cost=1, +storage capacity=2/rate=2, bus with ens_cost=100) with a longer, aperiodic +12-step demand series, supplied in memory rather than through a study folder: +the study directory and its `optim-config.yml` are only an entry point, and +`run_study`'s folder-to-CSV path has its own test (test_study_from_folder.py). + +Sequential mode with block-length=6, block-overlap=3 over t=0..11: + + block 0: [0 1 2 3 4 5] + block 1: [3 4 5 6 7 8] + block 2: [6 7 8 9 10 11] + block 3: [9 10 11] (truncated tail) + +Consecutive blocks share `block-overlap` = 3 absolute timesteps. The +`carry-over-length` first shared timesteps of block N+1 are pinned to block +N's already-solved values — counted from the *earliest* shared timestep, so +each pinned constraint matches the same absolute timestep in both blocks. + +`carry-over-length` is checked on the carry-over constraints themselves: what +the setting controls is which variables are *fixed* and which are left free, +and comparing solved values cannot tell a free timestep that happens to +re-optimize to the same value from a fixed one. +""" + +from functools import lru_cache +from pathlib import Path +from typing import Any, Dict, List, Set, Tuple + +import pandas as pd +import pytest + +from gems_craft.optim_config.parsing import ( + ModelOptimConfig, + OptimConfig, + OutOfBoundsConstraintConfig, + OutOfBoundsMode, + OutOfBoundsProcessingConfig, + ResolutionConfig, + ResolutionMode, + ScenarioScopeConfig, + TimeScopeConfig, +) +from gems_craft.study.data import TimeSeriesData +from gems_craft.study.folder import load_study +from gems_craft.study.study import Study +from gems_runner.session.session import SimulationSession +from gems_runner.simulation.optimization import OptimizationProblem +from gems_runner.simulation.simulation_table import SimulationTable + +_STUDY_SRC = Path(__file__).parent / "studies" / "rolling_horizon_suboptimality" + +# Aperiodic demand: peaks (4) need gen=2 + discharge=2, mid steps (2) are +# covered by the generator alone, zeros allow recharging. The pattern is +# deliberately not periodic with the block stride so that the SoC trajectory +# differs between a block's last timestep and the start of the overlap zone. +_DEMAND = [0, 4, 2, 0, 4, 0, 2, 4, 0, 4, 4, 0] +_BLOCK_LENGTH = 6 +_BLOCK_OVERLAP = 3 + + +@lru_cache +def _study() -> Study: + """The committed rolling-horizon study, with its 6-step demand series + replaced in memory by the 12-step aperiodic one above.""" + study = load_study(_STUDY_SRC) + study.database.add_data( + "load_node", "demand", TimeSeriesData(pd.Series(_DEMAND, dtype=float)) + ) + return study + + +def _config(**resolution: Any) -> OptimConfig: + """The study's optim-config, with `resolution` built from the given fields. + + Fields left out are left *unset* (not defaulted), which is what + distinguishes an omitted `carry-over-length` from an explicit `0`, and what + keeps `block-overlap` — sequential-only — out of the parallel config. + """ + return OptimConfig( + time_scope=TimeScopeConfig(first_time_step=0, last_time_step=len(_DEMAND) - 1), + scenario_scope=ScenarioScopeConfig(include=[0]), + models=[ + ModelOptimConfig( + id="rolling-horizon-lib.storage", + out_of_bounds_processing=OutOfBoundsProcessingConfig( + constraints=[ + OutOfBoundsConstraintConfig( + id="soc_balance", mode=OutOfBoundsMode.DROP + ) + ] + ), + ) + ], + resolution=ResolutionConfig(**resolution), + ) + + +def _solve(config: OptimConfig) -> Tuple[SimulationTable, List[OptimizationProblem]]: + """Run the study through a `SimulationSession` and return its result table + plus the solved problems, one per block, in solve order. + + `SimulationSession._run_block` returns the solved problem for carry-over + extraction *or inspection*, which is what gives the test access to the + carry-over constraints. + """ + session = SimulationSession(_study(), config) + problems: List[OptimizationProblem] = [] + run_block = session._run_block + + def spy(*args: Any, **kwargs: Any) -> Any: + problem, table = run_block(*args, **kwargs) + problems.append(problem) + return problem, table + + session._run_block = spy # type: ignore[assignment] + return session.run(), problems + + +def _pinned_window_lengths(problem: OptimizationProblem) -> Set[int]: + """Number of timesteps each carry-over equality constraint of `problem` + fixes. Empty when the block carries nothing over.""" + linopy_model = problem.linopy_model + return { + int(linopy_model.constraints[name].sizes["time"]) + for name in linopy_model.constraints + if name.startswith("carry_over__") + } + + +def _value( + st: SimulationTable, block: int, component: str, output: str, timestep: int +) -> float: + df = st.data + rows = df[ + (df["block"] == block) + & (df["component"] == component) + & (df["output"] == output) + & (df["absolute_time_index"] == timestep) + ] + assert len(rows) == 1, ( + f"Expected exactly one row for block={block} component={component} " + f"output={output} t={timestep}, got {len(rows)}" + ) + return float(rows.iloc[0]["value"]) + + +@pytest.mark.parametrize( + "carry_over, expected", + [ + # Omitted resolves to `block-overlap`: the whole overlap zone is pinned. + ({}, _BLOCK_OVERLAP), + ({"carry_over_length": 0}, 0), + ({"carry_over_length": 1}, 1), + ({"carry_over_length": 2}, 2), + ], +) +def test_carry_over_length_fixes_that_many_leading_timesteps( + carry_over: Dict[str, int], expected: int +) -> None: + """`carry-over-length: k` fixes, in every block but the first, the k + leading local timesteps — the k earliest shared timesteps — to the previous + block's solution, and leaves the rest of the overlap zone free. + + `k = 0` fixes nothing at all: the blocks still overlap (so lag constraints + keep their history) but are not stitched. + """ + _, problems = _solve( + _config( + mode=ResolutionMode.SEQUENTIAL_SUBPROBLEMS, + block_length=_BLOCK_LENGTH, + block_overlap=_BLOCK_OVERLAP, + **carry_over, + ) + ) + # block-length=6, block-overlap=3, t=0..11 → blocks [0..5], [3..8], [6..11] + # and the truncated tail [9..11]. + assert len(problems) == 4 + assert not _pinned_window_lengths( + problems[0] + ), "Nothing is carried into the first block" + + for block_id, problem in enumerate(problems[1:], start=1): + windows = _pinned_window_lengths(problem) + assert windows == ({expected} if expected else set()), ( + f"Block {block_id}: every carry-over constraint must fix the " + f"{expected} leading timesteps, found {sorted(windows)}" + ) + + +def test_zero_overlap_blocks_fully_independent() -> None: + """With block-overlap: 0 nothing is carried between blocks: each block is + solved as if it were alone (no carry-over constraints). + + Two complementary checks: + + - Block 1 ([6..11], demand [2,4,0,4,4,0]) serves its t=7 peak by + pre-charging its *free* initial storage state. + - The whole solution is identical to parallel-subproblems mode, which + solves the same windows independently by construction (and where + `block-overlap` is not accepted at all). + """ + seq, _ = _solve( + _config( + mode=ResolutionMode.SEQUENTIAL_SUBPROBLEMS, + block_length=_BLOCK_LENGTH, + block_overlap=0, + ) + ) + par, _ = _solve( + _config(mode=ResolutionMode.PARALLEL_SUBPROBLEMS, block_length=_BLOCK_LENGTH) + ) + + assert _value(seq, 1, "bus", "unsupplied", 7) == pytest.approx( + 0.0, abs=1e-6 + ), "Block 1 must serve its t=7 peak from a free initial storage state" + + # Both modes enumerate the same windows with the same 0-based block ids. + for component, output in [ + ("storage", "soc"), + ("storage", "charge"), + ("storage", "discharge"), + ("gen", "p"), + ("bus", "unsupplied"), + ]: + for t in range(len(_DEMAND)): + block = t // _BLOCK_LENGTH + v_seq = _value(seq, block, component, output, t) + v_par = _value(par, block, component, output, t) + assert v_seq == pytest.approx(v_par, abs=1e-6), ( + f"sequential (block-overlap: 0) and parallel modes disagree at " + f"t={t} for {component}.{output}: {v_seq} != {v_par}" + ) diff --git a/tests/unittests/gems_craft/optim_config/test_resolution_config.py b/tests/unittests/gems_craft/optim_config/test_resolution_config.py new file mode 100644 index 00000000..2cd01598 --- /dev/null +++ b/tests/unittests/gems_craft/optim_config/test_resolution_config.py @@ -0,0 +1,229 @@ +# 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. + +import pytest +from pydantic import ValidationError + +from gems_craft.optim_config.parsing import ResolutionConfig, ResolutionMode + +# --------------------------------------------------------------------------- +# Defaults and parsing +# --------------------------------------------------------------------------- + + +def test_defaults() -> None: + cfg = ResolutionConfig() + assert cfg.mode == ResolutionMode.FRONTAL + assert cfg.block_length is None + assert cfg.block_overlap == 0 + assert cfg.carry_over_length is None + assert cfg.effective_carry_over_length == 0 + + +def test_kebab_case_aliases() -> None: + cfg = ResolutionConfig.model_validate( + { + "mode": "sequential-subproblems", + "block-length": 168, + "block-overlap": 24, + "carry-over-length": 12, + } + ) + assert cfg.block_length == 168 + assert cfg.block_overlap == 24 + assert cfg.carry_over_length == 12 + + +def test_block_length_required_for_windowed_modes() -> None: + with pytest.raises(ValidationError, match="block_length"): + ResolutionConfig(mode=ResolutionMode.SEQUENTIAL_SUBPROBLEMS) + + +# --------------------------------------------------------------------------- +# effective_carry_over_length resolution +# --------------------------------------------------------------------------- + + +def test_carry_over_defaults_to_block_overlap() -> None: + cfg = ResolutionConfig( + mode=ResolutionMode.SEQUENTIAL_SUBPROBLEMS, + block_length=168, + block_overlap=24, + ) + assert cfg.carry_over_length is None + assert cfg.effective_carry_over_length == 24 + + +def test_explicit_zero_is_distinct_from_unset() -> None: + cfg = ResolutionConfig( + mode=ResolutionMode.SEQUENTIAL_SUBPROBLEMS, + block_length=168, + block_overlap=24, + carry_over_length=0, + ) + assert cfg.carry_over_length == 0 + assert cfg.effective_carry_over_length == 0 + + +def test_partial_carry_over() -> None: + cfg = ResolutionConfig( + mode=ResolutionMode.SEQUENTIAL_SUBPROBLEMS, + block_length=10, + block_overlap=4, + carry_over_length=3, + ) + assert cfg.effective_carry_over_length == 3 + + +# --------------------------------------------------------------------------- +# block-overlap validation +# --------------------------------------------------------------------------- + + +def test_negative_block_overlap_rejected() -> None: + with pytest.raises(ValidationError, match="'block-overlap' must be >= 0"): + ResolutionConfig( + mode=ResolutionMode.SEQUENTIAL_SUBPROBLEMS, + block_length=10, + block_overlap=-1, + ) + + +def test_block_overlap_equal_to_block_length_rejected() -> None: + with pytest.raises(ValidationError, match="must be < 'block-length'"): + ResolutionConfig( + mode=ResolutionMode.SEQUENTIAL_SUBPROBLEMS, + block_length=10, + block_overlap=10, + ) + + +def test_block_overlap_greater_than_block_length_rejected() -> None: + with pytest.raises(ValidationError, match="must be < 'block-length'"): + ResolutionConfig( + mode=ResolutionMode.SEQUENTIAL_SUBPROBLEMS, + block_length=10, + block_overlap=11, + ) + + +# --------------------------------------------------------------------------- +# carry-over-length validation +# --------------------------------------------------------------------------- + + +def test_negative_carry_over_length_rejected() -> None: + with pytest.raises(ValidationError, match="'carry-over-length' must be >= 0"): + ResolutionConfig( + mode=ResolutionMode.SEQUENTIAL_SUBPROBLEMS, + block_length=10, + block_overlap=2, + carry_over_length=-1, + ) + + +def test_carry_over_length_greater_than_overlap_rejected() -> None: + with pytest.raises(ValidationError, match="must be <= 'block-overlap'"): + ResolutionConfig( + mode=ResolutionMode.SEQUENTIAL_SUBPROBLEMS, + block_length=10, + block_overlap=2, + carry_over_length=3, + ) + + +def test_carry_over_length_rejected_when_overlap_is_zero() -> None: + # No special case at block_overlap == 0: any positive carry-over-length + # is rejected, there is no implicit single-timestep seeding any more. + with pytest.raises(ValidationError, match="must be <= 'block-overlap'"): + ResolutionConfig( + mode=ResolutionMode.SEQUENTIAL_SUBPROBLEMS, + block_length=10, + block_overlap=0, + carry_over_length=1, + ) + + +def test_carry_over_length_equal_to_overlap_accepted() -> None: + cfg = ResolutionConfig( + mode=ResolutionMode.SEQUENTIAL_SUBPROBLEMS, + block_length=10, + block_overlap=4, + carry_over_length=4, + ) + assert cfg.effective_carry_over_length == 4 + + +# --------------------------------------------------------------------------- +# Sequential-only fields rejected in other modes +# --------------------------------------------------------------------------- + +_NON_SEQUENTIAL_MODES = [ + ResolutionMode.FRONTAL, + ResolutionMode.PARALLEL_SUBPROBLEMS, + ResolutionMode.BENDERS_DECOMPOSITION, +] + + +@pytest.mark.parametrize("mode", _NON_SEQUENTIAL_MODES) +def test_block_overlap_rejected_outside_sequential(mode: ResolutionMode) -> None: + with pytest.raises(ValidationError, match="'block-overlap' only applies to mode"): + ResolutionConfig(mode=mode, block_length=10, block_overlap=2) + + +@pytest.mark.parametrize("mode", _NON_SEQUENTIAL_MODES) +def test_carry_over_length_rejected_outside_sequential(mode: ResolutionMode) -> None: + with pytest.raises( + ValidationError, match="'carry-over-length' only applies to mode" + ): + ResolutionConfig(mode=mode, block_length=10, carry_over_length=1) + + +def test_both_sequential_only_fields_reported_together() -> None: + with pytest.raises( + ValidationError, + match="'block-overlap', 'carry-over-length' only apply to mode", + ): + ResolutionConfig( + mode=ResolutionMode.FRONTAL, block_overlap=2, carry_over_length=1 + ) + + +def test_explicit_zero_block_overlap_rejected_outside_sequential() -> None: + # The check is on the keys the user wrote, not on their values: an explicit + # 'block-overlap: 0' is just as ignored as any other value. + with pytest.raises(ValidationError, match="'block-overlap' only applies to mode"): + ResolutionConfig(mode=ResolutionMode.FRONTAL, block_overlap=0) + + +def test_kebab_aliases_are_detected_as_declared() -> None: + with pytest.raises(ValidationError, match="'block-overlap' only applies to mode"): + ResolutionConfig.model_validate( + {"mode": "parallel-subproblems", "block-length": 6, "block-overlap": 0} + ) + + +@pytest.mark.parametrize("mode", _NON_SEQUENTIAL_MODES) +def test_non_sequential_modes_accepted_without_the_fields(mode: ResolutionMode) -> None: + cfg = ResolutionConfig(mode=mode, block_length=10) + assert cfg.block_overlap == 0 + assert cfg.effective_carry_over_length == 0 + + +def test_sequential_mode_still_accepts_both_fields() -> None: + cfg = ResolutionConfig( + mode=ResolutionMode.SEQUENTIAL_SUBPROBLEMS, + block_length=10, + block_overlap=4, + carry_over_length=2, + ) + assert cfg.effective_carry_over_length == 2 diff --git a/tests/unittests/gems_runner/simulation/test_carry_over_initial_values.py b/tests/unittests/gems_runner/simulation/test_carry_over_initial_values.py new file mode 100644 index 00000000..742d29df --- /dev/null +++ b/tests/unittests/gems_runner/simulation/test_carry_over_initial_values.py @@ -0,0 +1,111 @@ +# Copyright (c) 2026, 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. + +""" +Tests the `initial_values` contract of `build_problem`, i.e. how a carry-over +window from the previous block is turned into constraints of the next one: + +- only time-dependent variables are pinned — a time-independent variable + (`structure.time = False`) is left free in every block; +- a value with no `time` dimension is rejected outright. + +The pinned *window length*, which `carry-over-length` controls through the +session, is covered end to end in +`tests/e2e/functional/test_sequential_carry_over_length.py`. +""" + +import pytest +import xarray as xr + +from gems_craft.expression.expression import literal, param, var +from gems_craft.expression.indexing_structure import IndexingStructure +from gems_craft.model import Constraint, float_parameter, float_variable, model +from gems_craft.study import ConstantData, DataBase, Study, System, create_component +from gems_runner.simulation import TimeBlock, build_problem +from gems_runner.simulation.optimization import _validate_initial_values + +CONSTANT = IndexingStructure(False, False) + + +def _one_time_dependent_one_constant_study() -> Study: + """A single-component study whose model has one time-dependent variable + (`gen`) and one time-independent one (`cap`).""" + plant = model( + id="PLANT", + parameters=[float_parameter("cost", CONSTANT)], + variables=[ + float_variable("gen", lower_bound=literal(0), upper_bound=literal(10)), + float_variable( + "cap", + lower_bound=literal(0), + upper_bound=literal(10), + structure=CONSTANT, + ), + ], + constraints=[ + Constraint(name="Max generation", expression=var("gen") <= var("cap")) + ], + objective_contributions={ + "operational": (param("cost") * var("gen")).time_sum().expec() + }, + ) + database = DataBase() + database.add_data("P", "cost", ConstantData(1)) + system = System("carry_over_contract") + system.add_component(create_component(model=plant, id="P")) + return Study(system, database) + + +def _time_da(values: list[float]) -> xr.DataArray: + """Carry-over array in the shape `_extract_carry_over` produces: a `time` + dimension indexed 0..k-1.""" + return xr.DataArray( + values, dims=["time"], coords={"time": list(range(len(values)))} + ) + + +def test_carry_over_skips_time_independent_variables() -> None: + """Only time-dependent variables are pinned. A time-independent variable + (`structure.time = False`) is left free in every block even when the caller + passes a value for it, so consecutive blocks size it independently.""" + problem = build_problem( + _one_time_dependent_one_constant_study(), + TimeBlock(1, [0, 1, 2]), + [0], + initial_values={ + ("PLANT", "gen"): _time_da([2.0, 3.0]), + ("PLANT", "cap"): _time_da([7.0]), + }, + ) + + constraint_names = set(problem.linopy_model.constraints) + assert "carry_over__PLANT__gen" in constraint_names + assert "carry_over__PLANT__cap" not in constraint_names + + +def test_initial_values_without_time_dim_rejected() -> None: + """A value with no `time` dimension — the shape carried over before + multi-timestep stitching existed — is rejected outright rather than + silently reinterpreted as a single-timestep pin.""" + with pytest.raises(ValueError, match="must carry a 'time' dimension"): + build_problem( + _one_time_dependent_one_constant_study(), + TimeBlock(1, [0, 1, 2]), + [0], + initial_values={("PLANT", "gen"): xr.DataArray(2.0)}, + ) + + # The check is a precondition on the argument, so it does not need a study: + with pytest.raises(ValueError, match="must carry a 'time' dimension"): + _validate_initial_values({("PLANT", "gen"): xr.DataArray(2.0)}) + + assert _validate_initial_values(None) == {}