Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b3f8695
feat(optim-config): add carry-over-length resolution setting
claude Aug 14, 2026
86abb80
fix(runner): pin carry-over at matching absolute timesteps in sequent…
claude Aug 14, 2026
70cf486
docs(optim-config): document carry-over-length with stitching diagram
claude Aug 14, 2026
670af95
Apply suggestion from @aoustry
aoustry Aug 14, 2026
dca8015
Update CHANGELOG.md
aoustry Aug 14, 2026
51f1aa1
docs(optim-config): mark block-overlap as sequential-mode only
claude Aug 14, 2026
a3adfe9
test(runner): assert block-overlap 0 yields fully independent blocks
claude Aug 14, 2026
f843245
Update test_sequential_carry_over_length.py
aoustry Aug 14, 2026
8c56495
Update tests/e2e/functional/test_sequential_carry_over_length.py
aoustry Aug 19, 2026
7e5a0a0
Update carry_over extraction logic in session.py
aoustry Aug 19, 2026
4431711
undo change on computation of local_start for carry_over
aoustry Aug 19, 2026
11a5997
Modify E2E test description for clarity
aoustry Aug 19, 2026
84479d3
Refactor block timing calculations in session.py
aoustry Aug 19, 2026
c97b584
feat(optim-config): reject block-overlap/carry-over-length outside se…
claude Aug 19, 2026
9032635
[GP-02] Carry-over: remove the dead scalar branch, enforce the initia…
aoustry Aug 19, 2026
7e5d0a8
test(carry-over): assert the pinned constraint window, not the values…
aoustry Aug 19, 2026
679b2d9
test(carry-over): drop the study-folder entry point from the e2e test…
aoustry Aug 21, 2026
7a7b8ba
Merge branch 'main' into claude/issue-271-optimization-config-r9i6ns
aoustry Aug 21, 2026
bfe968c
Remove 'carry-over-length' setting details
aoustry Aug 24, 2026
5fd5494
remove blank lines
aoustry Aug 24, 2026
23bd311
Remove duplicated word
aoustry Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ All notable changes to GemsPy are documented here.
## [Unreleased]

### Changed
- **Sequential mode carry-over length can now be controlled by the user through the parameter `carry-over-length` (default: `block-overlap`). This also fixes an incorrect stitching for `block-overlap >= 2`, where the previous hardcoded behaviour pinned block
*N+1*'s first timestep to block *N*'s **last** timestep — a different absolute timestep.
- **linopy upgraded to `>=0.9.0`** - the minimum supported Python version rises
to **3.11** accordingly (linopy 0.9 requires Python >= 3.11).

Expand Down
92 changes: 84 additions & 8 deletions docs/user-guide/optim-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -220,7 +221,8 @@ optimisation subproblems.
|---|---|---|---|
| `mode` | str | `"frontal"` | Resolution strategy (see below) |
| `block-length` | int | — | Timesteps per window; required for windowed modes |
| `block-overlap` | int | `0` | Extra overlap timesteps between consecutive blocks |
| `block-overlap` | int | `0` | Sequential mode only (rejected in other modes): shared timesteps between consecutive blocks; must satisfy `0 <= block-overlap < block-length` |
| `carry-over-length` | int | `block-overlap` | Sequential mode only (rejected in other modes): how many of the shared timesteps are pinned to the previous block's values; must satisfy `0 <= carry-over-length <= block-overlap` |

### `frontal` (default)

Expand All @@ -236,18 +238,92 @@ Produces globally optimal results.

### `sequential-subproblems`

The horizon is split into non-overlapping (or slightly overlapping) windows of
`block-length` timesteps. Blocks are solved **one after the other**; the state
of inter-block dynamics (e.g. storage level) is carried over from one block to
the next.
The horizon is split into windows of `block-length` timesteps, each starting
`block-length - block-overlap` timesteps after the previous one. Blocks are
solved **one after the other**; the state of inter-block dynamics (e.g. storage
level) is carried over from one block to the next by pinning the leading
timesteps of each block to the values the previous block already computed.
Comment thread
aoustry marked this conversation as resolved.

