Decomposition
Decomposition in FlexTool means splitting a monolithic LP into smaller
solvable pieces, then recombining their solutions. FlexTool supports
three independent flavours, all implemented natively in
engine_polars:
- Spatial / Benders — partition the network into regions joined
only by the inter-regional trade flows; a coordinating master holds
those trade flows plus the trade-connection investment, and each
region is an operational subproblem whose duals become optimality
cuts the master accumulates until the optimality gap closes. Selected
per solve from the database (
solve.decomposition = benders); see §2.8. - Flex-temporal — let different parts of the same solve run at
different timestep resolutions (hourly power, daily hydrogen, …) in
one LP. Configured per-entity via
group.new_stepduration. - Sequential / rolling / nested — split a long horizon into
overlapping windows (rolling) or into an outer long-horizon solve
whose decisions feed an inner short-horizon solve (nested). The user
configuration is in
solve.solve_mode/solve.contains_solves; the engine wiring is in_recursive_solve.py+_solve_handoff.py.
Spatial and flex-temporal are LP-level decompositions (one solve, many sub-problems). Rolling / nested is a meta-level decomposition (one chain of solves, each with its own LP). They compose, with one exception noted in §6.
The engine-side overview of the per-solve pipeline and warm-start / Benders / cascade solve modes lives in engine_polars.md; this page focuses on the decomposition mechanics themselves.
1. Picking a decomposition
| Symptom | Use |
|---|---|
| Monolithic LP slow, but the system has clear regional structure (multi-country grid, sparse interconnections) | Spatial / Benders |
| Power demand needs hourly resolution but some commodities (long-duration storage, hydrogen, district heat) only need daily / weekly resolution | Flex-temporal |
| Long horizon (decades, full reanalysis years) that does not fit one solve | Rolling |
| Long-term storage needs a multi-year view, but dispatch needs intra-week resolution | Nested |
| Capacity-expansion at coarse resolution + intra-week dispatch at fine resolution | Nested |
| Multiple operational scenarios sharing the same realised path | Nested + stochastic branches |
The three flavours are orthogonal in intent (a decomposition along the geography axis, the time-resolution axis, and the horizon axis). §6 covers which combinations are actually wired today.
2. Spatial / Benders decomposition
2.1 Concept
Benders decomposition splits the LP into a coordinating master and a
set of operational subproblems, one per region. The complicating
decisions — the inter-regional trade flows and the trade-connection
investment — live in the master; everything inside a region (its own
network, generators, storage, demand) is a subproblem solved with the
master's trade schedule pinned. Each subproblem returns the duals of
its pinned trade columns; the master accumulates those as optimality
cuts and re-solves. The master objective is a valid lower bound on
the true optimum; the incumbent feasible cost is the upper bound; the loop
stops when the optimality gap (UB − LB) / |UB| drops below tolerance.
This replaces the earlier dual-subgradient (Lagrangian) scheme, which severed each cross-region arc into invest-less half-flows bounded near zero and collapsed greenfield trade to an autarkic solution with an invalid bound above the true optimum. Benders puts the trade investment and the trade flow / capacity coupling in the master, feeds each region the master's chosen flow as a pinned boundary injection, and returns a certified lower bound and the true optimum.
The master is built by build_flextool over a network-only reduced
FlexData (_region_filter.master_network_data) — the SAME emit the
monolith uses — so the master picks up the trade maxFlow capacity
coupling, the invest annuity, and the connection flow cost natively and
RP-weight-consistently: the master's trade flow cost and the regions'
recourse costs are both weighted by the same representative-period /
timestep weights build_flextool applies everywhere, so summing them is
arithmetically the monolithic objective (no hand-rolled annuity, no
weight mismatch). One recourse variable η_r per region carries that
region's operational cost in the master objective.
flowchart TD
A[FlexData whole system] --> B[_region_filter.split
regions + half-flows]
A --> M0[_region_filter.master_network_data
network-only reduced FlexData]
M0 --> M[Master = build_flextool + WarmProblem
trade flow f, invest C, recourse η_r]
B --> C[Per-region Problem
build_flextool, trade pinned to f̄]
M --> C
C --> D[subproblem duals
col_dual of pinned trade cols]
D --> E[master.add_cut_row
optimality cut per region]
E --> M
M --> F{gap ≤ tol?}
F -- iterate --> C
F -- stop --> G[BendersResult
region_objectives, LB/UB, log]
2.2 Region declaration
Regions are group entities whose decomposition_method parameter
equals the string benders_regional. The group's
group__node / group__unit / group__connection memberships list
the entities belonging to that region. The coordinator requires at
least two such groups in the active scenario; one is treated as an
error (use the monolithic path instead).
# Spine input DB fixture sketch.
entities += [("group", "region_A"), ("group", "region_B")]
parameter_values += [
("group", "region_A", "decomposition_method", "benders_regional", ALT),
("group", "region_B", "decomposition_method", "benders_regional", ALT),
]
for r in ("A", "B"):
for node in [f"elec_{r}", f"h2_{r}", f"battery_{r}"]:
entities.append(("group__node", (f"region_{r}", node)))
The reference catalogue entry is group.decomposition_method in
reference.md; the only accepted value is
benders_regional (the _region suffix makes the geographic flavour
explicit, leaving naming room for any future temporal variant). The
source of truth is
flextool/engine_polars/_region_filter.py:load_decomposition_method.
2.3 Half-flow rewriting
Every connection whose endpoints straddle two region groups is rewritten into a pair of import / export half-flows through a virtual commodity node, one per region:
hf_<pipe>__export__<region_A> (in region A, virtual sink node)
hf_<pipe>__import__<region_B> (in region B, virtual source node)
Inside a region's standalone LP each half-flow is an ordinary v_flow
column. The export and import sides of an arc carry the same flow at
optimality. In the Benders scheme that consensus is enforced by the
master: each iteration pins every region's forward cross-region
half-flow to the master's current f̄ per (d, t) (reverse pinned to 0)
before solving the region. The cut slope for (arc, d, t) is then the
reduced cost of the pinned forward column
(Solution.col_dual[pin_col_id] = ∂cost_r / ∂f̄, basis-correct, no
monolith reference). The rewriter / half-flow metadata structure is in
_region_filter (HalfFlow, Coupling, split); the arc / pin
bookkeeping for the master lives in _benders._build_arcs.
Star topologies (more than two regions sharing one pipeline) are not supported; the rewriter asserts bilateral arcs only.
2.4 Algorithm — master + multi-cut Benders loop
The master is a single polar_high.Problem wrapped in a
polar_high.WarmProblem, built ONCE and grown by appended optimality cut
rows. The loop:
- Bootstrap
f̄ = 0, solve every region, harvest the first cuts. - Master solve → new trade schedule
f̄and investC; the master objective is the lower boundLB(a valid global under-estimate — the whole point versus the old scheme). - Subproblems: pin each region's forward trade columns to
f̄, solve, readcol_dualof the pinned columns. - Cuts: append one optimality cut per region via
WarmProblem.add_cut_row, bounding that region's recourseη_r. - Upper bound:
UB = master invest cost(C) + Σ_r cost_r(f̄); incumbent = best (min)UB. - Repeat from 2 until
gap = (best_UB − LB) / |best_UB| ≤ tolor the iteration cap is hit.
The recourse vars and cut rows are appended through the polar-high
primitives WarmProblem.add_recourse_col and WarmProblem.add_cut_row;
each region subproblem is solved with WarmProblem.solve(retry_on_unknown=
…) so a transient HiGHS kUnknown is retried rather than aborting the
run. Each η_r is lower-bounded by a large-NEGATIVE finite floor (NOT a
hard η ≥ 0): FlexTool region costs can be negative, so a blind zero floor
could cut off the optimum; the finite floor is a provably valid global
under-estimate that keeps the cut-less iter-0 master optimal.
The two knobs:
DB parameter (solve entity) |
Default | Meaning |
|---|---|---|
benders_max_iter |
50 |
Outer-loop iteration cap |
benders_tolerance |
1e-3 |
Relative optimality-gap threshold (gap normalised by the incumbent UB) |
They are resolved per solve by SolveConfig.benders_config_for; a solve
that authors neither falls back to these defaults.
2.5 Existing vs investable trade capacity
The master handles existing and investable trade connections in ONE
build. An existing-only arc (existing > 0, no invest_method) carries
no invest variable; its flow is bounded by existing / unitsize through
the FlexTool maxFlow RHS (p_flow_upper_existing). An investable arc
gets a v_invest var and its capacity is bounded by existing +
invested. A greenfield arc (existing = 0) is unchanged — all its trade
capacity is invested C. Because the master is the SAME emit as the
monolith, the existing-capacity term and the invest annuity drop in with
no rescale, and the per-region half-flows are left uncapped
(benders_uncap_cross_region=True) so a positive master pin is always
feasible in the region.
2.6 Validity guard
A monolith-free sandwich guard is always on: LB ≤ optimum ≤ best_UB
must hold (best_UB is itself an upper bound on the optimum). An
LB > best_UB is the genuine invalid-lower-bound pathology — exactly the
bug this scheme fixes — and raises RuntimeError rather than reporting a
wrong-but-plausible optimum. No external big-M is needed.
2.7 Diagnostics
Every outer iteration logs the iteration index, the master LB, the
incumbent UB, and the current gap; the orchestrator also prints a final
convergence + LB/UB sandwich summary. The same data is available on the
returned BendersResult:
BendersResult.iteration_log— per-iteration dicts (LB / UB / gap trajectory)BendersResult.lower_bound/upper_bound/gap— final valuesBendersResult.region_objectives— per-region recourse cost at the incumbentBendersResult.converged— True iff the gap dropped belowtolbefore the iteration cap
Look for:
- the gap closing monotonically (LB rising, UB falling toward each
other); a stalled gap usually means
benders_max_iteris too low or the cut slopes are degenerate (a region whose pinned trade column is basic returns a zero reduced cost) - region objectives broadly similar magnitude — one region dominating often means the cut placement is wrong (a major load centre ended up in a region with too little generation)
2.8 Selecting the scheme per solve
Decomposition is chosen per solve, from the database — there is no
CLI flag. Set solve.decomposition = benders on the solve(s) you
want decomposed, optionally overriding the two benders_* knobs:
solve parameter |
Value list / type | Default | Effect |
|---|---|---|---|
decomposition |
decomposition_schemes = {none, benders} |
none |
benders routes this solve through solve_benders; none solves it monolithically |
benders_max_iter |
float | 50 |
Outer-loop iteration cap |
benders_tolerance |
float | 1e-3 |
Relative optimality-gap threshold |
Because the decision is per solve, a single chain can mix schemes — for
example a Benders investment solve followed by a monolithic
full-resolution dispatch solve. The orchestrator
(engine_polars._orchestration) reads each solve's resolved
decomposition value as it iterates model.solves. This is distinct
from the group-level group.decomposition_method
(none / benders_regional), which declares which groups are
regions; solve.decomposition declares whether a given solve
decomposes. Benders still requires at least two benders_regional
groups.
Invest → dispatch handoff. A Benders investment solve assembles the
owner-selected invested trade + in-region capacity into a whole-system
SolveHandoff (the TIER-1 invest→dispatch chain) so a downstream rolling
or monolithic dispatch solve consumes the realised capacity exactly as it
would after any other invest solve. The orchestrator records which Benders
solves produced an invest handoff and threads it forward; a downstream
solve that would consume a Benders solve which produced none is rejected
loudly rather than silently dropping the link.
The --region <GROUP_NAME> flag is a separate, filter-only entry
point that emits a per-region input directory and exits without
solving — useful for inspecting how the rewriter sees a given region.
2.9 Reference implementation
| Symbol | Module |
|---|---|
solve_benders(data, *, ...) — top-level entry |
flextool/engine_polars/_benders.py |
BendersResult — return type |
same |
Coupling, _build_arcs, the master + cut loop |
same |
master_network_data (network-only reduced FlexData for the master) |
flextool/engine_polars/_region_filter.py |
RegionSplit, HalfFlow, split |
same |
load_decomposition_method (reads decomposition_method from solve_data) |
same |
WarmProblem.add_cut_row / add_recourse_col / solve(retry_on_unknown=…) |
polar_high (external) |
The per-solve routing lives in flextool/engine_polars/_orchestration.py
(_PolarHighCascadeSolver._run_benders_solve, driven by
SolveConfig.decomposition_for). The Benders path is also surfaced
as a "Solve modes" entry in
engine_polars.md § Solve modes.
3. Flex-temporal decomposition
3.1 Concept
Mixed-resolution scheduling: some entities (power demand, VRE generation, batteries) need fine timesteps; others (long-duration storage, hydrogen, district heat) only need coarser steps for their dynamics to be well-represented. A monolithic LP at the fine resolution wastes solve time on the slow entities. Flex-temporal decomposition lets a single LP carry both — every entity runs on the block resolution it needs, joined by overlap-set aggregation across the LP's constraints.
This is structurally different from the Benders path: there is one
LP, one HiGHS run, no outer loop. The decomposition lives in the LP
structure (which (entity, block) rows the _emit_* modules produce).
3.2 Schema surface
| Parameter | Entity | Effect |
|---|---|---|
solve.new_stepduration |
solve |
Solve-wide default block duration (existing parameter, predates the multi-block work) |
group.new_stepduration |
group |
Override: members of this group operate at this step duration |
group.decomposition_method |
group |
Tag a group as a resolution group. Used together with new_stepduration. |
group__node / group__unit / group__connection |
(relation) | Membership lists for the resolution group |
Validation in BlockLayout.validate_group_membership:
- every entity may belong to at most one resolution-group (otherwise block assignment is ambiguous)
- every entity may belong to at most one decomposition-group (the Benders region groups counted here too)
- reserve participants must NOT sit in any resolution-group (reserve blocks are V1 only — preserved compatibility quirk)
Violations raise FlexToolConfigError at solve setup.
3.3 BlockLayout
BlockLayout.build (in _block_layout.py) is called once per solve
and produces, in one pass, every per-solve block-related frame:
entity_block_frame—(entity, block)process_side_block_frame—(process, side, block)process_block_frame—(process, block)(process-unified)block_step_duration_frame—(block, period, step, step_duration)overlap_set_frame—(period, block_coarse, step_coarse, block_fine, step_fine, fraction)block_step_previous_frame— per-block predecessor 7-tuplesblock_period_time_first_frame/block_period_time_last_frame— per-block period boundaries
The DEFAULT_BLOCK block always exists and inherits
solve.new_stepduration (or the bare timeline if unset). Every
resolution group becomes one additional named block at its own step
duration.
flowchart LR
A[group.new_stepduration
+ group__*] --> B[BlockLayout.build]
C[solve.new_stepduration] --> B
D[timeline_config] --> B
B --> E[entity_block]
B --> F[overlap_set]
B --> G[block_step_duration]
B --> H[block_step_previous]
E & F & G & H --> I[BlockBundle]
I --> J[block-aware _emit_*.py]
BlockBundle (in _derived_block.py) wraps the BlockLayout with
cached lazy-frame join helpers (block_compat_frame,
process_side_block_lf, …) that the _emit_* modules use to filter and
re-project their rows.
3.4 Overlap-set aggregation
The overlap_set is the cross-block aggregation key. For each
(period, block_coarse, step_coarse) row there are one or more
(block_fine, step_fine, fraction) rows whose fraction sums to 1.
The constraint generators use this to project fine-block decision
variables onto coarse-block constraints (and vice versa).
Concretely, when a coarse-block (e.g. daily hydrogen) node balance
joins an hourly arc, the hourly v_flow rows are aggregated to the
daily key via arc_sink_block_dt / arc_source_block_dt
(_derived_block.py), weighted by block_step_duration. The other
direction — a fine-block constraint reading a coarse-block variable —
projects the coarse value onto each fine step at fraction 1
(piecewise-constant).
Two structural assumptions:
- Two-block-deep limit — one resolution-group block plus the natural default fine block. Lifting it is a model-design question.
- Aligned subsets only — every coarse step must align with an
integer number of fine steps. Non-aligned configurations raise
NotImplementedErrorfrom_build_overlap_set.
3.5 Block-aware emitters
The _emit_*.py modules in engine_polars (formerly _writer_*.py,
renamed in the writer→emitter Phases 1–5; see
engine_polars.md § Emitters)
are block-aware. The major touch points:
- Node balance — built on the block of the node, joined with
per-side arcs through
overlap_set(loss-aware when the arc efficiency depends on flow direction). - Storage transition —
v_stateties at block boundaries; theblock_step_previous_frameprovides the per-block predecessor lookup so storage state continuity is enforced on the block granularity, not the fine granularity. - Ramp / minimum-load — block-aware: ramps use the block's step duration, not the underlying timeline's.
- Reserves — V1-only-on-default-block; reserves cannot ride a coarse block (the validation rule above guarantees this).
3.6 Output expansion
Coarse-block decision variables are expanded back onto the fine
timeline before the _emit_* modules write results, using the same
overlap_set (read in reverse). Per-fraction attribution stays
consistent across the solve / write boundary; downstream tooling sees
the same fine-grained output regardless of which entities ran on
which block.
This expansion is the reason that output_node_state_t,
output_node_balance_t, etc. emit one row per (period, fine_step)
even for entities on a coarse block.
3.7 Caveats
representative_period_weightsinteracts with block durations via the per-period normalisation inTimelineConfig.timeset_weights; picking arbitrary block durations on top of an RP-clustered timeset is supported but requires the block step duration to divide the RP period length evenly.- Block-aware reserves are not implemented; if you tag a reserve participant into a resolution group, the validator refuses the scenario.
The engine-side overview of flex-temporal lives in engine_polars.md § Flex-temporal decomposition.
4. Rolling / nested solves
These are user-facing patterns documented in how_to.md; this section covers the engine wiring.
4.1 Rolling
A long horizon is split into overlapping optimisation windows. Each window ("roll") is its own LP; the next window starts at the previous window's "jump" point with the storage state at that point fixed.
Schema:
solve.solve_mode = rolling_windowsolve.rolling_solve_jump— interval between roll start points (also the output interval per roll)solve.rolling_solve_horizon— length of the roll's optimisation horizon (> jump; the overlap ishorizon - jump)solve.rolling_duration— optional total horizon length;-1(or unset) means "until the active time list is exhausted"
Engine wiring:
RecursiveSolveBuilder.create_rolling_solvesin_recursive_solve.pyexpands the parent solve into a sequencesolve_roll_0, solve_roll_1, …with per-roll active-time and realized-time lists.- Per-roll storage carry-over rides
SolveHandoff.roll_end_state(_solve_handoff.py). - The cascade reuses the same LP shell via
polar_high.WarmProblemwhen consecutive rolls'FlexDatafingerprints match; otherwise cold-rebuild. See engine_polars.md § Warm-start cascade.
4.2 Nested
An outer long-horizon solve fixes long-term decisions (capacity, long- term storage state at chosen anchor periods); an inner short-horizon solve dispatches within those decisions.
Schema:
solve.contains_solves— array of child solves run after the parent using the parent's realised datasolve.fix_storage_periods— periods where the parent's last storage value becomes a target for the childnode.storage_nested_fix_method— how the child handles the fix-storage target (fix_quantity/fix_price/ etc.;fix_pricerequiresstorage_state_reference_price)
Engine wiring:
- The
RecursiveSolveBuilderwalks the solve tree atdefine_solve_recursivetime and threadsParentSolveInfointo nested children. - The parent's realised capacity / storage state rides
SolveHandoff.realized_invest/realized_existing/fix_storage/fix_storage_timesteps. - The child's preprocessing reads from
state.handoffs[parent_name](in-memory by default) or from the legacysolve_data_<parent>/fix_storage_*.csv(fallback).
The single-matching-period rename quirk: when a child solve's period
set intersects its parent's by exactly one period, the child is
renamed solve + "_" + period and duplicate_solve carbon-copies
the parent's lockstep dicts under the new name. Preserved verbatim
from the legacy preprocessor.
4.3 Stochastic branches
Each branch is itself a sub-solve in the chain. solve.stochastic_branches
is a 4-D map that decorates affected periods with branch-suffixed
clones (y2025 → y2025, y2025_low, y2025_high). Only the
realised branch contributes to realized_time_lists and
fix_storage_time_lists; the other branches' LP variables exist for
the optimisation but do not enter the realised outputs.
The wiring lives in _stochastic.StochasticSolver (called from
RecursiveSolveBuilder); non-anticipativity constraints are emitted
by _add_non_anticipativity_constraints in model.py. See
engine_polars.md § Stochastic branches.
5. RELEASE notes context
The 3.29.0–3.33.0 release cycle is the relevant window for these
features. Quick map (full text in
release.md or CHANGELOG.md):
- 3.29.0 — Flex-temporal decomposition lands.
v50migration movesnew_stepdurationfromtimesettosolve.v51adds group-levelnew_stepduration+decomposition_method. - 3.30.0–3.32.0 — Block-aware emitters complete (then known as writer ports); output expansion stabilises.
- 4.0.0 (
v60migration) — spatial decomposition becomes DB-driven and per-solve:solve.decompositionplus per-solve knobs. The global--decompositionCLI flags are removed; the orchestrator routes each solve from its DB value. - 4.0.0 (
v62migration) — the spatial scheme switches from the dual-subgradient (Lagrangian) coordinator to Benders. Thedecomposition_schemesvaluelagrangian→bendersand the group methodlagrangian_region→benders_regional; the oldlagrangian_alpha/lagrangian_max_iter/lagrangian_toleranceknobs are dropped and replaced bybenders_max_iter(50) /benders_tolerance(1e-3). Existing DBs are auto-migrated (breaking schema change, DB version 62). The Benders invest solve threads its realised capacity into a downstream dispatch solve (TIER-1 invest→dispatch handoff).
6. Where they compose
| Combination | Supported | Notes |
|---|---|---|
| Benders + flex-temporal | Yes | Each region's build_flextool runs through the block-aware _emit_* modules unchanged. |
| Benders + rolling | No | One Benders solve is the unit of work for the outer rolling loop. Not currently wired; would require per-roll cut warm-start. The TIER-1 invest→dispatch handoff threads a Benders invest solve into a downstream rolling dispatch solve, which is the supported pattern. |
| Benders + nested | Partial | The outer (parent) solve can be Benders, but the parent's handoff to children propagates only the invest→dispatch capacity, not per-region operational state. Treat as untested beyond the invest→dispatch chain. |
| Flex-temporal + rolling | Yes | The rolling jump is on the fine timeline; coarse blocks honour the jump boundary inside each window. |
| Flex-temporal + nested | Yes | Each parent / child solve carries its own BlockLayout. |
| Flex-temporal + stochastic | Yes | Branches enter the active-time lists at the fine resolution; coarse-block constraints pick up the relevant branch-suffixed steps via the same overlap set. |
| Rolling + nested | Yes | The legacy "more than one solve in a rolling solve" raise still trips for unsupported sibling-rolling-children patterns. |
| Rolling + stochastic | Yes | Each roll can carry branches; the realised branch chains forward. |
7. See also
- engine_polars.md — the engine guide; Solve modes section covers monolithic / warm / Benders and Flex-temporal decomposition section covers the BlockLayout shape.
- architecture.md — top-level architecture; how the decomposition pieces fit into the larger pipeline.
- scaling.md — auto-scaling pipeline; runs per sub-problem, so each region / each roll / each block gets its own scaling pass.
- reference.md — the parameters touched here:
solve.solve_mode,solve.rolling_solve_jump,solve.rolling_solve_horizon,solve.contains_solves,solve.fix_storage_periods,solve.stochastic_branches,solve.new_stepduration,group.new_stepduration,group.decomposition_method,node.storage_nested_fix_method. - how_to.md — practical recipes for setting up Benders regions, rolling windows, nested solves, and stochastic branches.