Summary
The accurate thermal heuristic (find_num_units_accurate, src/gems_runner/simulation/thermal_heuristic.py) enforces min-up/min-down duration on unit counts using a small LP whose time indices are cyclic ("All indices are cyclic (weekly wrap-around)", implemented via .roll() on num_starting/num_stopping/num_outages). That assumption is correct when the block being solved is the whole horizon (resolution.mode: frontal), but it is not adapted to resolution.mode: sequential-subproblems, where the horizon is split into independent blocks solved one after another with carry-over.
Where this breaks down
In SimulationSession._run_block (src/gems_runner/session/session.py), each block:
- builds a fresh
linopy.Model() for just that block's timesteps,
- solves once (relaxed for
integer-strategy: heuristic components),
- calls
apply_thermal_heuristics, which invokes the heuristic per block, and
- re-solves.
_run_sequential (session.py:90-120) then calls _extract_carry_over, which pins var[time=0] == value for the next block using the previous block's last-timestep solution — a forward-only mechanism.
The accurate heuristic's cyclic wraparound instead ties the end of the current block back onto the start of the same block (via .roll(dim_0=shift) across the full block length in the min_up/min_down constraints). Consequences:
- A unit whose min-up-time obligation starts near the end of block N should propagate its "must stay on" requirement forward into block N+1. Instead, the heuristic wraps that obligation onto the beginning of block N, which is already fixed by the carry-over constraint inherited from block N-1. The obligation is enforced in the wrong place, and it is silently lost at the block boundary.
- Nothing in
_extract_carry_over propagates a "still owes uptime/downtime" state between blocks — it only pins the raw variable value at time=0, not any remaining-duration counter — so even if the cyclic issue were fixed, min-up/down guarantees are not structurally carried across block boundaries.
By contrast, find_min_generation_fast does not have this issue: it treats block borders as a distinct max-of-border region (not a wraparound), so it degrades more gracefully across block seams.
Why this is currently unguarded
validate_optim_config (src/gems_craft/optim_config/validation.py) explicitly rejects integer-strategy: heuristic combined with resolution.mode: benders-decomposition (_check_no_heuristic_with_benders), but there is no equivalent check or warning for heuristic + sequential-subproblems.
- The existing e2e coverage (
tests/e2e/functional/test_thermal_heuristic_*.py, using tests/e2e/functional/optim-config/thermal_variants_for_heuristic.yml) only exercises a single 168-timestep block, i.e. frontal mode. There is no test combining thermal heuristics with sequential-subproblems, so this gap is currently unverified by CI.
Suggested next steps (for discussion, not prescriptive)
- Add an e2e test running the
accurate heuristic under sequential-subproblems with block-length smaller than the min-up/min-down durations, to make the boundary artifact reproducible/observable.
- Either:
- (a) make
find_num_units_accurate block-boundary-aware (e.g. accept a real "units already committed near the end of the previous block" state instead of assuming cyclic wraparound), or
- (b) explicitly document/validate that
heuristic-id: accurate combined with sequential-subproblems produces only approximate min-up/down guarantees at block seams — similar to the existing Benders-decomposition guard, but as a warning rather than a hard error if (a) is judged out of scope.
Pointers
src/gems_runner/simulation/thermal_heuristic.py (find_num_units_accurate, cyclic .roll() constraints)
src/gems_runner/simulation/heuristic_runner.py (apply_thermal_heuristics, called per block)
src/gems_runner/session/session.py (_run_block, _run_sequential, _extract_carry_over)
src/gems_craft/optim_config/validation.py (_check_no_heuristic_with_benders — the existing precedent for a resolution-mode/heuristic compatibility guard)
Summary
The
accuratethermal heuristic (find_num_units_accurate,src/gems_runner/simulation/thermal_heuristic.py) enforces min-up/min-down duration on unit counts using a small LP whose time indices are cyclic ("All indices are cyclic (weekly wrap-around)", implemented via.roll()onnum_starting/num_stopping/num_outages). That assumption is correct when the block being solved is the whole horizon (resolution.mode: frontal), but it is not adapted toresolution.mode: sequential-subproblems, where the horizon is split into independent blocks solved one after another with carry-over.Where this breaks down
In
SimulationSession._run_block(src/gems_runner/session/session.py), each block:linopy.Model()for just that block's timesteps,integer-strategy: heuristiccomponents),apply_thermal_heuristics, which invokes the heuristic per block, and_run_sequential(session.py:90-120) then calls_extract_carry_over, which pinsvar[time=0] == valuefor the next block using the previous block's last-timestep solution — a forward-only mechanism.The
accurateheuristic's cyclic wraparound instead ties the end of the current block back onto the start of the same block (via.roll(dim_0=shift)across the full block length in themin_up/min_downconstraints). Consequences:_extract_carry_overpropagates a "still owes uptime/downtime" state between blocks — it only pins the raw variable value attime=0, not any remaining-duration counter — so even if the cyclic issue were fixed, min-up/down guarantees are not structurally carried across block boundaries.By contrast,
find_min_generation_fastdoes not have this issue: it treats block borders as a distinct max-of-border region (not a wraparound), so it degrades more gracefully across block seams.Why this is currently unguarded
validate_optim_config(src/gems_craft/optim_config/validation.py) explicitly rejectsinteger-strategy: heuristiccombined withresolution.mode: benders-decomposition(_check_no_heuristic_with_benders), but there is no equivalent check or warning forheuristic+sequential-subproblems.tests/e2e/functional/test_thermal_heuristic_*.py, usingtests/e2e/functional/optim-config/thermal_variants_for_heuristic.yml) only exercises a single 168-timestep block, i.e. frontal mode. There is no test combining thermal heuristics withsequential-subproblems, so this gap is currently unverified by CI.Suggested next steps (for discussion, not prescriptive)
accurateheuristic undersequential-subproblemswithblock-lengthsmaller than the min-up/min-down durations, to make the boundary artifact reproducible/observable.find_num_units_accurateblock-boundary-aware (e.g. accept a real "units already committed near the end of the previous block" state instead of assuming cyclic wraparound), orheuristic-id: accuratecombined withsequential-subproblemsproduces only approximate min-up/down guarantees at block seams — similar to the existing Benders-decomposition guard, but as a warning rather than a hard error if (a) is judged out of scope.Pointers
src/gems_runner/simulation/thermal_heuristic.py(find_num_units_accurate, cyclic.roll()constraints)src/gems_runner/simulation/heuristic_runner.py(apply_thermal_heuristics, called per block)src/gems_runner/session/session.py(_run_block,_run_sequential,_extract_carry_over)src/gems_craft/optim_config/validation.py(_check_no_heuristic_with_benders— the existing precedent for a resolution-mode/heuristic compatibility guard)