~~~ yaml
resolution:
mode: sequential-subproblems
block-length: 168 # one week
block-overlap: 0
block-length: 168 # one week
block-overlap: 24 # one day shared between consecutive blocks
carry-over-length: 24 # optional; omitted → defaults to block-overlap (full pin)
# 0 is legal and explicit: overlap solved twice, no stitching
~~~

Three parameters shape the stitching between consecutive blocks. Illustrative
example with `block-length: 10`, `block-overlap: 4`, `carry-over-length: 3`
(a partial pin, so all three parameters are visible at once):

~~~ text
abs t 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Block N 0 1 2 3 4 5 6 7 8 9
└──────────────────────────────────────┘
block-length = 10

Block N+1 0 1 2 3 4 5 6 7 8 9
└──────────────────────────────────────┘
block-length = 10

|------------| overlap = 4 (t=6..9: solved by BOTH blocks)
|========| carry-over = 3 (t=6..8: PINNED to block N's value)
^ t=9: still shared, but free in N+1 (re-optimized)
~~~

Reading it:

- **`block-length`** — width of each block's own window (10 for both here).
- **`block-overlap`** — how far block *N+1*'s start reaches back into block
*N*'s window (4 → t=6..9 exist in both solves). The overlap gives block
*N+1* real historical values for lag-dependent constraints (e.g. a storage
balance using `soc[t-1]`, or min up/down durations spanning several hours).
- **`carry-over-length`** — how many of those *shared* leading timesteps of
block *N+1* get hard-pinned (`var[t] == value from block N`) to block *N*'s
already-solved values, counted from the earliest shared timestep (t=6), not
from t=9. Here `carry-over-length: 3 < overlap: 4`, so t=6,7,8 are frozen
but t=9 is left free — an MPC-style partial pin where the optimizer may
revise the tail of the overlap with more lookback context.

Defaults and special values:

- **Omitted** `carry-over-length` resolves to `block-overlap`: the whole
overlap zone is pinned. This is the right default when the overlap exists
to provide history for lag-dependent constraints without re-litigating
decisions the previous block already made.
- **Explicit `carry-over-length: 0`** is legal and distinct from omitting the
field: blocks overlap for lag-constraint history, but no timestep is pinned
— block *N+1* re-solves the whole overlap window independently.
- Validation requires `0 <= carry-over-length <= block-overlap` (and
`0 <= block-overlap < block-length`), with no special case at
`block-overlap: 0`.

Overlapping timesteps appear once per block in the simulation table, tagged
with the `block` column — nothing is lost or silently merged. Downstream
tooling decides which block's version of a shared timestep is authoritative;
`carry-over-length` only controls how much two consecutive blocks may
*disagree* on that shared window.

**What the carry-over pins.** The mechanism is plain *variable fixing*: for
block *N+1*, every time-dependent variable whose block-relative timestep falls
in `[0, carry-over-length[` is fixed to the value block *N* computed for the
**same absolute timestep**. Two consequences are worth spelling out:

- It is **not** an initial-condition mechanism. Block *N+1*'s problem is not
given the value of the timestep *preceding* its window, so a `t-1` time-shift
operator at the block's first timestep still resolves against that block's own
border condition (cyclic by default) rather than reaching into block *N*.
- It applies to **all** time-dependent variables of all models, not only
state-like ones such as a storage level. Finer, per-model granularity can be
added later if a use case needs it.

**Time-independent** variables (`structure.time = False`, e.g. an investment
capacity) are never carried over — nothing links their values across blocks, so
each block sizes them independently. Sequential mode is therefore not suited to
investment problems; use `frontal` or `benders-decomposition` for those.



### `parallel-subproblems`

Expand Down
64 changes: 64 additions & 0 deletions src/gems_craft/optim_config/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,14 @@ class ResolutionMode(str, Enum):
BENDERS_DECOMPOSITION = "benders-decomposition"


_SEQUENTIAL_ONLY_FIELDS = ("block_overlap", "carry_over_length")


class ResolutionConfig(ModifiedBaseModel):
mode: ResolutionMode = ResolutionMode.FRONTAL
block_length: Optional[int] = None
block_overlap: int = 0
carry_over_length: Optional[int] = None
Comment on lines 158 to +159

@tbittar tbittar Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we raise an error (or a warning) when block_overlap and carry_over_length are set in a different mode than sequential to avoid silent ignore of these parameters

@aoustry aoustry Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch - done in c97b584, as a hard error rather than a warning.

A new ResolutionConfig validator rejects block-overlap / carry-over-length in all three non-sequential modes (frontal, parallel-subproblems, benders-decomposition). parallel-subproblems was the motivating case: it does window the horizon, so an overlap there looks like it should do something and quietly does nothing.


@model_validator(mode="after")
def _block_length_required_for_windowed_modes(self) -> "ResolutionConfig":
Expand All @@ -164,6 +168,66 @@ def _block_length_required_for_windowed_modes(self) -> "ResolutionConfig":
raise ValueError(f"'block_length' is required for mode '{self.mode.value}'")
return self

@model_validator(mode="after")
def _reject_sequential_only_fields(self) -> "ResolutionConfig":
"""'block-overlap' and 'carry-over-length' steer the stitching of
consecutive blocks, which only exists in sequential mode. Reject them
elsewhere instead of dropping them silently. The check is on the keys
the user actually wrote (``model_fields_set``), so an explicit
'block-overlap: 0' is rejected too."""
if self.mode == ResolutionMode.SEQUENTIAL_SUBPROBLEMS:
return self
declared = [
name for name in _SEQUENTIAL_ONLY_FIELDS if name in self.model_fields_set
]
if declared:
keys = ", ".join(f"'{name.replace('_', '-')}'" for name in declared)
plural = len(declared) > 1
raise ValueError(
f"{keys} only appl{'y' if plural else 'ies'} to mode "
f"'{ResolutionMode.SEQUENTIAL_SUBPROBLEMS.value}', but mode is "
f"'{self.mode.value}'; remove {'them' if plural else 'it'} "
f"or switch mode"
)
return self

@model_validator(mode="after")
def _validate_block_overlap(self) -> "ResolutionConfig":
if self.block_overlap < 0:
raise ValueError(f"'block-overlap' must be >= 0, got {self.block_overlap}")
if self.block_length is not None and self.block_overlap >= self.block_length:
raise ValueError(
f"'block-overlap' ({self.block_overlap}) must be < 'block-length' "
f"({self.block_length})"
)
return self

@model_validator(mode="after")
def _validate_carry_over_length(self) -> "ResolutionConfig":
if self.carry_over_length is not None:
if self.carry_over_length < 0:
raise ValueError(
f"'carry-over-length' must be >= 0, got {self.carry_over_length}"
)
if self.carry_over_length > self.block_overlap:
raise ValueError(
f"'carry-over-length' ({self.carry_over_length}) must be <= "
f"'block-overlap' ({self.block_overlap})"
)
return self

@property
def effective_carry_over_length(self) -> int:
"""Resolved carry-over length: explicit value if set, else full pin of
the overlap zone (``block_overlap``). ``0`` is a legal explicit value,
distinct from "unset", meaning blocks overlap for lag-constraint
history but are not stitched at all."""
return (
self.carry_over_length
if self.carry_over_length is not None
else self.block_overlap
)


class TimeScopeConfig(ModifiedBaseModel):
first_time_step: int = 0
Expand Down
37 changes: 30 additions & 7 deletions src/gems_runner/session/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -111,10 +112,16 @@ def _run_sequential(self) -> SimulationTable:
initial_values=carry_over or None,
)
tables.append(table)
# Block N and block N+1 share `block_overlap` absolute
# timesteps: block N's local indices `block_length - overlap
# ...` are block N+1's local indices `0 ...`.
delta = block_length - block_overlap
t_start += delta
carry_over = self._extract_carry_over(
problem, local_index=len(timesteps) - 1
problem,
local_start=delta,
length=carry_over_length,
)
t_start += block_length - block_overlap
block_id += 1

