From b3f869506aef165e0c040cb63cada1d34dcf9939 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 15:15:19 +0000 Subject: [PATCH 01/20] feat(optim-config): add carry-over-length resolution setting Add 'carry-over-length' to ResolutionConfig (issue #271): optional, defaults to block-overlap via effective_carry_over_length, validated as 0 <= carry-over-length <= block-overlap with no special case at block-overlap == 0. Also add the previously missing range check 0 <= block-overlap < block-length. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018BsEZdPwCrguejkXxz33FS --- src/gems_craft/optim_config/parsing.py | 38 ++++ .../optim_config/test_resolution_config.py | 163 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 tests/unittests/gems_craft/optim_config/test_resolution_config.py diff --git a/src/gems_craft/optim_config/parsing.py b/src/gems_craft/optim_config/parsing.py index 69631e44..ba244a44 100644 --- a/src/gems_craft/optim_config/parsing.py +++ b/src/gems_craft/optim_config/parsing.py @@ -153,6 +153,7 @@ 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 +165,43 @@ 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 _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/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..c469da4d --- /dev/null +++ b/tests/unittests/gems_craft/optim_config/test_resolution_config.py @@ -0,0 +1,163 @@ +# 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 From 86abb805605e53c5c22d697ad5e88aa93b16bfed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 15:15:29 +0000 Subject: [PATCH 02/20] fix(runner): pin carry-over at matching absolute timesteps in sequential mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sequential carry-over always extracted block N's last timestep and pinned it to block N+1's first timestep, which only refers to the same absolute timestep when block-overlap == 1; for overlap >= 2 it silently stitched the wrong pair of timesteps (issue #271). _extract_carry_over now extracts effective_carry_over_length values starting at local index block_length - block_overlap (the earliest shared timestep), re-indexed to time 0..k-1, and Phase 5 pins the next block's first k timesteps against them. Scalar initial_values (no time dim) keep the legacy single-timestep pin for direct build_problem callers. Behavioural consequence: with block-overlap: 0 nothing is carried between blocks any more (the previous implicit single-timestep seeding is gone) — state continuity now requires block-overlap >= 1. The new e2e test fails against the previous runtime behaviour for all four of its cases and passes with this fix; existing block-overlap: 1 e2e tests pass unmodified. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018BsEZdPwCrguejkXxz33FS --- src/gems_runner/session/session.py | 29 ++- src/gems_runner/simulation/optimization.py | 30 ++- .../test_sequential_carry_over_length.py | 195 ++++++++++++++++++ 3 files changed, 241 insertions(+), 13 deletions(-) create mode 100644 tests/e2e/functional/test_sequential_carry_over_length.py diff --git a/src/gems_runner/session/session.py b/src/gems_runner/session/session.py index 9dc14849..2cf1df86 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,8 +112,13 @@ 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 ...`. carry_over = self._extract_carry_over( - problem, local_index=len(timesteps) - 1 + problem, + local_start=block_length - block_overlap, + length=carry_over_length, ) t_start += block_length - block_overlap block_id += 1 @@ -240,17 +246,28 @@ 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. + """ 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..fd297d4e 100644 --- a/src/gems_runner/simulation/optimization.py +++ b/src/gems_runner/simulation/optimization.py @@ -539,10 +539,22 @@ def build(self) -> OptimizationProblem: 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 "time" in init_val.dims: + # 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) + 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}", + ) + else: + # Scalar (no time dim): legacy form, pin the first timestep. + self.linopy_model.add_constraints( + linopy_var.isel(time=0) == init_val, # type: ignore[arg-type] + name=f"carry_over__{safe}", + ) # Extract constant objective contribution (linopy cannot hold pure constants). objective_constant = 0.0 @@ -992,9 +1004,13 @@ 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 is an ``xr.DataArray``; when it carries a ``time`` dimension of + length ``k`` (indexed ``0 .. k-1``), constraints + ``var[time=i] == value[i]`` are added for the block's first ``k`` + timesteps, overriding the cyclic border condition on that window. A + value without a ``time`` dimension pins only ``var[time=0]`` (legacy + single-timestep form). """ 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..0d495bf3 --- /dev/null +++ b/tests/e2e/functional/test_sequential_carry_over_length.py @@ -0,0 +1,195 @@ +# 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 (Issue #271). + +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 so that the storage state-of-charge trajectory varies +across block boundaries. + +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. +Before the fix for issue #271, the code pinned block N+1's first timestep to +block N's *last* timestep, which is a different absolute timestep whenever +block-overlap >= 2. + +The tests assert, from the merged simulation table (which keeps one row per +block for overlapping timesteps), that every pinned shared timestep carries +identical values in both blocks' solutions. +""" + +import shutil +import textwrap +from pathlib import Path + +import pandas as pd +import pytest + +from gems_runner.study.runner import run_study + +_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_12 = [0, 4, 2, 0, 4, 0, 2, 4, 0, 4, 4, 0] + +_BASE_CONFIG = textwrap.dedent("""\ + time-scope: + first-time-step: 0 + last-time-step: 11 + solver-options: + name: highs + logs: false + parameters: "" + scenario-scope: + include: + - 0 + models: + - id: rolling-horizon-lib.storage + out-of-bounds-processing: + constraints: + - id: soc_balance + mode: drop +""") + + +def _sequential_config(carry_over_length: str) -> str: + return _BASE_CONFIG + textwrap.dedent(f"""\ + resolution: + mode: sequential-subproblems + block-length: 6 + block-overlap: 3 + {carry_over_length} + """) + + +_OUTPUTS = [ + ("storage", "soc"), + ("storage", "charge"), + ("storage", "discharge"), + ("gen", "p"), + ("bus", "unsupplied"), +] + + +def _run(tmp_path: Path, name: str, config_yaml: str) -> pd.DataFrame: + study_dir = tmp_path / name + shutil.copytree(_STUDY_SRC, study_dir) + demand_path = study_dir / "input" / "data-series" / "demand.txt" + demand_path.write_text("\n".join(str(d) for d in _DEMAND_12) + "\n") + config_path = study_dir / "input" / "optim-config.yml" + config_path.write_text(config_yaml) + run_study(study_dir) + output_files = list((study_dir / "output").glob("**/simulation_table_*.csv")) + assert len(output_files) == 1 + return pd.read_csv(output_files[0]) + + +def _get_value( + raw: pd.DataFrame, block: int, component: str, output: str, timestep: int +) -> float: + mask = ( + (raw["block"] == block) + & (raw["component"] == component) + & (raw["output"] == output) + & (raw["absolute_time_index"] == timestep) + ) + rows = raw[mask] + 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"]) + + +def _shared_timesteps(raw: pd.DataFrame) -> dict: + """Map each consecutive block pair (n, n+1) to their shared absolute timesteps.""" + times_by_block = { + int(b): set(raw.loc[raw["block"] == b, "absolute_time_index"].dropna()) + for b in raw["block"].unique() + } + blocks = sorted(times_by_block) + return { + (n, n + 1): sorted(times_by_block[n] & times_by_block[n + 1]) + for n in blocks[:-1] + } + + +def _assert_pinned_window_consistent(raw: pd.DataFrame, carry_over_length: int) -> None: + """The first `carry_over_length` shared timesteps of each consecutive block + pair must have identical values in both blocks, for every output.""" + shared = _shared_timesteps(raw) + assert shared, "Expected at least two consecutive blocks" + for (block_n, block_n1), timesteps in shared.items(): + assert timesteps, f"Blocks {block_n} and {block_n1} share no timesteps" + for t in timesteps[:carry_over_length]: + for component, output in _OUTPUTS: + v_prev = _get_value(raw, block_n, component, output, int(t)) + v_next = _get_value(raw, block_n1, component, output, int(t)) + assert v_next == pytest.approx(v_prev, abs=1e-6), ( + f"Pinned timestep t={t} disagrees between block {block_n} " + f"({v_prev}) and block {block_n1} ({v_next}) for " + f"{component}.{output}" + ) + + +def test_full_pin_default(tmp_path: Path) -> None: + """Omitted carry-over-length defaults to block-overlap: the whole overlap + zone of every consecutive block pair is pinned to the earlier block's + values, timestep by absolute timestep.""" + raw = _run(tmp_path, "full_pin", _sequential_config("# carry-over-length omitted")) + + shared = _shared_timesteps(raw) + # block-length=6, block-overlap=3, t=0..11 → blocks [0..5], [3..8], + # [6..11], [9..11]; consecutive pairs share exactly 3 timesteps. + assert shared == { + (0, 1): [3, 4, 5], + (1, 2): [6, 7, 8], + (2, 3): [9, 10, 11], + } + _assert_pinned_window_consistent(raw, carry_over_length=3) + + +def test_explicit_full_pin(tmp_path: Path) -> None: + """carry-over-length equal to block-overlap behaves like the default.""" + raw = _run(tmp_path, "explicit_full", _sequential_config("carry-over-length: 3")) + _assert_pinned_window_consistent(raw, carry_over_length=3) + + +def test_partial_pin(tmp_path: Path) -> None: + """carry-over-length < block-overlap pins only the leading shared + timesteps; the rest of the overlap zone is re-optimized freely.""" + raw = _run(tmp_path, "partial_pin", _sequential_config("carry-over-length: 1")) + _assert_pinned_window_consistent(raw, carry_over_length=1) + + +def test_zero_carry_over(tmp_path: Path) -> None: + """Explicit carry-over-length: 0 disables stitching entirely: every block + is solved independently over its own window, and every timestep of the + horizon is still present in the output.""" + raw = _run(tmp_path, "zero_carry", _sequential_config("carry-over-length: 0")) + + timesteps = set(raw["absolute_time_index"].dropna().astype(int)) + assert timesteps == set(range(12)) From 70cf4864f8fa54d9b9c77d2d8274c4d5f0f410ca Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 15:15:35 +0000 Subject: [PATCH 03/20] docs(optim-config): document carry-over-length with stitching diagram Document the new 'carry-over-length' resolution setting: parameter table entry, expanded sequential-subproblems section with an annotated timeline diagram (block-length / block-overlap / carry-over-length), default and explicit-zero semantics, validation rules, and a breaking-change warning for block-overlap: 0 configs. Add the corresponding CHANGELOG entries. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018BsEZdPwCrguejkXxz33FS --- docs/CHANGELOG.md | 24 ++++++++++ docs/user-guide/optim-config.md | 81 +++++++++++++++++++++++++++++---- 2 files changed, 97 insertions(+), 8 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 80925bf7..56734e6b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,7 +4,31 @@ All notable changes to GemsPy are documented here. ## [Unreleased] +### Changed + +- **BREAKING — sequential mode carry-over is now bounded by `block-overlap`** + \- in `sequential-subproblems` mode, the carry-over that stitches + consecutive blocks together is now governed by a new `resolution` setting, + `carry-over-length` (default: `block-overlap`), and pins the *shared* + leading timesteps of each block to the previous block's values at the same + absolute timesteps. This 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. **Migration**: configs using `block-overlap: 0` (the + default) no longer get the previous implicit single-timestep state + continuity; if you rely on state carried between blocks (e.g. storage + state-of-charge), set `block-overlap: 1` (and optionally + `carry-over-length: 1`) to recover the previous behaviour. `block-overlap` + is also newly validated: `0 <= block-overlap < block-length`. + ### Added + +- **`carry-over-length` resolution setting** - optional int on the + `resolution` block (`ResolutionConfig.carry_over_length`), validated as + `0 <= carry-over-length <= block-overlap`. Omitted, it defaults to + `block-overlap` (the whole overlap zone is pinned); an explicit `0` is + legal and means blocks overlap for lag-constraint history but are not + stitched at all. - **Integer strategy and thermal heuristics** - components can now set `integer-strategy` (`exact` (default), `relaxed`, or `heuristic` + `heuristic-id`) to control how their model's integer/binary variables are diff --git a/docs/user-guide/optim-config.md b/docs/user-guide/optim-config.md index 9cc8ef82..54e72d60 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` | Shared timesteps between consecutive blocks; must satisfy `0 <= block-overlap < block-length` | +| `carry-over-length` | int | `block-overlap` | Sequential mode only: 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,81 @@ 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. + +!!! warning "Breaking change: `block-overlap: 0` no longer carries state" + Older GemsPy versions always carried a single timestep of state between + blocks, even with `block-overlap: 0`. Now the carry-over is bounded by + the overlap: a `block-overlap: 0` configuration carries **nothing** + between blocks. If you rely on state continuity (e.g. storage + state-of-charge), set `block-overlap: 1` (and optionally + `carry-over-length: 1`) to recover the previous behaviour. + ### `parallel-subproblems` From 670af95521a4a00f1a5ce2c722ee0a72c9bc069c Mon Sep 17 00:00:00 2001 From: "Antoine Oustry, PhD" <58943406+aoustry@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:19:27 +0200 Subject: [PATCH 04/20] Apply suggestion from @aoustry --- docs/user-guide/optim-config.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/docs/user-guide/optim-config.md b/docs/user-guide/optim-config.md index 54e72d60..39a09137 100644 --- a/docs/user-guide/optim-config.md +++ b/docs/user-guide/optim-config.md @@ -305,13 +305,6 @@ 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. -!!! warning "Breaking change: `block-overlap: 0` no longer carries state" - Older GemsPy versions always carried a single timestep of state between - blocks, even with `block-overlap: 0`. Now the carry-over is bounded by - the overlap: a `block-overlap: 0` configuration carries **nothing** - between blocks. If you rely on state continuity (e.g. storage - state-of-charge), set `block-overlap: 1` (and optionally - `carry-over-length: 1`) to recover the previous behaviour. ### `parallel-subproblems` From dca8015c5a056246e6bb3b4e2e8df99211f88b3b Mon Sep 17 00:00:00 2001 From: "Antoine Oustry, PhD" <58943406+aoustry@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:22:16 +0200 Subject: [PATCH 05/20] Update CHANGELOG.md --- docs/CHANGELOG.md | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 56734e6b..32b435da 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,20 +6,8 @@ All notable changes to GemsPy are documented here. ### Changed -- **BREAKING — sequential mode carry-over is now bounded by `block-overlap`** - \- in `sequential-subproblems` mode, the carry-over that stitches - consecutive blocks together is now governed by a new `resolution` setting, - `carry-over-length` (default: `block-overlap`), and pins the *shared* - leading timesteps of each block to the previous block's values at the same - absolute timesteps. This 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. **Migration**: configs using `block-overlap: 0` (the - default) no longer get the previous implicit single-timestep state - continuity; if you rely on state carried between blocks (e.g. storage - state-of-charge), set `block-overlap: 1` (and optionally - `carry-over-length: 1`) to recover the previous behaviour. `block-overlap` - is also newly validated: `0 <= block-overlap < block-length`. +- **Sequential mode carry-over length can now be controlled by the user through the parameter `carry-over-length` (default: `block-overlap`) `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. ### Added From 51f1aa1a2d73c9e67091e158768db1b9c60d92e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 15:23:34 +0000 Subject: [PATCH 06/20] docs(optim-config): mark block-overlap as sequential-mode only Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018BsEZdPwCrguejkXxz33FS --- docs/user-guide/optim-config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-guide/optim-config.md b/docs/user-guide/optim-config.md index 39a09137..ddf0698f 100644 --- a/docs/user-guide/optim-config.md +++ b/docs/user-guide/optim-config.md @@ -221,7 +221,7 @@ optimisation subproblems. |---|---|---|---| | `mode` | str | `"frontal"` | Resolution strategy (see below) | | `block-length` | int | — | Timesteps per window; required for windowed modes | -| `block-overlap` | int | `0` | Shared timesteps between consecutive blocks; must satisfy `0 <= block-overlap < block-length` | +| `block-overlap` | int | `0` | Sequential mode only: shared timesteps between consecutive blocks; must satisfy `0 <= block-overlap < block-length` | | `carry-over-length` | int | `block-overlap` | Sequential mode only: how many of the shared timesteps are pinned to the previous block's values; must satisfy `0 <= carry-over-length <= block-overlap` | ### `frontal` (default) From a3adfe9d5266906eb07a28560c4661e725989e1a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 15:31:51 +0000 Subject: [PATCH 07/20] test(runner): assert block-overlap 0 yields fully independent blocks With block-overlap: 0 no carry-over constraint is created: each block's initial state is free (block 1 serves its t=7 peak by pre-charging from a free SoC, which the old implicit single-timestep seeding made impossible), and the sequential result is identical to parallel-subproblems mode, which solves the same windows independently by construction. The test fails against the pre-#271 runtime behaviour. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018BsEZdPwCrguejkXxz33FS --- .../test_sequential_carry_over_length.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/e2e/functional/test_sequential_carry_over_length.py b/tests/e2e/functional/test_sequential_carry_over_length.py index 0d495bf3..e14e8318 100644 --- a/tests/e2e/functional/test_sequential_carry_over_length.py +++ b/tests/e2e/functional/test_sequential_carry_over_length.py @@ -193,3 +193,55 @@ def test_zero_carry_over(tmp_path: Path) -> None: timesteps = set(raw["absolute_time_index"].dropna().astype(int)) assert timesteps == set(range(12)) + + +def _no_overlap_config(mode: str) -> str: + return _BASE_CONFIG + textwrap.dedent(f"""\ + resolution: + mode: {mode} + block-length: 6 + block-overlap: 0 + """) + + +def test_zero_overlap_blocks_fully_independent(tmp_path: Path) -> None: + """With block-overlap: 0 nothing is carried between blocks: each block is + solved as if it were alone (no carry-over constraints). + + Older GemsPy versions implicitly seeded each block's first timestep with + the previous block's final state even at block-overlap: 0; that seeding is + gone (breaking change of issue #271). 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. Under the old implicit + seeding, SoC(t=6) was pinned to block 0's final SoC=0 and the generator + (p_max=2, fully used by demand=2 at t=6) could not recharge in time, + forcing 2 units of unserved energy at t=7. + - The whole solution is identical to parallel-subproblems mode, which + solves the same windows independently by construction. + """ + seq_raw = _run( + tmp_path, "seq_no_overlap", _no_overlap_config("sequential-subproblems") + ) + par_raw = _run(tmp_path, "parallel", _no_overlap_config("parallel-subproblems")) + + # block-length=6, block-overlap=0, t=0..11 → blocks [0..5] and [6..11] + # share no timesteps. + assert _shared_timesteps(seq_raw) == {(0, 1): []} + + # No carry-over constraint: block 1's initial SoC is free, the t=7 peak + # is fully served. + assert _get_value(seq_raw, 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" + + # Fully independent blocks: identical to parallel-subproblems mode + # (both modes enumerate the same windows with the same 0-based block ids). + for component, output in _OUTPUTS: + for t in range(12): + v_seq = _get_value(seq_raw, t // 6, component, output, t) + v_par = _get_value(par_raw, t // 6, 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}" + ) From f843245b6289a87a3c0ca97d0a6f7dd7cbfa00bb Mon Sep 17 00:00:00 2001 From: "Antoine Oustry, PhD" <58943406+aoustry@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:35:32 +0200 Subject: [PATCH 08/20] Update test_sequential_carry_over_length.py --- tests/e2e/functional/test_sequential_carry_over_length.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/e2e/functional/test_sequential_carry_over_length.py b/tests/e2e/functional/test_sequential_carry_over_length.py index e14e8318..1f76bac7 100644 --- a/tests/e2e/functional/test_sequential_carry_over_length.py +++ b/tests/e2e/functional/test_sequential_carry_over_length.py @@ -208,9 +208,7 @@ def test_zero_overlap_blocks_fully_independent(tmp_path: Path) -> None: """With block-overlap: 0 nothing is carried between blocks: each block is solved as if it were alone (no carry-over constraints). - Older GemsPy versions implicitly seeded each block's first timestep with - the previous block's final state even at block-overlap: 0; that seeding is - gone (breaking change of issue #271). Two complementary checks: + 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. Under the old implicit From 8c56495be9fc9579d85766676e45d7f8a0ec6b87 Mon Sep 17 00:00:00 2001 From: "Antoine Oustry, PhD" <58943406+aoustry@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:54:55 +0200 Subject: [PATCH 09/20] Update tests/e2e/functional/test_sequential_carry_over_length.py Co-authored-by: tbittar --- tests/e2e/functional/test_sequential_carry_over_length.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/e2e/functional/test_sequential_carry_over_length.py b/tests/e2e/functional/test_sequential_carry_over_length.py index 1f76bac7..416e0dd0 100644 --- a/tests/e2e/functional/test_sequential_carry_over_length.py +++ b/tests/e2e/functional/test_sequential_carry_over_length.py @@ -29,9 +29,6 @@ `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. -Before the fix for issue #271, the code pinned block N+1's first timestep to -block N's *last* timestep, which is a different absolute timestep whenever -block-overlap >= 2. The tests assert, from the merged simulation table (which keeps one row per block for overlapping timesteps), that every pinned shared timestep carries From 7e5a0a032268de7c93cfb74f06ad66e833759e8b Mon Sep 17 00:00:00 2001 From: "Antoine Oustry, PhD" <58943406+aoustry@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:57:11 +0200 Subject: [PATCH 10/20] Update carry_over extraction logic in session.py Refactor carry_over extraction to use updated t_start. --- src/gems_runner/session/session.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gems_runner/session/session.py b/src/gems_runner/session/session.py index 2cf1df86..2589113e 100644 --- a/src/gems_runner/session/session.py +++ b/src/gems_runner/session/session.py @@ -115,12 +115,12 @@ def _run_sequential(self) -> SimulationTable: # 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 ...`. + t_start += block_length - block_overlap carry_over = self._extract_carry_over( problem, - local_start=block_length - block_overlap, + local_start=t_start, length=carry_over_length, ) - t_start += block_length - block_overlap block_id += 1 return self._reduce(tables) From 4431711a213bc02d0a1806bed2a227a3092f92c6 Mon Sep 17 00:00:00 2001 From: "Antoine Oustry, PhD" <58943406+aoustry@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:00:26 +0200 Subject: [PATCH 11/20] undo change on computation of local_start for carry_over --- src/gems_runner/session/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gems_runner/session/session.py b/src/gems_runner/session/session.py index 2589113e..3e4531ed 100644 --- a/src/gems_runner/session/session.py +++ b/src/gems_runner/session/session.py @@ -118,7 +118,7 @@ def _run_sequential(self) -> SimulationTable: t_start += block_length - block_overlap carry_over = self._extract_carry_over( problem, - local_start=t_start, + local_start=block_length - block_overlap, length=carry_over_length, ) block_id += 1 From 11a59977b3b735310daf6c44345ac3ced13fa8d1 Mon Sep 17 00:00:00 2001 From: "Antoine Oustry, PhD" <58943406+aoustry@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:02:59 +0200 Subject: [PATCH 12/20] Modify E2E test description for clarity Updated the test description to remove issue reference. --- tests/e2e/functional/test_sequential_carry_over_length.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/e2e/functional/test_sequential_carry_over_length.py b/tests/e2e/functional/test_sequential_carry_over_length.py index 416e0dd0..0ed2fa3d 100644 --- a/tests/e2e/functional/test_sequential_carry_over_length.py +++ b/tests/e2e/functional/test_sequential_carry_over_length.py @@ -11,7 +11,7 @@ # This file is part of the Antares project. """ -E2E test: multi-timestep carry-over in sequential mode (Issue #271). +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 @@ -208,10 +208,7 @@ def test_zero_overlap_blocks_fully_independent(tmp_path: Path) -> None: 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. Under the old implicit - seeding, SoC(t=6) was pinned to block 0's final SoC=0 and the generator - (p_max=2, fully used by demand=2 at t=6) could not recharge in time, - forcing 2 units of unserved energy at t=7. + pre-charging its *free* initial storage state. - The whole solution is identical to parallel-subproblems mode, which solves the same windows independently by construction. """ From 84479d38991b6099e68df106267e1df882c55999 Mon Sep 17 00:00:00 2001 From: "Antoine Oustry, PhD" <58943406+aoustry@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:07:45 +0200 Subject: [PATCH 13/20] Refactor block timing calculations in session.py --- src/gems_runner/session/session.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/gems_runner/session/session.py b/src/gems_runner/session/session.py index 3e4531ed..8f6c622a 100644 --- a/src/gems_runner/session/session.py +++ b/src/gems_runner/session/session.py @@ -115,10 +115,11 @@ def _run_sequential(self) -> SimulationTable: # 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 ...`. - t_start += block_length - block_overlap + delta = block_length - block_overlap + t_start += delta carry_over = self._extract_carry_over( problem, - local_start=block_length - block_overlap, + local_start=delta, length=carry_over_length, ) block_id += 1 From c97b584a5a757a54f71fb21c86a2aac92707b505 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 10:21:16 +0000 Subject: [PATCH 14/20] feat(optim-config): reject block-overlap/carry-over-length outside sequential mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `block-overlap` and `carry-over-length` steer the stitching of consecutive blocks, which only exists in `sequential-subproblems`. They were silently dropped in the three other modes — most confusingly in `parallel-subproblems`, which does window the horizon but ignores the overlap — while the user guide already documented them as sequential-only. A new `ResolutionConfig` validator now rejects either key when the mode is not sequential. The check is on the keys the user actually wrote (`model_fields_set`), so an explicit `block-overlap: 0` is caught too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014b5oL6qL5BVN6MWwBLcJxc --- docs/user-guide/optim-config.md | 4 +- src/gems_craft/optim_config/parsing.py | 26 ++++++++ .../test_sequential_carry_over_length.py | 8 ++- .../optim_config/test_resolution_config.py | 66 +++++++++++++++++++ 4 files changed, 99 insertions(+), 5 deletions(-) diff --git a/docs/user-guide/optim-config.md b/docs/user-guide/optim-config.md index ddf0698f..25537693 100644 --- a/docs/user-guide/optim-config.md +++ b/docs/user-guide/optim-config.md @@ -221,8 +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` | Sequential mode only: shared timesteps between consecutive blocks; must satisfy `0 <= block-overlap < block-length` | -| `carry-over-length` | int | `block-overlap` | Sequential mode only: how many of the shared timesteps are pinned to the previous block's values; must satisfy `0 <= carry-over-length <= block-overlap` | +| `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) diff --git a/src/gems_craft/optim_config/parsing.py b/src/gems_craft/optim_config/parsing.py index ba244a44..a94fc82b 100644 --- a/src/gems_craft/optim_config/parsing.py +++ b/src/gems_craft/optim_config/parsing.py @@ -149,6 +149,9 @@ 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 @@ -165,6 +168,29 @@ 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: diff --git a/tests/e2e/functional/test_sequential_carry_over_length.py b/tests/e2e/functional/test_sequential_carry_over_length.py index 0ed2fa3d..28b3bfa6 100644 --- a/tests/e2e/functional/test_sequential_carry_over_length.py +++ b/tests/e2e/functional/test_sequential_carry_over_length.py @@ -193,12 +193,14 @@ def test_zero_carry_over(tmp_path: Path) -> None: def _no_overlap_config(mode: str) -> str: + # `block-overlap` is sequential-only and rejected in other modes; parallel + # partitions the horizon by construction, which is the same window layout. + overlap = " block-overlap: 0\n" if mode == "sequential-subproblems" else "" return _BASE_CONFIG + textwrap.dedent(f"""\ resolution: mode: {mode} block-length: 6 - block-overlap: 0 - """) + """) + overlap def test_zero_overlap_blocks_fully_independent(tmp_path: Path) -> None: @@ -208,7 +210,7 @@ def test_zero_overlap_blocks_fully_independent(tmp_path: Path) -> None: 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. + pre-charging its *free* initial storage state. - The whole solution is identical to parallel-subproblems mode, which solves the same windows independently by construction. """ diff --git a/tests/unittests/gems_craft/optim_config/test_resolution_config.py b/tests/unittests/gems_craft/optim_config/test_resolution_config.py index c469da4d..2cd01598 100644 --- a/tests/unittests/gems_craft/optim_config/test_resolution_config.py +++ b/tests/unittests/gems_craft/optim_config/test_resolution_config.py @@ -161,3 +161,69 @@ def test_carry_over_length_equal_to_overlap_accepted() -> None: 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 From 90326353c48ca7ac6fb57cd0b2cb7bccfa73a6b2 Mon Sep 17 00:00:00 2001 From: "Antoine Oustry, PhD" <58943406+aoustry@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:50:45 +0200 Subject: [PATCH 15/20] [GP-02] Carry-over: remove the dead scalar branch, enforce the initial_values contract (#282) * refactor(runner): drop the dead scalar carry-over branch, enforce the contract --- docs/user-guide/optim-config.md | 18 ++++ src/gems_runner/session/session.py | 5 + src/gems_runner/simulation/optimization.py | 72 +++++++++------ .../test_sequential_carry_over_length.py | 92 +++++++++++++++++++ 4 files changed, 161 insertions(+), 26 deletions(-) diff --git a/docs/user-guide/optim-config.md b/docs/user-guide/optim-config.md index 25537693..8e5733f9 100644 --- a/docs/user-guide/optim-config.md +++ b/docs/user-guide/optim-config.md @@ -305,6 +305,24 @@ 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_runner/session/session.py b/src/gems_runner/session/session.py index 8f6c622a..66e56918 100644 --- a/src/gems_runner/session/session.py +++ b/src/gems_runner/session/session.py @@ -257,6 +257,11 @@ def _extract_carry_over( 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 length <= 0 or problem.linopy_model.solution is None: diff --git a/src/gems_runner/simulation/optimization.py b/src/gems_runner/simulation/optimization.py index fd297d4e..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,27 +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("-", "_") - if "time" in init_val.dims: - # 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) - 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}", - ) - else: - # Scalar (no time dim): legacy form, pin the first timestep. - 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 @@ -1005,12 +1024,13 @@ def build_problem( Label for the linopy model. initial_values: Optional carry-over values keyed by ``(model_id, var_name)``. Each - value is an ``xr.DataArray``; when it carries a ``time`` dimension of - length ``k`` (indexed ``0 .. k-1``), constraints - ``var[time=i] == value[i]`` are added for the block's first ``k`` + 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 pins only ``var[time=0]`` (legacy - single-timestep form). + 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 index 28b3bfa6..34b14be8 100644 --- a/tests/e2e/functional/test_sequential_carry_over_length.py +++ b/tests/e2e/functional/test_sequential_carry_over_length.py @@ -41,8 +41,15 @@ import pandas as pd import pytest +import xarray as xr +from gems_craft.expression.expression import literal, param, var +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 from gems_runner.study.runner import run_study +from tests.e2e.functional.libs.standard import CONSTANT _STUDY_SRC = Path(__file__).parent / "studies" / "rolling_horizon_suboptimality" @@ -239,3 +246,88 @@ def test_zero_overlap_blocks_fully_independent(tmp_path: Path) -> None: f"sequential (block-overlap: 0) and parallel modes disagree at " f"t={t} for {component}.{output}: {v_seq} != {v_par}" ) + + +# --------------------------------------------------------------------------- +# `initial_values` contract, at the `build_problem` level +# +# The tests above are run-level: they observe the pinned values, not the +# constraints that produced them. These two build a problem directly to check +# *which* variables the carry-over pins, and that a malformed `initial_values` +# is rejected before anything is built. +# --------------------------------------------------------------------------- + + +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) == {} From 7e5d0a87a90ca65c3d2fa0f3179d69144a90984c Mon Sep 17 00:00:00 2001 From: "Antoine Oustry, PhD" <58943406+aoustry@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:37:08 +0200 Subject: [PATCH 16/20] test(carry-over): assert the pinned constraint window, not the values (#283) * test(carry-over): assert the pinned constraint window, not the values Replace the four value-comparison tests of `carry-over-length` with one parametrized test that checks what the setting actually controls: which variables are fixed by a carry-over equality constraint and which are left free. --- .../test_sequential_carry_over_length.py | 248 +++++++----------- .../test_carry_over_initial_values.py | 111 ++++++++ 2 files changed, 211 insertions(+), 148 deletions(-) create mode 100644 tests/unittests/gems_runner/simulation/test_carry_over_initial_values.py diff --git a/tests/e2e/functional/test_sequential_carry_over_length.py b/tests/e2e/functional/test_sequential_carry_over_length.py index 34b14be8..8178a68e 100644 --- a/tests/e2e/functional/test_sequential_carry_over_length.py +++ b/tests/e2e/functional/test_sequential_carry_over_length.py @@ -15,8 +15,7 @@ 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 so that the storage state-of-charge trajectory varies -across block boundaries. +12-step demand series. Sequential mode with block-length=6, block-overlap=3 over t=0..11: @@ -30,26 +29,26 @@ N's already-solved values — counted from the *earliest* shared timestep, so each pinned constraint matches the same absolute timestep in both blocks. -The tests assert, from the merged simulation table (which keeps one row per -block for overlapping timesteps), that every pinned shared timestep carries -identical values in both blocks' solutions. +`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. """ import shutil import textwrap from pathlib import Path +from typing import Any, List, Set import pandas as pd import pytest -import xarray as xr -from gems_craft.expression.expression import literal, param, var -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 +from gems_craft.optim_config.parsing import load_optim_config +from gems_craft.study.folder import load_study +from gems_runner.session.session import SimulationSession +from gems_runner.simulation import TimeBlock +from gems_runner.simulation.optimization import OptimizationProblem from gems_runner.study.runner import run_study -from tests.e2e.functional.libs.standard import CONSTANT _STUDY_SRC = Path(__file__).parent / "studies" / "rolling_horizon_suboptimality" @@ -79,12 +78,16 @@ """) +_BLOCK_LENGTH = 6 +_BLOCK_OVERLAP = 3 + + def _sequential_config(carry_over_length: str) -> str: return _BASE_CONFIG + textwrap.dedent(f"""\ resolution: mode: sequential-subproblems - block-length: 6 - block-overlap: 3 + block-length: {_BLOCK_LENGTH} + block-overlap: {_BLOCK_OVERLAP} {carry_over_length} """) @@ -141,62 +144,96 @@ def _shared_timesteps(raw: pd.DataFrame) -> dict: } -def _assert_pinned_window_consistent(raw: pd.DataFrame, carry_over_length: int) -> None: - """The first `carry_over_length` shared timesteps of each consecutive block - pair must have identical values in both blocks, for every output.""" - shared = _shared_timesteps(raw) - assert shared, "Expected at least two consecutive blocks" - for (block_n, block_n1), timesteps in shared.items(): - assert timesteps, f"Blocks {block_n} and {block_n1} share no timesteps" - for t in timesteps[:carry_over_length]: - for component, output in _OUTPUTS: - v_prev = _get_value(raw, block_n, component, output, int(t)) - v_next = _get_value(raw, block_n1, component, output, int(t)) - assert v_next == pytest.approx(v_prev, abs=1e-6), ( - f"Pinned timestep t={t} disagrees between block {block_n} " - f"({v_prev}) and block {block_n1} ({v_next}) for " - f"{component}.{output}" - ) - - -def test_full_pin_default(tmp_path: Path) -> None: - """Omitted carry-over-length defaults to block-overlap: the whole overlap - zone of every consecutive block pair is pinned to the earlier block's - values, timestep by absolute timestep.""" - raw = _run(tmp_path, "full_pin", _sequential_config("# carry-over-length omitted")) - - shared = _shared_timesteps(raw) - # block-length=6, block-overlap=3, t=0..11 → blocks [0..5], [3..8], - # [6..11], [9..11]; consecutive pairs share exactly 3 timesteps. - assert shared == { - (0, 1): [3, 4, 5], - (1, 2): [6, 7, 8], - (2, 3): [9, 10, 11], - } - _assert_pinned_window_consistent(raw, carry_over_length=3) +def _run_sequential_session( + tmp_path: Path, name: str, config_yaml: str +) -> List[OptimizationProblem]: + """Run the study through a `SimulationSession` and return the solved + problems, one per block, in solve order. + + `run_study` drops them; the session hands each one back + (`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. + """ + study_dir = tmp_path / name + shutil.copytree(_STUDY_SRC, study_dir) + demand_path = study_dir / "input" / "data-series" / "demand.txt" + demand_path.write_text("\n".join(str(d) for d in _DEMAND_12) + "\n") + config_path = study_dir / "input" / "optim-config.yml" + config_path.write_text(config_yaml) + optim_config = load_optim_config(config_path) + assert optim_config is not None + session = SimulationSession(load_study(study_dir), optim_config) -def test_explicit_full_pin(tmp_path: Path) -> None: - """carry-over-length equal to block-overlap behaves like the default.""" - raw = _run(tmp_path, "explicit_full", _sequential_config("carry-over-length: 3")) - _assert_pinned_window_consistent(raw, carry_over_length=3) + problems: List[OptimizationProblem] = [] + run_block = session._run_block + def spy(block: TimeBlock, **kwargs: Any) -> Any: + problem, table = run_block(block, **kwargs) + problems.append(problem) + return problem, table -def test_partial_pin(tmp_path: Path) -> None: - """carry-over-length < block-overlap pins only the leading shared - timesteps; the rest of the overlap zone is re-optimized freely.""" - raw = _run(tmp_path, "partial_pin", _sequential_config("carry-over-length: 1")) - _assert_pinned_window_consistent(raw, carry_over_length=1) + session._run_block = spy # type: ignore[assignment] + session.run() + return problems -def test_zero_carry_over(tmp_path: Path) -> None: - """Explicit carry-over-length: 0 disables stitching entirely: every block - is solved independently over its own window, and every timestep of the - horizon is still present in the output.""" - raw = _run(tmp_path, "zero_carry", _sequential_config("carry-over-length: 0")) +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__") + } - timesteps = set(raw["absolute_time_index"].dropna().astype(int)) - assert timesteps == set(range(12)) + +@pytest.mark.parametrize( + "setting, expected", + [ + # Omitted resolves to `block-overlap`: the whole overlap zone is pinned. + ("# carry-over-length omitted", _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( + tmp_path: Path, setting: str, 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 = _run_sequential_session( + tmp_path, f"carry_over_{expected}", _sequential_config(setting) + ) + # 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) + if expected == 0: + assert not windows, ( + f"Block {block_id}: 'carry-over-length: 0' must leave the whole " + f"overlap zone free, found constraints fixing {sorted(windows)} " + f"timestep(s)" + ) + else: + assert windows == {expected}, ( + f"Block {block_id}: every carry-over constraint must fix the " + f"{expected} leading timesteps, found {sorted(windows)}" + ) def _no_overlap_config(mode: str) -> str: @@ -246,88 +283,3 @@ def test_zero_overlap_blocks_fully_independent(tmp_path: Path) -> None: f"sequential (block-overlap: 0) and parallel modes disagree at " f"t={t} for {component}.{output}: {v_seq} != {v_par}" ) - - -# --------------------------------------------------------------------------- -# `initial_values` contract, at the `build_problem` level -# -# The tests above are run-level: they observe the pinned values, not the -# constraints that produced them. These two build a problem directly to check -# *which* variables the carry-over pins, and that a malformed `initial_values` -# is rejected before anything is built. -# --------------------------------------------------------------------------- - - -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) == {} 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) == {} From 679b2d98f7643d2a7ca523cd7287cd7f711fb874 Mon Sep 17 00:00:00 2001 From: "Antoine Oustry, PhD" <58943406+aoustry@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:44:15 +0200 Subject: [PATCH 17/20] test(carry-over): drop the study-folder entry point from the e2e test (#286) The two tests copied the study to tmp_path, rewrote demand.txt and optim-config.yml, then either re-loaded the copy or went through run_study and globbed the exported CSV back in. None of that adds coverage here: SimulationTable.data is the DataFrame to_csv writes, the folder-to-CSV path is covered by test_study_from_folder.py, and the kebab-case parsing of carry-over-length is covered by the ResolutionConfig unit tests. - Load the study once (lru_cache) and override the demand series in memory, as the thermal-heuristic e2e tests already do. - Build OptimConfig objects instead of templating YAML. Unset vs explicit 0 still works: the validators key off model_fields_set, which pydantic populates the same way for direct construction. This also removes the string-concat special case that kept block-overlap out of the parallel config. - Merge the two near-identical runner helpers (_run and _run_sequential_session) into one _solve() returning both the result table and the solved problems. - Drop _shared_timesteps: its single assertion only restated the config. Same assertions, same four parametrized cases; 285 -> 234 lines. --- .../test_sequential_carry_over_length.py | 278 ++++++++---------- 1 file changed, 120 insertions(+), 158 deletions(-) diff --git a/tests/e2e/functional/test_sequential_carry_over_length.py b/tests/e2e/functional/test_sequential_carry_over_length.py index 8178a68e..7c28af7c 100644 --- a/tests/e2e/functional/test_sequential_carry_over_length.py +++ b/tests/e2e/functional/test_sequential_carry_over_length.py @@ -15,7 +15,9 @@ 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. +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: @@ -35,20 +37,30 @@ re-optimize to the same value from a fixed one. """ -import shutil -import textwrap +from functools import lru_cache from pathlib import Path -from typing import Any, List, Set +from typing import Any, Dict, List, Set, Tuple import pandas as pd import pytest -from gems_craft.optim_config.parsing import load_optim_config +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 import TimeBlock from gems_runner.simulation.optimization import OptimizationProblem -from gems_runner.study.runner import run_study +from gems_runner.simulation.simulation_table import SimulationTable _STUDY_SRC = Path(__file__).parent / "studies" / "rolling_horizon_suboptimality" @@ -56,127 +68,67 @@ # 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_12 = [0, 4, 2, 0, 4, 0, 2, 4, 0, 4, 4, 0] - -_BASE_CONFIG = textwrap.dedent("""\ - time-scope: - first-time-step: 0 - last-time-step: 11 - solver-options: - name: highs - logs: false - parameters: "" - scenario-scope: - include: - - 0 - models: - - id: rolling-horizon-lib.storage - out-of-bounds-processing: - constraints: - - id: soc_balance - mode: drop -""") - - +_DEMAND = [0, 4, 2, 0, 4, 0, 2, 4, 0, 4, 4, 0] _BLOCK_LENGTH = 6 _BLOCK_OVERLAP = 3 -def _sequential_config(carry_over_length: str) -> str: - return _BASE_CONFIG + textwrap.dedent(f"""\ - resolution: - mode: sequential-subproblems - block-length: {_BLOCK_LENGTH} - block-overlap: {_BLOCK_OVERLAP} - {carry_over_length} - """) - - -_OUTPUTS = [ - ("storage", "soc"), - ("storage", "charge"), - ("storage", "discharge"), - ("gen", "p"), - ("bus", "unsupplied"), -] - - -def _run(tmp_path: Path, name: str, config_yaml: str) -> pd.DataFrame: - study_dir = tmp_path / name - shutil.copytree(_STUDY_SRC, study_dir) - demand_path = study_dir / "input" / "data-series" / "demand.txt" - demand_path.write_text("\n".join(str(d) for d in _DEMAND_12) + "\n") - config_path = study_dir / "input" / "optim-config.yml" - config_path.write_text(config_yaml) - run_study(study_dir) - output_files = list((study_dir / "output").glob("**/simulation_table_*.csv")) - assert len(output_files) == 1 - return pd.read_csv(output_files[0]) - - -def _get_value( - raw: pd.DataFrame, block: int, component: str, output: str, timestep: int -) -> float: - mask = ( - (raw["block"] == block) - & (raw["component"] == component) - & (raw["output"] == output) - & (raw["absolute_time_index"] == timestep) +@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)) ) - rows = raw[mask] - 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"]) + return study -def _shared_timesteps(raw: pd.DataFrame) -> dict: - """Map each consecutive block pair (n, n+1) to their shared absolute timesteps.""" - times_by_block = { - int(b): set(raw.loc[raw["block"] == b, "absolute_time_index"].dropna()) - for b in raw["block"].unique() - } - blocks = sorted(times_by_block) - return { - (n, n + 1): sorted(times_by_block[n] & times_by_block[n + 1]) - for n in blocks[:-1] - } +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 _run_sequential_session( - tmp_path: Path, name: str, config_yaml: str -) -> List[OptimizationProblem]: - """Run the study through a `SimulationSession` and return the solved - problems, one per block, in solve order. +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. - `run_study` drops them; the session hands each one back - (`SimulationSession._run_block` returns the solved problem for carry-over - extraction *or inspection*), which is what gives the test access to the + `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. """ - study_dir = tmp_path / name - shutil.copytree(_STUDY_SRC, study_dir) - demand_path = study_dir / "input" / "data-series" / "demand.txt" - demand_path.write_text("\n".join(str(d) for d in _DEMAND_12) + "\n") - config_path = study_dir / "input" / "optim-config.yml" - config_path.write_text(config_yaml) - - optim_config = load_optim_config(config_path) - assert optim_config is not None - session = SimulationSession(load_study(study_dir), optim_config) - + session = SimulationSession(_study(), config) problems: List[OptimizationProblem] = [] run_block = session._run_block - def spy(block: TimeBlock, **kwargs: Any) -> Any: - problem, table = run_block(block, **kwargs) + 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] - session.run() - return problems + return session.run(), problems def _pinned_window_lengths(problem: OptimizationProblem) -> Set[int]: @@ -190,18 +142,35 @@ def _pinned_window_lengths(problem: OptimizationProblem) -> Set[int]: } +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( - "setting, expected", + "carry_over, expected", [ # Omitted resolves to `block-overlap`: the whole overlap zone is pinned. - ("# carry-over-length omitted", _BLOCK_OVERLAP), - ("carry-over-length: 0", 0), - ("carry-over-length: 1", 1), - ("carry-over-length: 2", 2), + ({}, _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( - tmp_path: Path, setting: str, expected: int + 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 @@ -210,44 +179,30 @@ def test_carry_over_length_fixes_that_many_leading_timesteps( `k = 0` fixes nothing at all: the blocks still overlap (so lag constraints keep their history) but are not stitched. """ - problems = _run_sequential_session( - tmp_path, f"carry_over_{expected}", _sequential_config(setting) + _, 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) - if expected == 0: - assert not windows, ( - f"Block {block_id}: 'carry-over-length: 0' must leave the whole " - f"overlap zone free, found constraints fixing {sorted(windows)} " - f"timestep(s)" - ) - else: - assert windows == {expected}, ( - f"Block {block_id}: every carry-over constraint must fix the " - f"{expected} leading timesteps, found {sorted(windows)}" - ) + 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 _no_overlap_config(mode: str) -> str: - # `block-overlap` is sequential-only and rejected in other modes; parallel - # partitions the horizon by construction, which is the same window layout. - overlap = " block-overlap: 0\n" if mode == "sequential-subproblems" else "" - return _BASE_CONFIG + textwrap.dedent(f"""\ - resolution: - mode: {mode} - block-length: 6 - """) + overlap - - -def test_zero_overlap_blocks_fully_independent(tmp_path: Path) -> None: +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). @@ -256,29 +211,36 @@ def test_zero_overlap_blocks_fully_independent(tmp_path: Path) -> None: - 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. + solves the same windows independently by construction (and where + `block-overlap` is not accepted at all). """ - seq_raw = _run( - tmp_path, "seq_no_overlap", _no_overlap_config("sequential-subproblems") + 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) ) - par_raw = _run(tmp_path, "parallel", _no_overlap_config("parallel-subproblems")) - - # block-length=6, block-overlap=0, t=0..11 → blocks [0..5] and [6..11] - # share no timesteps. - assert _shared_timesteps(seq_raw) == {(0, 1): []} - # No carry-over constraint: block 1's initial SoC is free, the t=7 peak - # is fully served. - assert _get_value(seq_raw, 1, "bus", "unsupplied", 7) == pytest.approx( + 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" - # Fully independent blocks: identical to parallel-subproblems mode - # (both modes enumerate the same windows with the same 0-based block ids). - for component, output in _OUTPUTS: - for t in range(12): - v_seq = _get_value(seq_raw, t // 6, component, output, t) - v_par = _get_value(par_raw, t // 6, component, output, t) + # 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}" From bfe968c54f22d48a0d1e46980bc51739631a4f1d Mon Sep 17 00:00:00 2001 From: "Antoine Oustry, PhD" <58943406+aoustry@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:52:10 +0200 Subject: [PATCH 18/20] Remove 'carry-over-length' setting details Removed details about the 'carry-over-length' resolution setting from the changelog. --- docs/CHANGELOG.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 96c9c4a7..0aa419d5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,12 +13,6 @@ All notable changes to GemsPy are documented here. ### Added -- **`carry-over-length` resolution setting** - optional int on the - `resolution` block (`ResolutionConfig.carry_over_length`), validated as - `0 <= carry-over-length <= block-overlap`. Omitted, it defaults to - `block-overlap` (the whole overlap zone is pinned); an explicit `0` is - legal and means blocks overlap for lag-constraint history but are not - stitched at all. - **Integer strategy and thermal heuristics** - components can now set `integer-strategy` (`exact` (default), `relaxed`, or `heuristic` + `heuristic-id`) to control how their model's integer/binary variables are From 5fd54946ee08d76f06a7476aef88a5d4239335bd Mon Sep 17 00:00:00 2001 From: "Antoine Oustry, PhD" <58943406+aoustry@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:53:28 +0200 Subject: [PATCH 19/20] remove blank lines --- docs/CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0aa419d5..ff25c365 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,14 +5,12 @@ 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`) `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). ### Added - - **Integer strategy and thermal heuristics** - components can now set `integer-strategy` (`exact` (default), `relaxed`, or `heuristic` + `heuristic-id`) to control how their model's integer/binary variables are From 23bd3112b12d6200e294a49996bc4a6f18ea5449 Mon Sep 17 00:00:00 2001 From: "Antoine Oustry, PhD" <58943406+aoustry@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:54:15 +0200 Subject: [PATCH 20/20] Remove duplicated word --- docs/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ff25c365..985c616c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,7 +5,7 @@ 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`) `block-overlap`**. This also fixes an incorrect stitching for `block-overlap >= 2`, where the previous hardcoded behaviour pinned block +- **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).