return self._reduce(tables)
Expand Down Expand Up @@ -240,17 +247,33 @@ def _reduce(self, tables: List[SimulationTable]) -> SimulationTable:
@staticmethod
def _extract_carry_over(
problem: OptimizationProblem,
local_index: int,
local_start: int,
length: int,
) -> Dict[Tuple[str, str], xr.DataArray]:
"""Extract variable values at *local_index* for use as initial values in the next block."""
"""Extract variable values over *length* timesteps starting at *local_start*.

The returned arrays keep a ``time`` dimension re-indexed to
``0 .. length-1`` so they align with the leading timesteps of the next
block's variables. The window is clamped to the solved block's actual
horizon (a truncated final block can be shorter than ``block_length``),
so fewer than *length* values may be carried over.

Only variables carrying a ``time`` dimension are extracted.
Time-independent variables (e.g. an investment capacity) are
deliberately left free in every block, so each block re-optimizes them
independently.
"""
carry_over: Dict[Tuple[str, str], xr.DataArray] = {}
if problem.linopy_model.solution is None:
if length <= 0 or problem.linopy_model.solution is None:
return carry_over
for (model, var_name), linopy_var in problem._linopy_vars.items():
if "time" in linopy_var.dims:
sol_da = problem.get_variable_solution(model, var_name)
if sol_da is not None:
carry_over[(model, var_name)] = sol_da.isel(
time=local_index, drop=True
window = sol_da.isel(time=slice(local_start, local_start + length))
if window.sizes["time"] == 0:
continue
carry_over[(model, var_name)] = window.assign_coords(
time=list(range(window.sizes["time"]))
)
return carry_over
60 changes: 48 additions & 12 deletions src/gems_runner/simulation/optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand All @@ -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))
Expand Down Expand Up @@ -534,15 +556,24 @@ def build(self) -> OptimizationProblem:
model, port_arrays_for_model, total_obj
)

# Phase 5: carry-over constraints (sequential mode only)
# Phase 5: carry-over constraints (sequential mode only).
# Only time-dependent variables are pinned: time-independent ones
# (structure.time = False, e.g. an investment capacity) are deliberately
# left free in every block — see the user guide, `sequential-subproblems`.
for (mk, var_name), init_val in self._initial_values.items():
linopy_var = self.linopy_vars.get((mk, var_name))
if linopy_var is not None and "time" in linopy_var.dims:
safe = f"{mk}__{var_name}".replace("-", "_")
self.linopy_model.add_constraints(
linopy_var.isel(time=0) == init_val, # type: ignore[arg-type]
name=f"carry_over__{safe}",
)
if linopy_var is None or "time" not in linopy_var.dims:
continue
# Pin the first len(init_val.time) timesteps, clamped to this
# block's horizon (a truncated final block can be shorter than the
# carried window).
pin_length = min(init_val.sizes["time"], self.block_length)
safe = f"{mk}__{var_name}".replace("-", "_")
self.linopy_model.add_constraints(
linopy_var.isel(time=slice(0, pin_length))
== init_val.isel(time=slice(0, pin_length)), # type: ignore[arg-type]
name=f"carry_over__{safe}",
)

# Extract constant objective contribution (linopy cannot hold pure constants).
objective_constant = 0.0
Expand Down Expand Up @@ -992,9 +1023,14 @@ def build_problem(
problem_name:
Label for the linopy model.
initial_values:
Optional carry-over values keyed by ``(model_id, var_name)``. For
each entry a constraint ``var[time=0] == value`` is added, overriding
the cyclic border condition for the first timestep.
Optional carry-over values keyed by ``(model_id, var_name)``. Each
value must be an ``xr.DataArray`` carrying a ``time`` dimension of
length ``k`` indexed ``0 .. k-1``; constraints
``var[time=i] == value[i]`` are then added for the block's first ``k``
timesteps, overriding the cyclic border condition on that window. A
value without a ``time`` dimension raises ``ValueError`` before the
problem is built. Entries whose variable is time-independent, or is
absent from this block, are ignored.
"""
study.check_consistency()

Expand Down
Loading
Loading