Release notes
Unreleased
Database migration v67 → v68 (one step, automatic on load,
_migrate_v68_rp_group_flag). The new parameter is additive and defaults to
prior behaviour, so a migrated database selects representative periods
byte-identically until you opt into net-load clustering.
- Net-load representative-period clustering (
--netload-clustering). The representative-period preprocessor can cluster on a real-MW net-load signal (demand − Σ VRE·availability over demand-match-sized capacities) instead of the default per-series normalized profile/inflow stack. VRE is anyunit__node__profilearc whoseprofile_methodisupper_limit(the schema default).--vre-penetrationscales the demand-match energy-share target. The default (non---netload-clustering) path is byte-parity with prior behaviour. group.use_for_representative_periods(schema v68). A per-groupyes_noflag that marks a region-group as a net-load aggregation unit: when one or more groups carryyes, net load is summed per flagged group; when none do, selection falls back to per-node net-load signals.- Net-load iteration driver (
python -m flextool.representative_periods.netload_iterate). An invest-only iterate-until-stable loop that feeds each iteration's solved investment capacities back into the next net-load selection until the representative set stabilises (early-stop on stability; bootstrap dispatch skipped).--keep-bestadditionally dispatches each mature iteration and keeps the lowest full-year dispatch-cost set. Subsets are run via amodel.solvesoverride alternative; a fail-closed guard rejects an active timedenergy_margin_adder. - Fixed representative-period blended-weights inter-period storage state being mis-scaled by unit size for storage nodes with unit size ≠ 1.
Release 4.0.0b26 (30.7.2026) — adequacy calibrator + Calibrate-investments GUI; additive energy-margin & hard node balance; near-optimal IPM acceptance
Database migration v65 → v67 (two steps, automatic on load). The
polar-high floor moves to >=3.7.0 (the near-optimal solve-acceptance
diagnostics below rely on it). Other floors unchanged (polars>=1.40,
highspy<=1.14.0). Every new schema parameter is additive and defaults to
prior behaviour, and the two migration steps preserve existing values, so a
migrated database solves byte-identically until you opt into the new levers.
Adequacy — calibration & margins
- Adequacy-margin calibrator (new CLI
python -m flextool.calibrate <db> <scenario>). An iterative helper that repeatedly solves a scenario and raises a per-node energy-margin adder until the system reaches an adequate capacity margin, so investments are neither over- nor under-built. Each iteration reads the previous solve's unserved-energy slack and resizes the margin; the result is written into a dedicated<scenario>_adeq_calibalternative appended to the scenario's stack (originals untouched — delete the alternative to undo). Controls include--iterations,--sizing(timed, placing margin only in the periods/steps that fell short, oruniform), a planning-safety--overshoot, first/remaining-iteration damping, and a resource-cap stall guard (--stall-fraction) that stops chasing a target a hard limit makes unreachable. Runs are warm-started between iterations for speed. node.energy_margin_adder+energy_margin_method = inflow_adder(schema v66). An additive companion to the multiplicative margin from b25: extra demand [MWh] added to a node in the investment solve only, raising adequacy headroom without changing dispatch demand. The b25 multiplicativeenergy_marginparameter is renamed to the self-documentingenergy_margin_multiplier(a definition rename; existing values are preserved), and the previously reservedinflow_addermethod value is now live.- Representative periods — per-region budgeted force-include. The
net-load force-include gains an opt-in per-region-group scope
(
--force-region-scope/--force-region-budget) that scores net load independently per region group and greedily forces each region's worst lull under a budget cap, instead of forcing a single system-coincident period. Default (no flags) is unchanged, byte-parity behaviour.
GUI — Calibrate investments
- New "Calibrate investments" dialog. A non-modal dialog (opened from the
main window for the checked scenarios) that hosts two tools: a
representative-periods builder and the adequacy calibrator above. It never
renders its own command lines — the live CLI previews it shows are the exact
commands it runs, with a Copy button each. Representative periods are written
into a scenario-named alternative (
<scenario>_rp_<n>rp_<len>h, de-duplicated on repeat builds) with a descriptive note, and an opt-in checkbox appends that alternative to the selected scenario(s). Every control carries a plain-English hover explanation; launching either tool opens the Execution window so the run is visible. The dialog is scenario-aware (each scenario is clustered from its own series and only receives the solves that belong to it) and fits small screens (bounded, scrollable solve list; the body scrolls within a height-capped window). xlsx-backed scenarios are excluded, since their database is regenerated on each run.
Node balance — hard-balance option
node.penalty_method(schema v67). A per-node toggle for the balance slack.regular(default, byte-identical to before) keeps thepenalty_up/penalty_downunserved-energy / over-supply slack variables;offremoves them, making the node balance a hard equality so a node that cannot balance turns the solve infeasible instead of silently absorbing the shortfall at penalty cost. Useful on adequacy studies where that hidden slack would otherwise mask inadequate capacity.
Solver — near-optimal acceptance
- Accept in-practice-optimal crossover-off interior-point solves (requires
polar-high>=3.7.0). When the interior-point method returns a feasible primal with a negligible primal–dual gap but the status stays "unknown" (crossover disabled), the solve is now accepted as optimal instead of being treated as a failure, and this acceptance is propagated to the multi-solve cascade's exit scan. Tunable viaFLEXTOOL_ACCEPT_*knobs; default thresholds are conservative.
Fixes
- Reserve balance crashed on dynamic / N-1 reserves. The dynamic and N-1 reserve-balance right-hand side left the flow variable's native source/sink axis open, which could abort canonicalisation on some models; the axis is now tied correctly.
- Mixed-shape group floors were dropped in
(period, time)broadcasting. A group with a mix of period-scalar and period-time floor Maps had the other groups' floors inner-joined away, letting the optimum fall below the intended floor; the broadcast now preserves every group's rows. - Calibrator alternative write crashed on 0-based scenario stacks. The calibration-alternative append no longer collides on real models whose scenario_alternative ranks are stored 0-based.
Release 4.0.0b25 (15.7.2026) — per-solve autoscaler mode; energy/capacity margins; rep-period force-include; storage-handoff & scale-invariant-floor fixes
Database migration v63 → v65 (two steps, automatic on load). The
polar-high floor moves to >=3.6.1 (the scale-invariant coefficient floor —
without it, full-autoscale re-solves of some battery / demand-response and
wide-coefficient-range models are mis-computed). Other floors unchanged
(polars>=1.40,
highspy<=1.14.0). The new schema parameters are additive and default to
prior behaviour; the correctness fixes below (scale-invariant floor,
storage-handoff, space-mangled names) change results only on the models that
exercise those paths.
Numerical scaling — correctness
- Per-solve autoscaler mode:
solve.scaling(schema v64). The LP autoscaler mode (off/solver_only/basic/full) is now a per-solve database parameter, not just the run-wide--scaling/FLEXTOOL_SCALINGcontrol, so a model can pin the mode that solves it correctly. Motivation: some models with a very wide coefficient range are mis-scaled by autoscale Layer 2 and return a grossly wrong all-slack solution even though the LP is correct (HiGHS solves it directly to the right answer);solve.scaling = basiclets the DB carry that workaround per model. Default isfull(unchanged behaviour). Precedence: operator override (--scaling/ env) >solve.scalingdefault.
- Scale-invariant coefficient floor (requires
polar-high>=3.6.1). Infullautoscale mode the coefficient floor previously floored structurally- essential matrix coefficients whose Layer-2-scaled magnitude fell below the threshold, silently mis-solving several battery / demand-response models. The floor is now applied in user-space, sofullagrees withoff/basicagain. Five scenario goldens (dr_shift_demand,test_a_lot,multi_fullYear_battery_nested_*) were regenerated to the corrected solutions;off/basicoutputs are byte-identical to before.
Adequacy — representative-period bias levers (opt-in)
node.energy_margin+energy_margin_method(schema v65). An invest- stage-only lever that scales a node's demand inflow upward during the investment solve to offset representative-period VRE optimism (so the model builds enough firm capacity), while dispatch still runs on the true inflow.energy_margin_method:none(default, off) orinflow_multiplier(apply theenergy_marginfactor). Note FlexTool represents demand as negative inflow, so a factor > 1 correctly deepens demand.group.capacity_margin_methodreplaceshas_capacity_margin(schema v65). The yes/nohas_capacity_marginflag becomes a method enumnone/manual(migratedyes → manual), consistent withenergy_margin_methodand leaving room for a future automatic mode.- Representative-period net-load force-include. New opt-in CLI flags on the
representative-period preprocessor force adequacy-critical base periods (peak-
load / highest sustained net-load) into the representative set, fixing rep-
period under-investment when the sampled periods miss a seasonal stress window
(e.g. a coincident low-VRE / high-demand week). Supports
grow / fixed count modes, a
vg_weightknob,+f{n}naming, and optional node-group demand-weighting (--region-groups) so the net-load signal is weighted by regional demand. Default (no flags / no region groups) is the unweighted, byte-parity path.
Fixes
- Storage nested-fix handoff was inert / inverted. Three fixes to the
multi-solve coarse→fine storage handoff: (1) the
node__storage_nested_fix_methodemit used the wrong column contract (storage_nested_fix_methodvs the consumer'smethod), so every fix-node set was empty and the handoff did nothing; (2) thefix_usagethroughput cap was collapsed to a single coarse window instead of summing over every coarse window overlapping the fine period (too tight for any roll spanning >1 coarse window); (3) thefix_pricewater-value reference price carried the AMPL/GLPK dual sign, which is inverted relative to HiGHS — the child was penalised for holding storage instead of rewarded.multi_fullYear_battery_nested_*goldens regenerated now that the handoff is active. - Space-bearing entity names in output after an in-process solve. HiGHS'
writeSolutionpermanently rewrites spaces to underscores in the live LP column/row names; the output extractors then failed to join a node whose name contains a space (e.g.Coastal Grid) against the originals (KeyErrorincalc_capacity_flows, silently-empty dual columns). The un-mangled names are now restored from the polars-renderedSolution.col_names/.row_names. Models without spaces in entity names are byte-identical.
Release 4.0.0b24 (9.7.2026) — warm-start basis cache; greenfield indirect-converter capacity fix; coarse rep-period storage fix
No schema change (still v63). The polar-high floor moves to >=3.6.0 (the
warm-start basis cache and the Benders trust-region wiring call new 3.6.0
primitives). Other floors unchanged (polars>=1.40, highspy<=1.14.0). Two
result-changing correctness fixes below (indirect converters, coarse rep-period
storage) affect models that use those features; everything else is OFF by
default and byte-identical on models that don't opt in.
Model — correctness (changes results on affected models)
- Greenfield indirect (process-node) converters now pay for their capacity.
An indirect converter (
H2 + elec -> [proc] -> NH3) took its capacity- enforcingmaxFlowRHS fromp_flow_upper(theexisting + invest_maxceiling) on every arc, so a greenfield unit (existing = 0) got free, unpaid capacity up to the investment ceiling — the output constraintv_flow − Σ v_invest ≤ invest_max/unitsizewas satisfied atv_invest = 0. The indirectmaxFlowRHS is now split by arc side, mirroring the direct- unitmaxToSink/maxFromSourcesemantics: output arcs bind with the existing-only RHS (any output flow forces paidv_invest > 0), while input/fuel and zero-coef aux arcs keep the loose bound (pinning them toexisting = 0would make fuel hard-infeasible). Direct units are byte- identical. Sink-less indirect invest units now raise a clearNotImplementedErrorinstead of silently emitting free capacity.
Representative periods — correctness
- Coarse representative-period nodes keep their seasonal storage structure.
Coarsening a rep-period run previously dropped the per-representative-period
storage seams, collapsing the rep-period structure; storage now keeps per-
rep-day seams with a free-start balance. Fixes a companion bug where
representative-period labels were nulled on
FlexDataload (round-trip).
Warm-start — basis cache (opt-in, OFF by default)
- A solve can now reuse a related solve's HiGHS basis to warm-start. New, opt-in machinery keyed by a structural fingerprint of the model's name-set (so a cached basis is only injected into a compatible model), covering both solve paths:
- save-memory subprocess path: the basis is cached as a native HiGHS
.basfile and injected into the child via a newcmd_solve_mps --warm-basis(readBasis) hook, with a per-transfer stats sidecar. No variable-name state crosses into the parent (positional-by-construction via the determinism wrapper). - in-process
WarmProblempath: the basis is cached in memory (get_named_basis/set_named_basis) and re-injected on the next compatible solve (sweep / resume — the "UC2/UC3" use cases). - Safety gate: a first-transfer A/B harness measures the warm vs. cold
run and disables warm-start on any regression, so a stale or unhelpful
basis can never make a solve worse. All failure modes (added/dropped/
renamed column) fail safe to a correct cold solve. Also fixes a latent bug
where the configured
work_foldernever reached the subprocess.
Solvers — configuration
highs.optis now honoured on the in-process solve path (it was silently ignored — the option-file directory was clobbered mid-orchestration). The work folder is also seeded with asolver_config/skeleton, and the full merged effective solver options are logged before the solve (precedence: baseline <highs.opt<solver_arguments< CLI).
Decomposition (Benders) — mostly experimental, OFF by default
- Region-subproblem autoscale fixes a large-
Ndecomposition crash. Layer-2 (+ Layer-3 HiGHS-native top-up) numerical scaling is now applied to the Benders region subproblems, with a region-solve retry. This resolves anN = 10mid-solve crash that previously aborted the decomposition. - Driver rewired onto the
polar_high.benderscoordinator (byte-parity with the previous in-line loop; pinned by aλ = 0reference trajectory). - Master-primal trust-region stabilizer wiring (an honest absolute box on
the master coupling point; env-gated, mutually exclusive with in-out) and
experimental master-hosted-node mode (
split()4-way arc classification, invest handoff, go/no-go pin diagnostic) — both opt-in and off by default. - Interior region-dual mode (
FLEXTOOL_BENDERS_REGION_DUALS, env-gated) no longer crashes at iteration 1 (barrier primal-dual tolerance).
Release 4.0.0b23 (3.7.2026) — commercial solvers handle spaces in names; opt-in Benders cut compaction
No schema change (still v63). Dependency floors unchanged (polar-high>=3.4.0,
polars>=1.40, highspy<=1.14.0). The commercial-solver fixes below change no
result on the default (HiGHS) path; the Benders additions are OFF by default.
Solvers — commercial CLIs (Gurobi / CPLEX / Xpress / COPT)
- Commercial solvers now handle entity names containing spaces (common in
FlexTool models, e.g. a node named
Battery Farm), matching the whitespace- agnostic in-process HiGHS path. The subprocess path previously (a) crashed in the parent withparent failed to read MPS back— it re-read the just-written MPS throughhighspy.Highs.readModelpurely to recover variable names — and (b) once that was removed, could still return a silently wrong answer because free-format MPS is whitespace-delimited and a spaced name corrupts the column. The MPS is now written with generic, whitespace-safe names (write_mps(emit_names=False)) and the solver's solution is mapped back by index onto the real names held in memory (rebuilt from the releasedProblem's_vars/ pre-release_cstrs). No parent-sidereadModel(which also spiked tens of GB of RSS on large LPs). - CPLEX / Xpress / COPT
.solparsing fixes surfaced while adding real-solve coverage: COPT writes# Objective value <v>with no=(objective is now parsed); Xpress' defaultwritesolemits an index-based.asc/.hdrCSV that was never parseable, so the Xpress path is switched to the MPS-like SLX solution (writeslxsol, name-based) for the primal pluswriteprtsolfor the objective. The per-solve temp dir is now removed wholesale so solver sidecars don't leak. - New real-solve tests exercise the full subprocess path for all four commercial solvers on a model whose entity name contains a space, backed by each solver's Python wheel via a CLI-shaped shim (skip cleanly where a wheel / licence is absent).
Decomposition (Benders) — opt-in, OFF by default
- Periodic master cut compaction (
FLEXTOOL_BENDERS_CUT_COMPACT_AT, unset/0 = OFF = byte-identical to before) prunes accumulated master cut rows viapolar_high.WarmProblem.compact_cuts, with aslack(default) or non-defaultdominanceselection policy (FLEXTOOL_BENDERS_CUT_POLICY). Note: this is an experimental knob — on load-bearing cut pools it does not reduce iterations. - Capability guard:
compact_cutsneedspolar-high>=3.5.0. When the env flag is set but an older polar-high is installed (the floor still allows 3.4.0), compaction is disabled at run start with a clearneeds >= 3.5.0warning instead of crashing mid-solve. Default (OFF) solves are unaffected.
Database migration v62 → v63 (automatic on load). Dependency floors
unchanged (polar-high>=3.4.0, polars>=1.40, highspy<=1.14.0). Additive and
OFF by default — the converged solution is unchanged on every model.
Decomposition (Benders)
- The in-out separation weight is now a per-solve database parameter,
solve.benders_in_out_weight(previously only the machine-local environment variableFLEXTOOL_BENDERS_IN_OUT_WEIGHTfrom b21). It is the weightλin the in-out separation pointf_sep = λ·centre + (1-λ)·f_out:0.0(the default) = off (exact Benders, byte-identical to before); values in(0, 1)turn the stabilisation on, larger = more. Only used whendecomposition = 'benders'. The environment variable still works and, when set to a valid value, overrides the database parameter (machine-local wins), mirroring the worker-count knob; an out-of-range or malformed environment value is ignored with a warning and the database value is used. The v63 migration adds the parameter to thesolveclass (default0.0,solve_advancedgroup) — no authored data changes, and a database with the parameter unset behaves exactly as before.
Release 4.0.0b21 (1.7.2026) — Benders in-out stabilization (degeneracy tail-off)
No database migration (schema stays v62). Dependency floor raised to
polar-high>=3.4.0 (polars>=1.40, highspy<=1.14.0 unchanged). Opt-in and
OFF by default — the converged solution is unchanged on every model.
Decomposition (Benders)
- Optional in-out separation to speed up a slowly-converging (tailing-off)
spatial-Benders solve. When storage (or any inter-temporal coupling) makes
timesteps fungible, the region recourse is flat in the per-timestep coupling
flow and the cut slopes become basis-dependent; the master then wanders among
cost-equivalent flow schedules and the lower bound closes very slowly even
though the best feasible solution is near-optimal early. In-out separation
(Ben-Ameur & Neto 2007) generates each cut at an interior point
f_sep = λ·centre + (1-λ)·f_outinstead of the extreme master vertex — better- centred cuts, no wandering, faster bound closure, at zero extra subproblem solves (the region is solved once per iteration either way, just at a better point). Enabled per solve via the environment variableFLEXTOOL_BENDERS_IN_OUT_WEIGHT(the weightλ):0.0(the default) is OFF and byte-identical to before;λ ∈ (0, 1)turns it on, larger = more stabilisation. On a hydrogen-trade tail-off benchmark,λ ≈ 0.3–0.7reached the practical optimality gaps about 30% faster and closed the bound tighter than the un-stabilised run (which plateaued above the gap it could otherwise reach). The stabilisation math is the domain-freepolar_high.decomposition.InOutStabilizer(3.4.0); FlexTool drives one instance per node group. Correctness is preserved regardless ofλ: a cut generated at any point is a valid supporting hyperplane (valid lower bound), the interior point is clamped to the chosen capacity (valid upper bound), and the moment a region's cut fails to separate the master vertex the method falls back to an exact-Benders step for that region — so the optimum is unchanged and convergence is guaranteed. A future release will promote the knob to asolvedatabase parameter once a default is settled.
Release 4.0.0b20 (1.7.2026) — Benders stall guard (fail fast with a diagnostic)
No database migration (schema stays v62). Dependency floor raised to
polar-high>=3.3.0 (polars>=1.40, highspy<=1.14.0 unchanged) for the new
generic tail-off detector. Detection only — no converged solution changes.
Decomposition (Benders)
- A stalled spatial-Benders solve now fails fast with a plain-English
diagnostic instead of silently exhausting the iteration cap. When one node
group is near-infeasible on its own (it can only meet its demand via the
boundary/slack penalty), the master keeps proposing coupling flows that force
the penalty regime, the best feasible cost freezes far above any sane value,
and the run would otherwise burn all its iterations returning that garbage. The
solver now detects this and aborts with a three-section message that names the
worst-offender node group and recommends the fix (give that node group's
import/boundary nodes a moderate import price rather than an extreme penalty —
an over-large penalty is what inflates the recourse and freezes the bound). The
detector is a domain-free tail-off monitor in polar-high 3.3.0
(
decomposition.StallMonitor); FlexTool supplies the reference scale (the sum of the node groups' stand-alone costs) and the node-group diagnosis. A run is flagged stalled only when the best feasible cost is frozen for a window of iterations AND still far above that reference AND the gap is far from the tolerance — a conjunction that never trips a slow-but-genuinely-converging solve (validated against solves that converge in 8–28 iterations). The window defaults to 8 and is overridable withFLEXTOOL_BENDERS_MAX_STALL.
Release 4.0.0b19 (1.7.2026) — retiring-unit existing map read + Benders cut-tolerance fix
No database migration (schema stays v62) and dependency floors unchanged
(polar-high>=3.2.0, polars>=1.40, highspy<=1.14.0). Two independent
fixes: one corrects an output/dispatch parameter for retiring units; the other
removes a spurious hard-failure in the spatial-Benders decomposition solver.
Outputs / dispatch
- Read a retiring unit's
existingcapacity by the per-period MAX, not the last row._entity_unitsize_lfgated theexisting-map cascade input on"period" in columns, but Spine names a Map's index column with its silent-default"x", so the gate never matched and every period row fell through to aunique(keep="last")that kept the last period's value. For a unit whoseexistingmap decays to0at expiry, that collapsed the cascade input to0, defaultedunitsizeto1000, and turnedexisting_count = existing/unitsizeinto a spurious fraction — capping continuous online at that fraction and making integer online impossible to commit. The reader now takes the per-entity MAX over whatever period rows exist, index-name-agnostically (CLAUDE.md Invariant #2). Byte-identical for scalarexisting(a single-row group-by is a no-op) and for every existing test fixture; corrects the reported online/count for retiring-unit models. Adds regression tests in the silent-default-index coverage file and a dev-doc audit of the readers that live outside the_param_shapesresolver.
Decomposition (Benders)
- The spatial-Benders cut self-check no longer hard-fails on solver
round-off. After each master solve,
_check_cuts_satisfiedasserts every just-appended optimality cut is honoured at the new master point. That check re-derives a row already present in the master LP, so it can only differ from the solved value by the solver's feasibility tolerance — which HiGHS measures on its internally-scaled matrix, making the unscaled slack scale with the cut row's coefficient magnitude, not its right-hand side. On early iterations a node group whose recourse cost overshoots produces large reduced-cost slopes, socost_randΣ slope·f̄nearly cancel: the rhs collapses toO(1)while the coefficients stay atO(1e6). The old tolerance keyed off the cancelled rhs and demanded a precision the ill-conditioned row cannot deliver, aborting an otherwise-converging solve (observed on a 7-node-group hydrogen- trade model: a7e-4slack on a2.66e6-scale row —2.7e-10relative). The tolerance is now keyed off the row magnitude, matching the fail-safe design of the sibling flow-clamp / lower-bound / sandwich guards: numerical noise is absorbed, a moderate gap is warned, and only a gross violation (a genuinely un-appended cut, whose recourse estimate sits near its large-negative floor) still hard-fails — now with the same three-section plain-English diagnostic as the other guards. The affected model converges to the monolith objective within tolerance.
Release 4.0.0b18 (30.6.2026) — fix zeroed existing capacity for scalar+Map entity classes
No database migration (schema stays v62) and dependency floors unchanged
(polar-high>=3.2.0, polars>=1.40, highspy<=1.14.0). Output-only fix — the
converged solution (dispatch, investments, optimal objective) is unchanged.
Outputs
- Fix zeroed
existingcapacity for a unit whose constant value shares an entity class with a period-Map sibling. When one unit carries a scalar (constant)existingcapacity while another unit of the same class carries a period-indexed Map, the Spine reader returns a single frame with one shared index column (namedxby Spine's silent default) in which the constant unit's row has a null index. The per-entity resolver classified rows by the presence of that column rather than per-row, so the null-index scalar became an explicit(entity, period=null)row that never joined the period grid and was silently filled with0. Downstream this zeroed the output-facing existing/total capacity for such units — surfacing most visibly as negative VRE curtailment (potential − flow, withpotentialcomputed from a0capacity), and as0in the capacity, capacity-factor, and per-entity pre-existing fixed-cost reports. The resolver now detects scalars per row (a null index broadcasts the constant across the period universe), in both mirror implementations. The LP itself was never affected — its flow bound reads a separate, scalar-correct existing-count parameter, and existing fixed cost enters the objective only as an opt-in constant term — so this is purely a reporting fix; re-running output processing on an existing solve produces correct reports without re-solving.
Release 4.0.0b17 (30.6.2026) — autoscale centres the objective (no more "excessively small costs")
No database migration (schema stays v62). Dependency floor raised to
polar-high>=3.2.0 (polars>=1.40, highspy<=1.14.0 unchanged). The
converged solution is unchanged on every model.
Numerical scaling
- The "Problem has some excessively small costs" HiGHS warning is fixed at the
source. FlexTool multiplies the whole objective by the legacy
scale_the_objective = 1e-6, which drags every cost coefficient ~6 decades down (e.g. a typical operational-cost band lands around[5e-5, 6e-1], below HiGHS'1e-4floor). polar-high 3.2.0's autoscale Layer 3 now geometrically centres the cost band over HiGHS' comfort zone via a power-of-twouser_objective_scale: it lands the band straddling1.0, clearing the warning and giving the cheapest costs several decades of headroom above the dual-feasibility tolerance — which helps duals (node/reserve prices) converge on degenerate dispatch LPs. The exponent is a power of two and HiGHS unscales the objective and duals on output, so the reported solution is byte-for-byte unchanged — only the magnitudes the simplex pivots on. Bands already in HiGHS' zone are left untouched, so well-scaled models see no change.
Housekeeping
- Genericized a handful of test fixtures and source comments (synthetic entity names in place of model-specific ones); no behavioural change.
Release 4.0.0b16 (30.6.2026) — Benders fail-safe numerical guards + clearer failure diagnostics
No database migration (schema stays v62) and dependency floors unchanged
(polar-high>=3.1.0, polars>=1.40, highspy<=1.14.0). Robustness fixes to the
Benders regional-decomposition driver plus a docs note; a model that does not use
regional decomposition is unaffected, and the converged solution is unchanged.
Benders regional decomposition
- Clamp the master coupling flow to capacity instead of tuning the tolerance.
The post-master-solve
f <= capself-check kept tripping on benign solver slack: HiGHS enforces feasibility on the internally-scaled master, so the unscaled slack exceeds both the nominal tolerance and the reportedmax_primal_infeasibility, and it grows as cuts accumulate (~3e-6at iter 4, ~1.2e-5at iter 8) — a fixed tolerance chases a moving target (this supersedes the b14 tolerance-from-feasibility approach). Instead, the upper bound is now evaluated at the strictly capacity-feasible point(C, min(f, cap)): clamping flow down can only raise region recourse cost, so it stays a valid whole-problem UB and the optimality cut stays valid (a subgradient inequality holds at any linearization point). The clamped flow is used consistently downstream (region pin, next cuts, incumbent); only a gross overshoot (>1e-2relative, or1e3×the solver's reported infeasibility) still hard-fails as a genuine read/stale bug. Clamp magnitude logged at DEBUG. - Fail-safe LB guards. The LB-monotonicity (
1e-6) andLB <= best_UBsandwich (1e-9) self-checks no longer hard-crash the whole run on benign numerical noise (discarding a good feasible incumbent). Both now separate noise from a genuine invalid-bound bug via a unified gross bandmax(tol, 1e-3): a small LB dip pins LB back to the previous valid bound and continues; a small LB/UB crossing is treated as converged (stop on the incumbent); a gross dip or overshoot still hard-fails (stale basis / corrupted cut / invalid-bound pathology). - Plain-English failure diagnostics. Every hard-failure site (master /
node-group not optimal, LB dip,
LB > UB, coupling overshoot) now emits a one-line summary plus "What this means" and "How to avoid it", using only FlexTool class vocabulary (node, group, connection, flow) — no model-instance terms — pinned by a static AST regression test.
Documentation
- Recommend setting an explicit
Mapindex_name(periodortime) for parameters that accept both a period map and a time series (e.g. flowGroupmin_instant_flow/max_instant_flow), so the value-domain fallback never has to guess; cross-linked from the flowGroup flow-limit reference.
Release 4.0.0b15 (30.6.2026) — flowGroup instant-flow obligation fix
Fixes a flowGroup min_instant_flow / max_instant_flow limit being silently
ignored — or crashing at load — depending on how the limit was authored. No
database migration (schema stays v62; FLEXTOOL_DB_VERSION unchanged) and
dependency floors are unchanged (polar-high>=3.1.0, polars>=1.40,
highspy<=1.14.0). Models that set no instant-flow limit on a flowGroup are
unaffected.
flowGroup flow limits
- Emit the instant-flow constraint for every authored shape. The
min_instant_flow/max_instant_flowobligation's RHS cap was resolved correctly for all shapes (constant, period map, time map, period+time — including Spine's silent-default"x"Mapindex_name), but the constraint's(g, d, t)support was built by a separate raw-source projection that detected the index axis by column name. That projection returned an empty support for an"x"-labelled period map, a constant, or a time map — so the>=/<=constraint was never emitted and the limit was silently ignored — and it crashed (ColumnNotFoundError: "t", atapply_projection_params) on a pure period map, which has notcolumn. Spine Toolbox cannot cleanly distinguish period from time maps, so authors routinely leaveindex_nameas"x", landing in the broken path. The support is now derived directly from the resolved cap and broadcast against the active(d, t)grid — exactly as the cumulative-flow limits already are — so support and RHS stay in lock-step and every authoring shape binds correctly. A regression test covers each cap shape plus the reported field case: an"x"-indexed period map with a non-zero first period and a zero later period under a single-period solve.
Release 4.0.0b14 (29.6.2026) — Benders master coupling self-check tolerance fix
Raises the solver-backend floor to polar-high>=3.1.0 (the Benders master
coupling self-check now reads Solution.max_primal_infeasibility, added in
3.1.0). No database migration (schema stays v62); other floors unchanged
(polars>=1.40, highspy<=1.14.0). A model that does not use regional
(Benders) decomposition is unaffected.
Benders regional decomposition
- Size the master coupling self-check from the solver's achieved feasibility.
The post-master-solve self-check that the chosen flow is supported by the
chosen capacity (
f <= cap) used a hard-coded1e-6absolute tolerance. For a unitsize-normalised coupling rowC - f >= 0with small capacity, HiGHS enforces primal feasibility on the internally-scaled row (default1e-7), which maps to a larger unscaled slack onf <= cap— a normal solver artifact, not an invalid bound. On a full-scale model this tripped at iteration 4 (slack ~3e-6> tol1e-6) and aborted an otherwise-valid solve. The tolerance is now derived from the master solve's ownSolution.max_primal_infeasibility(with a small relative floor), and the error message reports the actual slack vs. tolerance so a genuine, orders-of-magnitude coupling violation still surfaces loudly.
Release 4.0.0b13 (25.6.2026) — scenario-comparison & nodeGroup-dispatch plot fixes
Output/visualization-only bug fixes — no database migration (schema stays
v62; FLEXTOOL_DB_VERSION unchanged) and dependency floors unchanged
(polar-high>=3.0.0, polars>=1.40, highspy<=1.14.0). The LP, engine, and all
solver outputs are byte-identical to b12; only scenario comparison and the
nodeGroup dispatch plots change. Each fix carries a regression test.
Scenario comparison
- Retag single-level scenario columns to folder identity.
combine_parquet_filesre-tags each scenario's baked-in model tag to its folder name so two folders holding the same model scenario stay distinct — but the retag was gated onMultiIndexcolumns only. Entity-less, period-indexed variables (e.g.costs_discounted_p_) carry a single-level columnIndexnamedscenarioand so skipped it; two compared folders sharing a baked tag (e.g.trade_only_shipping_4/_2, both bakedtrade_only_shipping) then produced duplicate columns and crashedplot_outputs'stack()("Columns with duplicate values are not supported in stack"). Adds the single-level retag branch.
NodeGroup dispatch plots
- Correct consumer sign + restore connections. Consumers no longer render as
producers (the node→unit path double-negated the already-negated
unit_inputNode_dt_ee); non-aggregated connections (battery/transport) load unconditionally instead of only when an aggregated connection exists; and the redundant not-in-aggregate "total" connection path that double-counted is dropped. Fixes the matching per-node consumer bug too. - Keep both directions for same-node bidirectional units. A unit that both
produces to and consumes from the same node (reversible/storage with
input_node == output_node) no longer has its production side overwritten; the mixed-sign column is split into_pos/_negso charge and discharge both show. - Read connection flow by the node's physical end, not flow direction.
connection_leftward/rightwardare keyed by the connection's physical end node, but the plot picked a frame by the mapping table's flow direction, so a unidirectional connection whose in-group node sits on the "wrong" end vanished from the plot (while correct in the engine's authoritativenodeGroup_flows). Both paths now use a single end-correct lookup (_connection_series_at_node).
These plots now agree with the engine's authoritative group-flow output; real elec-group plots are byte-equivalent where the old code happened to capture everything, with regression tests added for the cases it dropped.
Release 4.0.0b12 (25.6.2026) — Benders regional decomposition (v62) replaces Lagrangian; greenfield cross-region trade; GUI fixes
Requires a database migration to v62 (FLEXTOOL_DB_VERSION 61 → 62) and
raises the solver-backend floor to polar-high>=3.0.0 (3.0.0 removes the old
LagrangianProblem driver and ships the Benders cut-append / warm-restart /
parallel primitives this release uses); other floors unchanged (polars>=1.40,
highspy<=1.14.0). This is a breaking schema change: the spatial regional
decomposition is changed from the dual-subgradient (Lagrangian) scheme
(introduced in the unreleased b11) to a Benders decomposition. Existing
databases are auto-migrated by the v62 step; a model that does not opt into
decomposition solves exactly as before (the monolithic LP and all outputs are
unchanged).
Benders regional decomposition (schema v62)
- New algorithm. A coordinating master problem holds the inter-regional trade flows and the trade-connection investment plus one recourse variable per region; each region is an operational subproblem solved with the master's trade fixed, returning duals that become optimality cuts the master accumulates. The master objective is a valid lower bound and the loop converges on the optimality gap. This fixes the earlier subgradient scheme, which collapsed greenfield cross-region trade to autarky with an invalid bound above the true optimum.
- Schema renames. The
decomposition_schemesvalue-list memberlagrangian→benders, and thedecomposition_methodsmemberlagrangian_region→benders_regional(every authoredsolve.decomposition/group.decomposition_methodvalue is rewritten in place by the migration). - Knobs. The
solve.lagrangian_alpha/lagrangian_max_iter/lagrangian_toleranceparameters are dropped (Benders has no subgradient step) and replaced bysolve.benders_max_iter(default50) andsolve.benders_tolerance(default1e-3, the relative optimality-gap threshold). - Invest → dispatch handoff. A Benders invest solve assembles the owner-selected invested trade + in-region capacity into a whole-system handoff so a downstream rolling or monolithic dispatch solve consumes the realised capacity end-to-end.
- RP-weight consistency. The master is built by
build_flextoolover a network-only reduced model — the same emit the monolith uses — so the master trade-flow cost and the region recourse costs carry the same representative-period / timestep weights and sum to the monolithic objective. - Warm master + parallel regions. The master is grown one optimality cut per
iteration and warm-re-solved off the retained basis
(
WarmProblem.solve(retry_on_unknown=True)) instead of rebuilt, removing the super-linear cold-presolve cost at scale; the region recourse subproblems solve in parallel viapolar_high.parallel. The worker count is the machine-localFLEXTOOL_BENDERS_WORKERSenv var (0/auto = cpu − 1; no DB/schema knob, so a many-core author's count never travels to a small box) — superseding the b11--lagrangian-workersflag / GUI knob, which never shipped. - Implementation. Driver in
flextool/engine_polars/_benders.py(master + multi-cut loop,solve_benders/BendersResult); region slicing inflextool/engine_polars/_region_filter.py. Uses the polar-high primitivesWarmProblem.add_cut_row/add_recourse_col/solve(retry_on_unknown=…)and thepolar_high.parallelhelpers (floor raised topolar-high>=3.0.0). Benders is HiGHS-only (the master is a persistentWarmProblem). - Stochastic branch weights reach the objective. Confirmed (and pinned by a
regression test) that per-branch probability weights from
solve.stochastic_branchesflow through the derived cascade into the objective — the stochastic twin of the representative-period-weight fix above; the dense1.0baseline set inapply_derived_ais superseded by the real sibling-normalised weights inapply_derived_g. - Clean Benders progress log. The per-sub-solve native HiGHS output (master +
N region solves each iteration) is muted by default via
WarmProblem.set_output_flag(False); instead the orchestrator streams a concise per-iterationLB / UB / gapline and a lock-guarded "iter k:done (obj=…)" line as each region finishes (fired from the parallel worker via a new solve_benderssubsolve_callback). The start line carries the run config inside its tag so it stays visible past the long region list. SetFLEXTOOL_BENDERS_VERBOSE=1to restore the full native HiGHS log.
Desktop GUI
- Fix a crash when the migration dialog's
grab_setfires from a callback on a not-yet-viewable window. - Smaller default code/log font, with the code font size now exposed in settings.
- Simplify the Add-to-execution button to a static label driven only by the checked rows (enabled when ≥1 scenario is checked, disabled otherwise), dropping the per-scenario "Create vs. Update" results-on-disk detection.
Release 4.0.0b11 (24.6.2026) — per-solve Lagrangian decomposition (v60/v61); thread-parallel subsolves; xlsx round-trip + output fixes; per-monitor GUI placement
Requires a database migration to v61 (FLEXTOOL_DB_VERSION 59 → 61, in two
steps): v60 adds the per-solve solve.decomposition selector and its Lagrangian
knobs; v61 adds timeset.representative_period_weights. Raises the solver-backend
floor to polar-high>=2.9.0 (the per-solve decomposition path and the new
--lagrangian-workers knob use its thread-parallel subsolves / per-subsolve
callback); other dependency floors unchanged (polars>=1.40, highspy<=1.14.0).
A model that does not opt into decomposition solves exactly as before — the LP and
all outputs are unchanged for the monolithic path.
Per-solve Lagrangian decomposition (schema v60)
- DB-driven decomposition. A solve now selects its own scheme via the new
solve.decompositionparameter (value listdecomposition_schemes); set it tolagrangianto decompose that solve's investment problem by owner region. The Lagrangian behaviour is tuned per solve bylagrangian_alpha,lagrangian_max_iterandlagrangian_tolerance(grouped undersolve_advanced). The old--decomposition*CLI flags are removed — the choice lives in the database, not the command line. - Investment → dispatch handoff. A decomposed investment solve assembles the owner-selected invest/divest variables and deposits the resulting capacities for the downstream rolling dispatch solve to consume, so a Lagrangian invest stage hands its build decisions to an ordinary dispatch stage end-to-end.
- Live progress in the log. The Lagrangian driver now prints a per-iteration
line plus a closing dual-gap summary block; the progress callback is forwarded to
polar-highonly when the installed version supports it.
Thread-parallel Lagrangian subsolves
--lagrangian-workers N(CLI; mirrored toFLEXTOOL_LAGRANGIAN_WORKERS) and a machine-local "Parallel workers (Lagrangian)" spinbox in the GUI solver-options dialog (0= auto = cpu − 1) cap how many region subproblems solve concurrently within each barrier. The count is plumbed topolar-high'smax_workersand a per-subsolve progress callback, both version-gated viainspect.signature— an olderpolar-high(which floors at 2.9.0 here, but the guard is defensive) silently stays on the sequential path. The worker count is a per-machine setting (GUI YAML, not a DB/schema parameter), so a many-core author's count never travels to a small box. Default behaviour is unchanged — with no flag the solve stays sequential.
Representative-period weights (schema v61) & xlsx round-trip
- New
timeset.representative_period_weights(rank-2 nested Map: base-period start → representative-period start → weight) produced by the representative-periods preprocess tool; its presence enables RP-aware storage binding (*_blended_weights), its absence silently downgrades those methods to non-RP behaviour. Mutually exclusive withtimeset_weightson the same timeset. Its Map axes are named for a clean Excel export. - Excel round-trip correctness. Several DB → xlsx → DB corruptions are fixed:
constant/periodic-shared scalars no longer duplicate; float leaves and the period
axis survive Map round-trips; ladder parameter identity, tier order and
solve-parameter export are preserved; empty
Array/Mapvalues round-trip via a sentinel; and a generic multi-index nested-float Map path fixes a latent stochastic 3-D/4-D drop.
Output & plot fixes
- Node prices for coarse-resolution block nodes. Nodes on a coarser
new_stepdurationresolution land in the block balance equation; the per-(d,t)price output extracts and broadcasts that block dual instead of raising anode_prices_dt_eKeyError. - Cost-by-entity comparison plot is now grouped + stacked correctly (and a
frivolous
scenario_ruleis dropped). - Dispatch plots: build each scenario's dispatch DataFrame once to avoid pandas
fragmentation; silence a degenerate dispatch y-limit; correct the
connection_dt_eeecomparison column spec.
Input fix
- Cast
p_flow_maxvalues toFloat64on read, so an integer-typed limit no longer trips a dtype mismatch downstream.
Desktop GUI
- Per-monitor window placement. Child windows (Execution-jobs / Results) open on
the main window's monitor and remember their geometry per monitor configuration;
placement uses
screeninfofor flicker-free, multi-monitor-aware positioning. Fixes the earlier single-monitorwinfo_screenwidth()assumption that could strand a child window off-screen. - Windows DPI font scaling, header style and the Add-input-source dialog sizing are corrected; startup logs ground-truth DPI / font-scaling for diagnosis.
Release 4.0.0b10 (16.6.2026) — never open child windows off-screen
GUI-only release — no database migration (schema stays at v59;
FLEXTOOL_DB_VERSION unchanged) and dependency floors unchanged
(polar-high>=2.6.0, polars>=1.40, highspy<=1.14.0). The LP, engine, and
all outputs are byte-identical to b8.
Desktop GUI
- Never open the Execution-jobs / Results window off-screen. Both placed
themselves to the right of the main window (
main_x + main_w) whenever the screen was wider than 2400 px; with b9's full-screen main-window sizing (main_w ≈ screen_w) the child opened entirely off the right edge — which Windows renders as a black, unrecoverable alt-tab thumbnail — andwinfo_screenwidth()only sees the primary monitor, compounding the bad offset. Side-by-side placement is now gated on real free space to the right (exec_x >= 0and enough room for the minimum width); otherwise it falls back to the overlap layout that always stays fully on the primary screen.
Release 4.0.0b9 (16.6.2026) — live free-RAM execution admission; deterministic Windows tk scaling
GUI-only release — no database migration (input schema stays at v59;
FLEXTOOL_DB_VERSION unchanged) and dependency floors unchanged
(polar-high>=2.6.0, polars>=1.40, highspy<=1.14.0). The LP, engine, and
all outputs are byte-identical to b8.
Desktop GUI
- Live free-RAM execution admission (replaces the static per-job
reservation). Raising "max parallel executions" no longer fails to start new
scenarios while wrongly reporting "memory limited": the old admission summed
each running job's dispatch-time budget snapshot against total RAM minus a
reserve, so with N jobs running the snapshots already filled the headroom and
raising the worker count never freed room (a permanent false memory-limit).
Admission now keys off actual live free RAM (
vm.available), re-measured every scheduler tick, with a per-job estimate ladder (explicit cap > learned history ×1.05 > auto fair-share) and a predicted-available figure that subtracts only the unrealised growth of still-warming jobs. - Deterministic Windows
tk scaling=dpi/72. Tk on Windows does not auto-scale for display DPI (it stays pinned at the 96-DPI baseline), so point-sized fonts rendered tiny on a scaled screen while the old(dpi/96)*0.85fudge plus a raise-only guard made the result wildly machine-dependent. With the process DPI-aware,tk scalingis now set to the documented pixels-per-pointdpi/72unconditionally on Windows (1.0 at 100 %, 1.5 at 150 %, …); Linux keeps its tuned 96-baseline raise-only behaviour.
Release 4.0.0b8 (16.6.2026) — small-number coefficient cutoff (v59); legacy Excel import; solver-options GUI
Requires a database migration to v59 (FLEXTOOL_DB_VERSION 58 → 59; adds the
model.small_number_threshold parameter). Raises the solver-backend floor to
polar-high>=2.6.0 (the cutoff sets its new coef_zero_threshold); other
dependency floors unchanged (polars>=1.40, highspy<=1.14.0). LP-neutral by
default — the cutoff is a no-op at its 0.0001 default unless a model carries
coefficients below it, and the rest of the release is fixes and importer/GUI
work.
Model-wide small-number coefficient cutoff (schema v59)
- New
model.small_number_threshold(float, default0.0001): LP matrix coefficients and RHS terms whose absolute value is below it are floored to0.0when building the problem, narrowing the LP's numerical range (conditioning). The floor is a genericpolar_highfeature (Problem.coef_zero_threshold) applied at every coefficient/RHS finalize point; FlexTool reads the parameter (spec → provider →FlexData) and sets it inbuild_flextool. Registered in autoscalePARAMETER_TYPES; v59 migration adds the parameter; schema, canonical DBs and fixtures regenerated.
Legacy (pre-v25) specification-Excel import
- The specification-format Excel importer now handles older template layouts
(changelog v12+): coerces numpy scalars to native Python before
to_database(fixes an int64 crash), builds an empty unstacked frame for header-only parameter sheets (instead of a pandas "need at least one array" raise) while still reporting genuine errors with the sheet name, remaps renamed legacy parameters (period_timeblockSet→period_timeset,block_duration→ timeset_duration,has_state→has_storage,variable_cost→ other_operational_cost) and aliases the pre-v25 time-structure sheets. The GUI conversion flow is hardened/clarified.
Desktop GUI — solver options
- Fix the solver-options dialog never opening (it referenced a non-existent
self.root;MainWindowis the Tk root). Add a MIP relative gap control (a checkbox gates emission — off defers to the.optbaseline / solver default; default0.001,0valid) routed GUI →--solver-mip-gap→FLEXTOOL_HIGHS_MIP_GAP, mirroring the time-limit knob. Presolve "choose" is now actually forwarded (was silently dropped to the engine's pinned "on"). Add runtime-file bootstrap. Clamp the main-window width to the screen (only height was clamped) so a DPI-aware natural width can't run off a small display.
CO₂ total-cap & engine fixes
- Report CO₂ for
co2_max_total(total-cap) models: the emissions output set wired only the period-cap and price flow sets; the multi-period total-cap port (flow_from_co2_capped_total,group_co2_max_total) was never added, so aco2_method=totalmodel (e.g. SouthAfrica) reportedCO2 [Mt] = 0despite a correct engine cap. - Read the CO₂-accumulator unitsize on its actual entity axis — the
rolling-accumulator preferred the
e-keyedp_all_entity_unitsizecarrier but unconditionally selectedp, crashing the output stage on the firstco2_method=totalmodel after an Optimal solve. - Key the profile-flow source on the real input-node arc: a direct
(constant-efficiency) unit carrying an output-node profile keyed the
constraint on
(unit, unit, sink)instead of the realv_flow[unit, input_node, sink]arc, matching nothing → empty row → HiGHS presolve Infeasible (e.g. a nuclear unit with afixedoutput profile). - Make the v28 interest/discount rename collision-safe: it now checks the target name is free, so a DB that already carries both names (reachable via an interrupted migration + template re-apply) no longer raises.
Output fixes
- Empty the scenario
output_parquet/<scenario>/dir before writing so retired/ renamed outputs don't linger and trip the scenario-comparison "not registered" warning (done late, afterresultsis built, so a failed run keeps prior outputs).
Release 4.0.0b7 (11.6.2026) — self-documenting output metadata; per-entity cost break-down; unit corrections
No database migration — the input schema stays at v58
(FLEXTOOL_DB_VERSION unchanged). The results SpineDB schema grows (six new
per-entity cost parameters + back-filled metadata descriptions; pinned
parameter-definition count 52 → 58), but results DBs are write-only outputs, so
there is nothing to migrate. Dependency floors unchanged (polar-high>=2.5.0,
polars>=1.40, highspy<=1.14.0). LP-neutral: the solve and every system-level
total are byte-identical to b6 — the changes are output presentation,
self-documenting metadata, additive per-entity outputs, and reported-unit
corrections. One entity-first dt golden (group_flow__dt.csv) is regenerated
for a fixed solve-column, and a handful of reported values are corrected to
ground-truth units (CO₂, invest-marginal — labelling, not LP changes).
Self-documenting output metadata
Output values now carry their own unit + semantics, derived once from the output-stage transform and surfaced everywhere — so the meaning of a column can't drift from the code.
- A single-source
_output_metaderives per-column(unit, semantics)from the transform each output applies; 84/84 processed outputs are declared, with per-column maps for mixed-unit tables. The ~22 inlineddt→dperiod- annualization expressions are centralized onto oneannualize_dt_to_dhelper (behaviour-preserving, golden-byte-identical includingtimestep_weight≠1). - Metadata is embedded in the parquet footer and surfaced in every renderer:
a CSV
datapackage.jsonsidecar (the CSVs themselves stay byte-identical), an Excel metadata sheet + header-cell hover comments, Spine fills the emptyparameter_definitiondescriptions at import (rich hand-written ones are never clobbered), and plots / the result viewer label the value axis and show a hover tooltip — including the on-the-flya(total) andw(weekly) variants.result_key_summary()/result_variant_summary()back the hovers. - Reported-unit corrections to ground truth:
invest_marginal→ CUR/kW(h) (a single ÷1000 at the dual-synthesis choke point); per-entity CO₂ is t/a (the schema's Mt was 1e6 too large) and the system total is Mt-over-horizon;dtgroup/node flows and raw inflow are the un-integrated MW level (not per-step MWh);startup_cumulativeis units/a; anddefault_plots.yamlvalue-axis units are corrected (nodeGroup energy, marginal investment,entity_annuity= CUR/MW/a). Schema-vs-_output_metadrift is guarded by a CI ratchet rather than per-run log spam.
Per-entity cost break-down
Six additive, period-level outputs collapse every cost category per
entity instead of system-wide:
cost_{unit,connection,node}_{annualized,discounted}_d_ec (annualized M CUR/a +
discounted M CUR). No existing system total changes.
- Fuel cost is attributed to the consuming process; CO₂ cost is summed over every priced group a process touches. Built by vectorized slice+scale (no per-entity / per-period loops).
- Emitted to CSV, the results SpineDB (
Map(category → solve → period → value)on the unit/connection/node classes; six new schema params, count 52 → 58), and plots ("Cost by unit/connection/node" — stacked categories on period bars, one subplot per entity, annualized + discounted, comparison grouped by scenario). Registered in scenario-comparison loading. - Reconciliation tests: the cross-kind per-entity sum equals the system summary
per category and period (rtol 1e-9), with explicit non-zero value checks for
starts/commodity_sales/fixed cost invested; 13-scenario goldens.
Plot & output fixes
- Solve-column injection is keyed off the named
period/timeindex levels instead of positional slots, so an entity-firstdtoutput (e.g.group_flow__dtwith a(group, period, time)index) no longer emits a blank solve column. Behaviour-preserving for all period/time-first indexes; one affected golden regenerated (other values byte-identical). - Bar value-label geometry: orientation-aware slot floor, thin single bars under
labels, slot room scaled with label thickness; variant-aware units for the
a/wplot variants.
Desktop GUI
- Open
.sqliteresults DBs in the Spine DB Editor (with a fallback popup when it is unavailable); DPI-aware sizing fixes; output-metadata hover in the result viewer.
Fixes & tests
- NaN-guard the capacity factor when capacity is zero.
- Migrate the
input_entity_colorstest fixtures to the v58 top-levelflowGroupclass, and regenerate thebase_weightednode__d.csvgolden for the timeslice-weight-aligneddt→daggregation.
Release 4.0.0b6 (10.6.2026) — period energy balance; flowGroup carve-out; Spine Toolbox ↔ project interoperability
Requires a database migration to v58 (FLEXTOOL_DB_VERSION 57 → 58; carves
flowGroup out of the group class — see below). Raises the solver-backend
floor to polar-high>=2.5.0 (it routes the HiGHS log through Python's
sys.stdout, so the solver log is visible under the Spine-Toolbox Basic Console
on Windows — see the Toolbox section); other dependency floors unchanged
(polars>=1.40, highspy<=1.14.0). The release is LP-neutral / byte-identical
to b5 on all existing fixtures; the sole results change is the
balance_within_period correctness fix, which only moves numbers for nodes that
actually carry that method under non-uniform timeslice weights.
Period energy balance — balance_within_period nodes
Ports the legacy balance_within_period node type to the polars engine
(nodeBalancePeriod_eq): a per-(node, period) energy balance for annual-budget
nodes (e.g. a yearly gas-import node), closing a bug where such a node was a free
unconstrained source.
- Emit
nodeBalancePeriod_eq— one row per(n, period), each flow/slack term summed overtwith RHS-Σ_t inflow, no storage / self-discharge terms, excluding nodes that also carry a state; thevq_state_up/downslack domain is widened tonodeBalance ∪ nodeBalancePeriod. Gated to a no-op (and collapses to the per-dtnodeBalance_eq) when no period nodes are present. - Annualize every period-balance term (flows, sections, back-flow, slacks,
and the inflow RHS) by the per-
(d,t)timeslice representativeness weight that the cost objective, demand scaling, and thedt→denergy/emission outputs already use — so a representative/timeslice-weighted solve conserves physical annual energy, not the unweighted timeslice sum (verified: per-period import/draw 0.838 → 1.000000, slack 0). The per-dtbalance correctly omits the weight; only a period balance annualizes. - Rename the internal
p_rp_cost_weight→p_timestep_weight(Python field, builder, output attribute, and wire-key): it is a temporal representativeness weight — neither RP-specific nor a cost weight — that drives demand, energy and cost annualization alike. No autoscale-registry or schema change.
flowGroup carve-out (schema v58)
Splits the overloaded SpineDB group class: group keeps the node-balance /
system / investment / structural concerns, and a new flowGroup class
(3-dim (process, node) membership) owns the flow limits and aggregation.
- New
flowGroupentity class +group__unit__node/group__connection__nodere-homed toflowGroup__*; the engine read/solve path (flow-limit readers, the 3-dim membership index-set populator,_param_shapesallow-list, autoscalePARAMETER_TYPES) now resolves the four flow-limit params againstflowGroup. flow_aggregatorbecomes a method enum (none/dispatch_plots_only/standalone_aggregator_only/both) that subsumes the dropped booleanoutput_flowGroup_indicators;output_nodeGroup_dispatch/output_nodeGroup_indicatorsare renamed toprint_dispatch/print_indicators.- A double-count overlap warning: a flow arc bound into two dispatch-bound
aggregators (
dispatch_plots_only/both) is named loudly — this was the root-cause class that motivated the carve-out; overlap among standalone aggregators is legitimate and not warned. - New signed
group_flow__dt.csvstandalone timewise output; fixes a latentgroup_process_nodebackfill bug; heals missing DC-power-flow parameter metadata. Migration authored with correctedflowGroup/print_*descriptions. Validated LP-neutral by the full scenario suite.
Spine Toolbox ↔ project interoperability
The Spine Toolbox workflow becomes a first-class peer of the FlexTool GUI: a
Toolbox run now roots all its outputs at a user-chosen project folder,
the per-project plot_settings.yaml is honored, and a results SpineDB can be
produced from the processed parquet without re-solving. No forced migration
— existing workflows keep working unchanged.
Project-folder output rooting
- A Toolbox run roots
output_parquet/,results.sqlite, plots, and the per-projectplot_settings.yamlat a project folder instead of the repo root. The folder is chosen by editing one line in the gitignored, user-local filetemplates/project_folder.txt(seeded byflextool-update). The path may be absolute or relative to the FlexTool root; blank / comment-only reproduces the old repo-root behavior. Editing the file never dirties git, so switching projects needs no committed change. - This replaces the old
flextool_location.txtmechanism. The FlexTool run Tool's command argument changed from--flextool-location templates/flextool_location.txtto--project-folder-file templates/project_folder.txt. The legacy--flextool-locationCLI argument still works as a fallback for old / forked setups.
Results SpineDB from parquet replay
output-spinedb=truein the Output settings DB writes<project>/results.sqlite(each scenario as a Spine alternative) from both workflow steps: the FlexTool run produces it from the live solve (with all params), and the "Re-create results" step (re)builds/augments it by replaying the processed parquet — no re-solve (e.g. to add the SpineDB afterwards without re-running, or to reduce the scenario set via its filter).- Caveats on the replay path only: the two inflation/discount-factor params
are omitted (they need the live solve); for a bidirectional connection the
(source, sink)byname uses the parquet(node_1, node_2)geometry (1-way connections are exact). The native run path writes the full set. output-spinedbnow defaults totruein the Toolbox output-settings template, so a fresh project getsresults.sqlitewithout flipping the option.- The cold-start results-DB build is cross-platform: the create-from-JSON is
serialized by an exclusive sidecar lock (
fcntl.flockon POSIX,msvcrt.lockingon Windows) and the atomic publish retries the transient Windows "file still in use" (WinError 32)os.replace, so parallel scenario writers into one sharedresults.sqliteno longer race or fail on Windows.
Per-project plot_settings in Toolbox
- A
plot_settings.yamlplaced in the project folder is now honored by both the per-scenario plots and the comparison plots in the Toolbox track (previously it silently used the bundled default).
GUI interoperability
- Outputs under
projects/<Name>/output_parquet/<scenario>/are picked up automatically by the FlexTool GUI (the folder name is the result identity; a Toolbox run shows under "source 0" — cosmetic). Conversely, the Toolbox can run a GUI-made project by pointing "Input data" at the project's input DB and settingproject_folder.txtto that project. - Spine-Toolbox Basic Console (Windows): the run survives the Console's
python -iREPL, and the full HiGHS solver log is now visible there — the latter viapolar-high>=2.5.0, which re-emits the log throughsys.stdout(the Console only captures the Python stream, not the native fd-1 write).
Migration
- Run
flextool-update— it seedstemplates/project_folder.txtand refreshes the settings DBs. The refresh now back-fills new parameter definitions (e.g.output-spinedb) into existing settings DBs that predate them — schema only, so existing user choices are preserved — so options added in later versions appear in the editor without rebuilding the DB by hand. - Only if you forked / customized
.spinetoolbox/project.json: update the FlexTool Tool's command argument from--flextool-location …to--project-folder-file <project>/templates/project_folder.txt.
Release 4.0.0b5 (8.6.2026) — multi-solve output union; plot colour/order system; timeslice-weight alignment; SpineDB output; Python 3.13
Requires a database migration to v57 (FLEXTOOL_DB_VERSION 56 → 57;
timeslice-weighting doc descriptions). New dependency floor polars>=1.40
(older polars silently bypasses autoscale Layers 2/3 — see below);
polar-high>=2.4.0 and highspy<=1.14.0 unchanged. This release contains model
and constraint changes; they are byte-identical to b4 where the relevant inputs
(timeset weights, capacity coefficients, availabilities) are uniform/absent.
Multi-solve output unioning
A nested / multi-solve run (e.g. invest → dispatch, or several rolling solves) now unions each sub-solve's realized outputs into one complete result set instead of letting the last writer win.
- A cross-solve
OutputTimelineBundle(realized dispatch + invest union, with[d,t]→solve/period→solvemaps) is built once from the authoritative realized timelines; output writers consume it to union per-roll param/set slices, oracle-parity with a single-solve run. - A raising disjointness guard replaces the legacy silent earliest-wins
trim: overlapping realized
(period, timestep)or invest period across solves is now aFlexToolConfigErrornaming both solves, not a masked truncation. - Deterministic period-share annualisation: the per-period dispatch denominator is reduced by max-per-period, fixing an order-dependent dedup that could intermittently inflate dispatch totals/costs (≈120× on short invest windows) depending on filesystem glob order.
- The realized invest-eligible set is unioned so the invested capacity breakdown survives a dispatch-only child roll; invest cost inputs union across sub-solves.
Plot colour & ordering system
A per-project, editable colour and stacking-order system for all plots, driven
by <project>/plot_settings.yaml (falls back to the bundled default when
absent; byte-identical output for projects without one).
- Colours/order resolve from the project file through both PNG export and the result viewer; entity colours are auto-seeded from the input DB at job start (additive, append-only) so the list is complete and editable while scenarios run.
- New GUI "Colors, order…" picker: a non-modal window with an embedded HSV colour picker (SV square + hue slider, no Pillow), side-by-side positive/negative swatches with an auto-breaking link, drag/keyboard row reordering, multi-select group moves, Refresh-from-DB, and Undo/Redo.
- Colour simple bars / time-series / dispatch by entity class
(
color_entity_class), including subplot- and file-split entities; thegroupclass is split into nodeGroup / flowGroup by membership. - Comparison plots colour their scenario series from an editable
scenariossection; the deadconfig.yamlsystem is removed.
Timeslice / representative-period weighting alignment
One representative weight (p_rp_cost_weight, = 1 without timeset_weights) is
now routed through inflow demand-scaling and every extensive dt→d
energy/emissions output aggregation — matching the cost objective, which already
used it — so served == reported == costed energy. Adds an f annualisation-
divergence diagnostic + warning; missing years_represented is treated as unit
weights (no noisy warning). Doc descriptions authored via the v57 migration.
Byte-identical when weights are uniform/absent.
SpineDB results output
A new spinedb write-method dumps the processed result tables into a Spine
database using the FlexTool results schema, alongside the CSV / parquet / Excel
outputs.
write_spinedbwriter with round-trip tests + the checked-in results schema JSON; wired into the dispatcher and CLIs (--write-methods spinedb, or theoutput-spinedbsetting in the output template). One Spine alternative per run; time series as nestedMapvalues on the model entity classes.- Desktop GUI: a "Comparison SpineDB" File-outputs row drives the new method.
Documented in
docs/results.md.
Investment duals
- Synthesize a complete signed effective investment dual (positive = more
investment would lower total cost); extract the min-side duals; retire the
redundant standalone
dual_invest_*_d_eoutputs. The sign convention is stated on the plot value-axis label. - Fixes:
1/scale_the_objectiveon thev_investreduced-cost writer; period- cap constraints aligned tocoef = unitsize/ RHS-in-MW; a guaranteed period axis for the sole-bindingmaxInvest_total; cover cross-solve invest candidates in the densify universe.
Model & constraint changes
- Densify availability in the profile/online and node-state RHS so default-1.0 units (wind / solar / coal, profile storage nodes) aren't inner-join-dropped to zero generation.
- Wire
capacity_min_coeffinto theminFlow_minloadfloor and applycapacity_max_coeffto direct arcs (both were previously ignored on those paths). - Reverse flow for lossless 2-way connections (
method_2way_1var_off) via a shared non-negativev_flow_backaux unifying DC and non-DC arcs (verified against a hand-calc) — a lossless 2-way connection could not previously carry reverse flow. - Rename the four flow-bound constraints to per-edge names
(
maxToSink* → maxFlow*,minToSink_minload → minFlow_minload); LP-internal identifiers only, no output/golden/migration impact. - INFLOW-1: derive the inflow signed-split on synthetic / rolling sub-solves
so the
non_syncandcapacity_margindemand budget survives (was silently dropped, pinning VRE to zero).
Environment robustness — solver-stack auto-heal
Compiled-extension native crashes (a bare exit code — Windows 0xC0000005,
POSIX SIGILL/SIGABRT/SIGSEGV — that no try/except can catch) are now diagnosed
and, where fixable, auto-healed.
flextool/env_check.pyprobes the whole solver stack in one child process — polars (a real SIMD op) → highspy (Highs) → polar_high (Problem) — flushing a marker after each so the surviving markers pinpoint the dead extension.- Per-component remediation:
polars→polars-lts-cpu;highspy1.14.0 →highspy==1.13.1(HiGHS#2964 import-crashes 1.14.0 on Server 2019 / Win10 1809-era machines); a clear "rebuild your environment" message when the fault is the Python environment itself (Anaconda-mixed venv, network drive), which no pip install can fix. - Wired into self-update (auto-fix), GUI startup (one-click fix, fingerprinted
OK-cache), and the scenario crash path.
faulthandlerenabled incmd_run_flextool. Neither remediation is made the default;FLEXTOOL_NO_ENV_CHECKopts out. 31 tests intests/test_env_check.py.
Dispatch, comparison & import
- Folder name is the scenario identity at the load boundary, so two output folders holding the same model scenario (different settings) stay distinct in legends/titles/filenames instead of collapsing under the shared in-data tag (guarded no-op — byte-identical — on the DB/CLI path).
- Slice dispatch / comparison by the in-data scenario tag, not the output-folder
name; allow a comparison view via an override
map_dimensions_for_plots. - Old-FlexTool (FlexTool-2) import now opens the execution window and runs the migration as its own visible job; the importer is pinned to the frozen v56 schema and always migrates.
Desktop GUI
- Theme-aware run-log colouring (command echo, timer/memory rows, solve-start,
HiGHS dump block, warnings/errors blended from the log widget's live colours);
clearer solve-phase layout with an
Autoscale by polar-high:checkpoint so the "Solver" row reports pure HiGHS time. - Memory guard master switch (session-only) + per-job "Force start" override to admit a scenario past the memory limit; a jobs-list right-click menu (Force start / Pause / Kill / Remove).
- Track retired input sources and fix source-number drift.
Packaging — Python 3.13 + dependency floor
- Officially support and CI-test Python 3.13 (classifier + a 3.13 leg on
Linux / Windows / macOS); the full suite passes on CPython 3.13 (polars 1.41,
pandas 3.0).
requires-pythonstays>=3.9. - Pin
polars>=1.40: 1.40 introducedpl.struct(...).is_sorted()(used by polar_high's_verify_dense_sorted); older polars raised on it and silently bypassed autoscale Layers 2/3.
Plot & output fixes
- Keep value labels on sub-pixel bars in comparison charts; avoid a stack + grouped-bar conflict; use the settings-group name as the default disk-plan title; apply category/entity colours regardless of legend layout.
perf(outputs): densify entity columns in one reindex instead of a per-column loop.
Internal cleanup
- Remove the experimental
--fast-single-solveengine path and a temporary diagnostic dump block. - Stop emitting unconsumed scratch/middle-product CSVs and delete the dead
derive_*emit wrappers across the per-roll emitters; fuse the inflow-peak stages into one closed-form graph; remove a dead duplicate_inflow_scaling.pyand an orphaned golden; reconcile the emit manifest. - Drop the dead
group_label_widthfrom the bar-chart layout (the expand-axis group was already folded into the bar tick label). - The test suite now runs in parallel by default (
pytest-xdist,-n 8; a[dev]dependency, wired into CI). Worker processes keep the per-test global-state resets isolated;-n 0disables it for interactive debugging.
Release 4.0.0b4 (2.6.2026) — vectorized per-roll preprocessing; glpsol scaffolding removed
Per-roll preprocessing — vectorized
Every heavy per-roll preprocessing parameter/set emitter now derives its output
frame with vectorized polars — a dense (entity × dt) or (entity × period) grid,
a left-join for each parameter source, a priority coalesce across the cascade
branches, and group-by-sum folds for stochastic / parent-period terms — once per
roll over that roll's own window, replacing the per-cell Python loops. There is no
cache and no full-domain frame. On the RETO-Africa DES scenario (9 rolling
solves) the run is ~25% faster end-to-end, with every output byte-identical to
the previous release and the global peak working set unchanged (~18 GB). No model or
input changes.
- New shared module
engine_polars/_vectorize.py: the entity×dt / entity×period grid builders (carrying row-order keys), the dict→lookup lift, the stochastic + parent-period fold, andrepr-based value rendering. - Converted families:
pdtProcess,pdtNode,pdtProcess_source/pdtProcess_sink,pdtCommodity,pdtGroup/pdGroup/pdtProfile, the varCost pair +pssdt_varCostfilters,pdtNodeInflow, and the inflow- and lp-scaling pipelines. - Each heavy derive is gated by a per-family parity test against a reference
implementation: strict byte-equality for the lookup/coalesce families,
rtol≈1e-12tolerance for the genuinely-summing families (inflow / lp scaling, multi-term folds).
Solver / packaging
- Removed the leftover scaffolding for the already-retired glpsol binary: the
build-glpsol.ymlworkflow, thebin/glpsolCI permission step, and the GLPK clauses inLICENSE.txt. HiGHS (viahighspy) is the only solver. The DB migration that rewrites a legacysolver=glpsoltohighsis unchanged.
Docs
docs/dev/engine_polars.mddocuments the vectorized per-roll emit;CONTRIBUTING.mdandCLAUDE.mdrecord the parity-test obligation and the byte-parity invariants.
Release 4.0.0b3 (1.6.2026) — solver backend: bounded-memory autoscale + block-COO; plot & GUI fixes
Solver backend — bounded-memory autoscale + block-COO
Requires polar-high >= 2.4.0.
FlexTool's automatic scaling (Layers 1-3) and LP coefficient build no longer
materialise wide coefficient products to read magnitude statistics. polar-high's
new block-COO evaluation slices the pre-sorted (period, timestep) dense axis as
contiguous numpy blocks, and the autoscale range / bucket readouts walk the
constraint spine in bounded row-batches. On the large RETO-Africa DES scenario
(9 rolling solves): autoscale peak working set dropped ~46 -> ~23 GB, ~15%
faster, with every solve objective byte-identical to the previous release. No
model or input changes.
engine_polars/autoscale/_layer2.pybuckets coefficient magnitudes through the bounded walk; the size-blind family-row skip is gone (no family's range is silently dropped from the scaling decision).- The scaling decision is cached per rolling-group LP shape, skipping the per-roll range traversal on shape-identical interior rolls; between-solve transient peaks are flattened and prior solve-level state is released before the next build.
- Requires polar-high >= 2.4.0 (
declare_dense_axes+ the bounded walk); the interim capability guards have been dropped.
Plot output
- Grouped-bar plots: deterministic y-axis label, aligned grouped-bar rows, value axis sized to label width, compact engineering value labels, and thicker bars when value labels are enabled.
Desktop GUI
- Update dialog: an OK button to save settings without updating; smarter handling of no-tracking branches and no-op updates.
- Execution log no longer unresponsive / unselectable (refreshes coalesced).
- Status icons, spinners and checkboxes use Windows-renderable glyphs.
Fixes
- Migration: the
timeblockSet->timesettimeline collapse is now deterministic. - Input:
p_sectionwired on the synthetic-solve early return. - Input: an explicit
nullparameter value in a higher-priority alternative — the way a scenario clears a value set in the base alternative (e.g. nulling a node'sconstraint_invested_capacity_coeffMap) — now resolves to "unset" instead of crashing the solve with a misleading "Unrecognised Map index column(s) []" error. - The
test_a_lotscenario golden was regenerated — a benign alternate-optimum dispatch reshuffle (objective byte-identical), not a model change.
Release 4.0.0b2 (29.5.2026) — desktop GUI: in-app updates, safer migration, macOS fix
This beta focuses on the standalone Tkinter desktop application.
In-app "Update FlexTool"
- New Update FlexTool… button in the top-right of the main window (next to
UI settings…). It detects how FlexTool is installed and runs the right
upgrade automatically —
git pull+ editable reinstall for a git checkout, orpip install --upgradefor a PyPI install — streaming output to the Execution window and ending with a restart-required prompt. - An Install Spine Toolbox checkbox (opt-in; large dependency, required for the Spine DB Editor; pre-ticked when Toolbox is already present).
- A lightweight startup update check highlights the button (blue) when a
newer version exists. Disable it with the Check for updates on startup
toggle in the dialog or the
FLEXTOOL_NO_UPDATE_CHECKenvironment variable.
Database migration
- GUI database migration now streams step-by-step progress to the Execution window; the modal dialog is a clear "interface locked until done" gate.
- Migration is wrapped in a backup/restore: a database is always left either fully upgraded or exactly as it was found (a failed or cancelled migration is rolled back), with failures pointing to the issue tracker.
Launchers and diagnostics
- A bare
flextoolcommand launches the desktop GUI;flextool/flextool-guiare windowed launchers (no stray console on Windows), backed by a rotatingflextool_gui.logso diagnostics survive when there is no console. - When the Spine DB Editor is missing or fails to start, the GUI explains why (and offers Update FlexTool / the install command) instead of failing silently; the editor's traceback is captured to the Execution window.
- Execution-window log: keyboard selection (Shift+arrows), a Copy log text button, and a right-click Copy / Select-all menu.
Fixes
- GUI status icons, spinners and checkboxes now use glyphs that render in the default Windows Tk fonts (Geometric Shapes / check marks); the previous emoji/Math-symbol glyphs (hourglasses, ballot boxes, ⌀, ⊘) showed as missing-glyph boxes on Windows. (This is a font-coverage issue, distinct from the cp1252 console-printing fix — GUI glyphs are rendered by Tk, not printed.)
- Fixed a macOS crash (
RuntimeError: main thread is not in main loop) during database migration: worker threads no longer call tkinter (includingafter) directly — all worker→main GUI updates are marshalled onto the main thread.
Release 4.0.0b1 (29.5.2026) — first beta: schema v56 finalised, input-shape correctness, autoscale hardening
The 4.0 line moves from alpha to beta. The Spine data schema is
finalised at version 56, the polars engine's input derivation gained an
authoritative parameter-shape resolver, and the auto-scaler's registry
contract is now enforced in CI. Existing input databases migrate forward
automatically (flextool-migrate-database / GUI auto-migration).
Schema finalised at v56 (parameter & method renames; solver-config consolidation)
- Coefficient parameters renamed for consistency:
flow_coefficient → conversion_flow_coeff,max_capacity_coefficient → capacity_max_coeff,min_capacity_coefficient → capacity_min_coeff,constraint_*_coefficient → constraint_*_coeff. - Method value-lists cleaned up:
co2_methods.no_method → none; an explicitnoneoff-member added toconversion_methods,profile_methodsandramp_methods;is_*/has_*flags retyped to theyes_nolist; the redundantstorage_nested_fix_method.nomember dropped. is_enabledre-added onconstraintand thereserve__upDown__{unit,connection}__nodeclasses (read-time gate that drops disabled entities).- Per-solve solver knobs consolidated: the individual
solver_options,highs_method,highs_parallel,highs_presolve,solver_threads,solver_log_level,solver_time_limit,solver_io_apianduse_row_scalingparameters are gone — solver options now live in thesolver_argumentsmap, and the rest are CLI flags (--scaling,--solver-log-level,--solver-time-limit,--solver-io-api,--highs-threads).db_migration.pycarries every rename/fold forward.
Input-shape correctness — authoritative parameter-shape resolver
- New
engine_polars/_param_shapes.pyresolver detects each DB-authored parameter's shape from its nesting depth + per-levelindex_name(validated against a per-parameter allow-list), with value-domain probing to disambiguate spinedb_api's silent-default"x"Map index. - Efficiency /
min_load/efficiency_at_min_loadandother_operational_costderivation now route through this resolver (p_slope,p_section,p_pssdt_varCost). This fixes a class of cross-join blow-ups on long timelines — a time-series parameter carrying the"x"index was mis-read as a scalar and broadcast against the full(period, time)grid (overflowing on multi-week/multi-period runs) — and the matching silent flattening of time variation on short timelines.
Numerical scaling / auto-scaler hardening
- The Layer-2 registries (
_layer2_types.py/_quantity_types.py) are verified complete against the schema and every emitted constraint / variable;lookup_cstrnow implements the documented prefix dispatch so dynamic constraint names (e.g. the ramp family) resolve. - An unregistered family no longer silently degrades a solve to an
un-scaled LP:
FLEXTOOL_AUTOSCALE_STRICT=1turns it into a hard error, and CI now runs the suite with strict mode on. - Layer-2 scaling preserves the solver objective across the setSolution push-back; the fast single-solve path unpacks the Layer-2 result correctly.
Solver & I/O
- Requires
polar-high >= 2.3.0(Where-pushdown release). - Self-describing Excel v2 round-trips cleanly; commodity price-ladder
parameters use the facet-leaf (
price/quantity) layout; the Spine reader no longer invents defaults (strict bool / header / dtype / alt handling).
Testing & developer docs
- Tests build their input DB from JSON/schema under
tmp_pathrather than depending on a checked-in SQLite (which lagged the schema); a session-scopedschema_db_urlfixture and schema-driven registry coverage catch drift at the source. - Per-scenario timing-budget floor raised to 10 s (machine/drive dependent; still catches gross regressions).
- Developer docs document the autoscale-registry, parameter-shape and
build-from-schema invariants (
docs/dev/architecture.md);CLAUDE.mdis now tracked;ruff check .is required and clean.
Release 4.0.0a5 (28.5.2026) — GUI migration UX + side-menu polish
GUI-focused follow-on to 4.0.0a4. Two visible bugs and a sweep of side-menu housekeeping.
Automatic DB migration no longer freezes the GUI
Opening a project with outdated sqlite input sources used to lock the Tk main thread for the duration of the migration with no feedback at all. The migration now runs in a worker thread behind a modal progress dialog with a Cancel button.
flextool/update_flextool/db_migration.py—migrate_database()gains keyword-onlyprogress_callbackandcancel_checkhooks plus aMigrationCancelledexception carrying the last successfully completed version. Cancel is checked at the top of each step only — an in-flight commit always finishes before the exception is raised, so cancel does not corrupt the DB.flextool/gui/db_version_check.py— forwards the new hooks throughcheck_and_upgrade_database(); adds public helpersneeds_flextool_migration()andget_target_flextool_version()so the GUI can pre-check which files actually need migration without performing one. A dedicatedexcept MigrationCancelledbranch reports partial progress and documents that re-run is safe (steps idempotent).- Externally-referenced sqlite files (registered via
external_refs) now trigger a 3-button consent dialog before migration: Migrate in place, Copy all to project and migrate (copies intoinput_sources/and drops the external_refs entry), or Cancel (skips externals; internal files still migrate). - Two new dialogs under
flextool/gui/dialogs/:migration_consent_dialog.pyandmigration_progress_dialog.py(modal with spinner, current-file label, thread-safeupdate_status/mark_finished, Cancel button). MainWindow._run_db_migrations_with_ui()drives the new flow: plan → consent → optional copy (persisted once before the worker starts) → progress modal → worker thread → wait_window → summary messagebox.
Per-project side-menu settings actually persist now
_load_auto_gen_vars() restored 12 Tk vars in sequence. Each
var.set() immediately fired the _on_auto_gen_toggled trace,
which read all vars and wrote settings.yaml — capturing the
still-stale not-yet-restored values from the previous project (or
fresh defaults on first load). Every project load silently
overwrote the file with the wrong values within milliseconds of
opening. A guard flag now suppresses the trace during the restore.
Stacked-area y-axis no longer clips peaks
flextool/plot_outputs/plan.py _compute_time_plan used the
per-series min/max for subplot_y_ranges, but stacked-area
charts render the column-wise sum — peaks would flat-top at the
visual ceiling (orange wind_plant over blue coal_plant in the
dispatch plot). For chart_type == 'stack' the range now uses
num.clip(lower=0).sum(axis=1).max() and
num.clip(upper=0).sum(axis=1).min(). Non-stack line charts
keep the original formula. The shared-axis manifest unions
already-correct numbers, so cross-scenario sharing is preserved.
Side menu / UI settings popup
- Theme radios moved from the side menu into the UI settings popup as a "Theme" cascade — they are a global setting and belong in the global menu.
- Button-label hygiene: Project → Projects…; Add → Add…; Png settings → Png settings…; Execution jobs → Execution jobs…; Results viewer → Results viewer…; UI settings → UI settings…
- Tooltips on every File outputs row (Scenario pngs / Excels / csvs / Comparison pngs / Excel) — short descriptions of what the action produces and where on disk.
_styled_popup_menu()themes nativetk.Menupopups to match the current sv_ttk theme (bg/fg/activebackground/selectcolor) and bindsfont="TkDefaultFont"so the menu font tracks the body font. Applied to the UI settings popup plus the three right-click context menus on the input-sources and scenarios trees — the radio bullet was invisible on dark themes and the menu font was smaller than the rest of the UI._install_menu_hover_dismiss()auto-unposts the UI settings popup ~400 ms after the mouse leaves all menus; the delay lets the cursor briefly cross the parent border into a cascade submenu without dismissing.
Cleanup
- Removed
plot_settings/{single_dataset,multiple_datasets}/ default_result_plots.json— superseded byflextool/schemas/default_plots.yaml, not referenced by any code path, not shipped viapyproject.tomlpackage-data. default_plots.yaml—scenario_rule: ladded to three sub-configs; oneunit_time_plotsvariant flipped fromhtol.
Release 4.0.0a4 (28.5.2026) — cross-solve memory hygiene + diagnostics
Follow-on to 4.0.0a3. The Stage A+B refactor exposed (but did not
fix) a pre-existing cross-solve memory accumulation in the
orchestration: each completed solve's OrchestrationStep held a
full parent-side highspy.Highs instance plus per-variable polars
frames plus the per-solve FlexData and FlexDataProvider,
released only at the START of the iter-after-next (a one-iter lag)
or — for the per-step provider/flex_data — only after the WHOLE
cascade. On DES this meant the parent reached 47-48 GB by solve 8,
and solve 9 froze the machine under swap pressure.
This release lands the targeted fixes plus the diagnostic infrastructure used to find them.
Cross-solve memory hygiene
-
flextool/engine_polars/_subprocess_solve.py— the cold-path (--save-memory) read-back from the subprocess.solfile no longer re-loads the entire MPS into a parent-sidehighspy.Highsinstance just to extract column names and dual arrays. A new_parse_highs_solparser plus a duck-typed_SolHighsShimexpose only what the downstream writers consume (getSolution,getLp().row_names_,passColName,getObjectiveValue). On DES this was the +33 GB sidecar per solve. As a side effect, a pre-existing latent bug is fixed:_v_obj__{solve}.parqueton cold runs used to write0.0for the objective (the priorgetObjectiveValue()call had no value to read); the shim returns the value parsed from the.sol'sObjective:line. If you have analysis built onv_objparquets from any prior cold (--save-memory) run, those parquets are wrong. Warm-path runs are unaffected. -
flextool/engine_polars/_orchestration.py— the slim of the prior step's solution state moved from "start of next iter's handoff" to "end of current iter, immediately afterOutputs written" (kills the one-iter retention lag). Extended to also nullflex_data,flex_data_provider, andsolution._varson the cold path. The just-parked step survives so the LAST iter's outputs still write correctly. -
flextool/engine_polars/_native_run_model.py+_orchestration.py— on the warm path the slim is per-level: the most-recentSolution.highsandflex_data_providerof each level (solve_hydro,solve_dispatch, ...) survive until either (a) a fresher same-level step supersedes them, or (b) the pipeline has no more upcoming iterations of that level. Earlier same-level Highs instances and unrelated-level state are dropped.solution._varsandflex_dataare dropped unconditionally on every prior step (warm-restart uses HiGHS' basis, not these frames). -
FLEXTOOL_COLD_KEEP_PROVIDER=1— opt-in env knob. Cold path dropsflex_data_providerby default (each cold solve re-reads the Spine DB). Set this to retain the provider across cold iterations — trades higher parent RSS for skipping the per-iter DB re-read. Useful for workloads where Spine DB re-read dominates wall time on large input DBs.
Diagnostics
-
flextool/_mem_sampler.py(new module) — opt-in daemon-thread RSS / available / swap sampler. Activated byFLEXTOOL_MEM_SAMPLER=1. Writes timestamped TSV-ish lines to/tmp/flextool_mem_sampler_<pid>.log(override withFLEXTOOL_MEM_SAMPLER_LOG=path) at 100 ms cadence (override withFLEXTOOL_MEM_SAMPLER_INTERVAL_MS=N, clamped 20-10000). Per- sample explicit flush so the last samples before SIGKILL reach disk. Zero-overhead when off. Falls back to/procparsing when psutil is unavailable on Linux. -
Memory/timer table redesigned in
_MemoryRecorder: 4-column layout (time | RSS memory | system memory | system swap) where each cell renders<absolute> (<±delta>)in a unit consistent with the absolute value. Swap now reports a delta (previously swap_prev_mb was not tracked). -
"Solve cleanup" checkpoint row emitted at level-group boundaries (between consecutive solves in the cascade). Δrss vs.
Outputs writtenmakes any retained-across-the-boundary memory obvious in the standard log table — no separate diagnostic tool required. -
Textual log messages (autoscale console summary, save-memory / warm-disabled / soft-promote warnings) now wrap at 100 chars via a
textwrap.fillhelper so multi-sentence prose stays readable in narrow terminals.
Documentation
-
docs/dev/env_vars.md(new) — comprehensive reference for every env var the flextool code actually reads. Grouped by Functional toggles / HiGHS tuning / Memory diagnostics / Memory tuning / Precision cleanup / Niche-test hooks. Cross-linked with polar-high'sdocs/guide/env-vars.mdfor thePOLAR_HIGH_*side. -
docs/dev/inject_between_solves.md(new) — developer guide for therun_chain_from_db(..., override_provider=callable)parameter-injection mechanism. Covers the 11 whitelisted handoff carrier keys, lifecycle (per-iter, after handoff translation, before preprocessing), warm-path interaction (warm reuse happens BEFORE the override fires), audit hook (FLEXTOOL_AUDIT_SOURCES=1), security note, and a working Python wrapper example. -
docs/dev/scaling.md,engine_polars.md,architecture.md— fixed staleFLEXTOOL_AUTO_SCALE=0references; the real variable isFLEXTOOL_SCALING=off. -
Two stale
_orchestration.pycomments referencing the Stage A6-deleted_layer2._rewrite_term_lazyhelper updated to describe the actual side-vector mechanism that replaced it.
Maintenance
-
polar-high>=2.0.2→polar-high>=2.2.0inpyproject.toml. The cross-solve hygiene code (and most of 4.0.0a3 itself) reads polar-high's canonical-matrix surface (Problem.canonicalise,Problem._matrix,_layer2_*_factor,_canonical_dirty), all introduced in polar-high 2.2.0. -
flextool/cli/cmd_run_flextool.pyandflextool/engine_polars/_orchestration.py— ruff clean. Earlier the sampler import sat between theMALLOC_ARENA_MAXsetdefault and the workload imports, triggering 15 E402 errors; moved thestart_mem_sampler()call to the first statement after imports. Pre-existing F401 / F841 / F821 warnings in_orchestration.pyalso cleaned (unused imports/locals removed,polars as plandCallableadded to theTYPE_CHECKINGblock). -
_audit_reports/FLEXTOOL_PYRAMID_HANDOFF.mdand_audit_reports/storage_binding_method_callsites.mdremoved from the git index (the_audit_reports/andspecs/folders are gitignored; these two were the only tracked exceptions). Files preserved on disk.
Real-workload validation
The cross-solve memory cliff that froze the user's machine at DES
solve 3 under --save-memory (parent at ~40 GB + subprocess
trying to start + system swap-thrashing → kernel page-fault
storm) is structurally addressed. Synthetic 4-solve fixture
validates the per-solve RSS-at-Outputs written baseline shifts
from a +9 MB-per-solve climb to a flat curve (modulo the
intentional captured_vars retention for cross-solve writers,
which is independent of the cliff). Full validation on a real
DES run is recommended before relying on the workaround being
durable.
tests/engine_polars/autoscale/test_layer2_roundtrip.py and
tests/engine_polars/autoscale/test_h2_trade_e2e.py (the
bit-for-bit safety nets) pass; full flextool autoscale suite 51
passed; polar-high 126 passed / 8 skipped.
Requires polar-high >= 2.2.0 (unchanged from 4.0.0a3).
Release 4.0.0a3 (28.5.2026) — GLPK-style autoscale refactor
Stage A of a two-stage refactor of the polar-high <-> flextool
autoscale interface, paired with polar-high 2.2.0. Together they
replace the pre-existing "rewrite every constraint's lazy plan to
embed the scaling factors, then re-evaluate from scratch in every
consumer" model with the GLPK textbook approach: scaling lives in
two numpy side vectors on Problem, and a single canonical CSC
matrix is built once and shared across consumers.
What changes in flextool
-
flextool/engine_polars/autoscale/_layer2.py—apply_layer2no longer mutatesProblem._cstrsorProblem._obj_terms. It writes the per-column factor intoProblem._layer2_col_factor(as1 / cf_math, the inverse convention required by consumers that multiply rather than divide) and the per-row factor intoProblem._layer2_row_factor(forwardrf_math). SetsProblem._layer2_locked = TrueandProblem._canonical_dirty = True.Var.lower/Var.uppercontinue to be mutated in place — they are scalar per family, the cost isO(n_var_families), and they must be visible to any caller ofVar(not just to the matrix consumers).Layer2Plan's public shape is unchanged;unscale_solutionis byte-for-byte unchanged. -
The four now-dead helper functions are deleted:
_rewrite_term_lazy,_rewrite_obj_term_lazy,_scale_rhs,_rhs_has_vars. ~250 LoC net reduction in_layer2.py. -
The
POLAR_HIGH_RANGES_MAX_FAMILY_ROWSfamily-size skip added in 4.0.0a2 toapply_layer2's row-factor loop is no longer needed and was removed. (The equivalent skip still lives inbucket_coefficients— that one is for the in-bucket-walk pattern in Layer 1 detection, unrelated.) -
flextool/engine_polars/_orchestration.py— two warm-path comments updated to describe the side-vector design instead of the deleted_layer2._rewrite_term_lazyhelper.
Real-workload validation
The original OOM trigger on DES (profile_flow_upper_limit's
1.5M-row multi-Param chain in apply_layer2) is gone. A full DES
smoke run under the new architecture completed 8 of 9 dispatch
solves with Model status: Optimal before OOM-killing at solve 9
— and that 9th-solve OOM is not a refactor regression but a
pre-existing cross-solve memory build-up that the refactor exposed
by getting further into the workflow than any previous run. At
solve 9's start the process is at 47.6 GB RSS purely from
accumulated state across solves 1-8; investigation deferred.
Bit-for-bit safety nets (test_layer2_roundtrip.py +
test_h2_trade_e2e.py::test_h2_trade_autoscale_full_matches_solver_only_bit_for_bit)
pass under the new code path. Full flextool autoscale suite: 51
passed. Full polar-high suite: 126 passed / 8 skipped (the one
pre-existing-hung warm-rolling-speedup test deselected).
Requires polar-high >= 2.2.0 — the side-vector storage, the
canonical matrix, and the _canonical_dirty flag all live on the
polar-high Problem class.
Release 4.0.0a1 (26.5.2026) — first v4 alpha
First public alpha of the v4 line. The 3.x series ended at 3.47.0
on 24.5.2026; the v4 clean-slate orphan-root commit (5b8225a3,
25.5.2026) was the version-identity break. This alpha gathers the
v4 engine swap, the output overhaul, and the recent
storage_binding_method restructure into a single PEP-440-tagged
pre-release so downstream users can pull 4.0.0a1 from pip /
TestPyPI for trial integration.
Parity with the 3.x line is expected (modulo bugs surfacing
during alpha testing). 4.0.0 final follows after the remaining
minor-functionality additions, cleanup, and broader testing land.
Subsequent alphas / betas / release candidates follow PEP 440
(4.0.0a2, 4.0.0b1, 4.0.0rc1, 4.0.0).
What v4 brings (recap from the orphan-root commit 5b8225a3)
- Engine: polar_high + highspy in-process; legacy GMPL pipeline, glpsol bundling, flextoolrunner package retired entirely.
- Input API: SpineDBBackend as the single Spine DB → polars layer; input_derivation/ as the per-solve preprocessing successor to flextoolrunner/preprocessing.
- Cascade transport: FlexDataProvider (in-memory) is the contract; CSV mirrors only emit under --csv-dump (debug flag).
- Outputs: canonical parquet under output_parquet/
/; intermediate output_raw/ deleted automatically on successful run unless --csv-dump preserves it. - CLI: --engine flag retired (polar_high is the only engine); --highs-threads wired through to HiGHS; bin/ relocated to solver_config/.
- Tests: 521 MB of tracked fixture data reduced to ~5 KB; the scenario_workdir fixture builds workdirs on demand via the live cascade.
What lands on top in this alpha
- storage_binding_method restructure (six-phase migration, May
2026) — the parameter was previously list-valued with additive
semantics in
nodeBalance_eq, which silently double-counted state-change residuals for any node carrying multiple methods. Reverted to single-valued; the value_list is now an eight-member enum (seven cycle-scope methods plusbind_intraperiod_blocksas an orthogonal aggregation method); the three legacy names (bind_within_timeset,bind_using_blended_weights,bind_within_model) are migrated by DB schema v55 to their new names; the two new RP variants (bind_within_period_blended_weights,bind_forward_only_blended_weights) are fully implemented. Same storage entity can now drive an RP investment solve and a chronological dispatch solve back-to-back without parameter changes (per-solve silent degrade replaces the earlier strict check). See thestorage_binding_method Phase A-Fcommits. - DB schema v55 — migration auto-runs on DB load via
migrate_database(path); bumpsFLEXTOOL_DB_VERSION54 → 55. Refreshes both the value_list members and the parameter description on existing DBs to match the new schema template. - autoscale package (commits
92b98efdthroughd4a4d9df) — semantic per-type scaling layer between the cascade and HiGHS; the legacyscaling.pyis fully retired.--auto-scale/--scalingCLI flag is on by default; HiGHS-native bound + objective scaling driven from polar-high.
Known caveats for alpha users
- This is an alpha — bugs are expected. Reports against any scenario that worked under 3.47.0 but misbehaves under 4.0.0a1 are especially welcome and feed directly into the cleanup before the 4.0.0 final.
- One known parity-tolerance gap remains
(
tests/engine_polars/test_phase_e_g_multi_roll.py::test_fullYear_roll_matches_v3320_golden, rel 1e-6); pre-existing, orthogonal to the alpha-cycle changes. - Pre-v55 DBs that carried array-valued
storage_binding_methodparameter values must be migrated before use; see the v52→v55 migration chain inflextool/update_flextool/db_migration.py.
Release 3.47.0 (24.5.2026) — JSON-fixture architecture + Stage 4 test migration
Minor release on top of 3.46.0. Closes nine latent test failures surfaced during the Tier 4 sweep, lands the architectural overhaul of the JSON-fixture infrastructure that supports test data, and migrates 140 engine_polars test files off the gitignored disk fixtures that were generated externally by the legacy GMPL engine. Twenty-six commits across four themes.
Latent failures (Groups B / C / A)
The Tier 4 sweep surfaced nine failures that pre-existed the retirement but were masked by narrower test gates. All addressed:
Group B(commit7b119abd) — three tests intest_handoff_cumulative_carriers.pyreferenced pre-Phase-4.1a column names (value,mwh) that no longer exist; producers emit the canonicalp_ladder_cum_sim_hours/p_ladder_cum_realized_mwh. Test-only fix.Group C-reader(commit79dc699b) — threecsv.readersites inflextool/engine_polars/_blocks.py(_read_input_rowsplus two header probes inemit_block_data_for_solve) read from disk directly, violating the Provider-only cascade contract. Routed throughinput._provider_openso the Provider serves the bytes in cascade mode and disk is consulted only as the non-cascade-caller fallback.Group C-writer(commit460539b1) — gated thep_entity_period_existing_capacity.csvdisk write on a newcsv_dumpflag threaded fromstate.csv_dumpthroughwrite_outputs_for_solve→write_all_handoffs→write_p_entity_period_existing_capacity. The in-memorySolveHandoff.realized_existingalready carries the data, and_load_prior_existingalready prefers it over disk; the unconditional CSV write was a redundant dump.Group A (output path)(commit6a05f5a4) —_entity_all_capacityinread_parameters.pyneeds each sub- solve'sv_invest_p/n+v_divest_p/nlong-form frames to compute thetotalcolumn inunit_capacity__d.csv(and the sister CSVs). Polar-high's recent memory optimisation releasesSolution._varsinternally between sub-solves, so by the time the end-of-cascade writer runs only the last sub-solve's variables survive. Captured the four frames intoOrchestrationStep.captured_varsimmediately before the step is deposited and exposed them through aSnapshotSolutionwrapper via a neweffective_solutionproperty. Fixesmulti_yearandwind_battery_invest_lifetime_renew_4solve.Group A (parity tests)(commit905781e7, supersedes993a0cde) —multi_fullYear_battery_nested_*parity failures were the same upstream cause (LP missed roll-state handoff + storage-pin constraints because the production loader reads handoff carriers exclusively from in-memory Provider keys and the isolated parity tests never run the orchestrator that populates them). The four replay-style tests have been deleted as redundant withtest_scenarios.py(which runs the full cascade for the same scenarios and passes against tracked goldens).
Worktree merge — fix_price / fix_usage / upward storage handoff
Four commits cherry-picked from a parallel worktree session
(f206272c, 1ab347e3, f420ce83, bb35cd54):
fix_price—model.py:3427used the wrongp_unitsize(process-indexed) instead ofp_state_unitsize(node-indexed) on theuse_reference_priceobjective term. Surfaced by a new γ-style child-only wiring test undertests/engine_polars/constraints/. The stale xfail attest_cost_aggregation_semantics.py:943retired.fix_usageproducer (input.py:5706-5837) promoted to the full formula (efficiency slope + min_load section + noEff branch), mirroring the LP constraint LHS atmodel.py:1500-1537. Closes the legacy producer/constraint asymmetry documented inspecs/feature_fixes.md§3.- Upward dispatch→storage state carrier
(
SolveHandoff.upward_roll_end_state+HANDOFF_UPWARD_ROLL_END_STATEprovider key) made explicit. Sequential-prior orchestration produces identical numerical behaviour today; the carrier exists to make the upward path separable for future routing changes (specs/feature_fixes.md§1).
JSON-fixture architecture overhaul — single source of truth
Five commits land the architectural cleanup the user steered toward during the session. Today the test-fixture and canonical-database JSONs are kept consistent by tooling rather than convention:
Stage 1(commit4e857535) — deleted three silently-divergent duplicates oftests/fixtures/{tests,stochastics,lh2_three_region}.jsonundertests/engine_polars/fixtures/. Same line counts, but drifted parameter defaults (e.g.nullvs1.0). Thetests/engine_polars/fixtures/directory's remaining role is exposingflex_toy_*.pymodules onsys.path.Stage 2a(commiteb352d3c) — extended schema migration coverage totests/fixtures/*.jsonvia a newflextool.update_flextool.test_fixturesmodule mirroring the existingcanonical_databases.migrate_allpattern. Brought the seven test fixtures up toFLEXTOOL_DB_VERSION=52(tests.json went 213→234 parameter_definitions, stochastics 207→235, etc., counts preserved on entities / alternatives / scenarios / parameter_values / scenario_alternatives).Stage 2b(commits3494d7d6+c763e637+0ff95715) — builtflextool/update_flextool/generate_canonical.py+flextool/schemas/canonical_databases/_recipes.yamland wiredtemplates_examples.json+howto_stochastics.jsonas generated projections oftests.json/stochastics.json. The remaining sevenhowto_*.json+templates_time_settings_only.jsonstay authoritative for themselves — purpose-specific minimal teaching examples whose curation goals differ from test-coverage curation. Generator surfaced one drift (parameter_group_name='reserve' lost on two reserve.is_active parameters) which was fixed at the source.Stage 2c(commit79a02552) —flextool/update_flextool/ extend_tests_fixture.pyadds an agent-friendly YAML-delta workflow for append-only additions totests/fixtures/*.json(new entities + alternatives + scalar parameter values + scenarios). Validates againstspinedb_schema.jsonwith did-you-mean suggestions; rejects edits to existing entries; rejects structured (Map / time-series) values, pointing the caller at the SpineDB editor for those. 8 unit tests.Stage 5(commit4d73460b) — deleted four nested-battery replay-style parity tests +_handoff_seed.py. Same scenarios covered end-to-end via JSON-path intest_scenarios.py.
CI verify chain
The drift-detection pipeline is now CI-enforced
(tests.yml::template-check, commit 6d5849ac):
sync_master_json_template --verify— schema parity (pre-existing)test_fixtures verify—tests/fixtures/*.jsonat current schemagenerate_canonical --verify— generated canonical files in synccanonical_databases verify— authoritative canonical files at current schema (new; mirrorstest_fixtures.verify_all)
Same commands run locally as pre-push sanity.
Stage 4 — engine_polars test migration off gitignored disk fixtures
Five phases (a8ce274f, 2e4f499d, 8f42313d, 840fc56c,
494046f9, 905781e7) migrate 140 test files off the
tests/engine_polars/data/work_<scenario>/ gitignored snapshot
pattern. Tests now build their work-folders on demand via a new
session-scoped scenario_workdir fixture in
tests/engine_polars/conftest.py:
scenario_workdir(scenario_name, db_fixture="main")runs the full cascade withcsv_dump=True+keep_solutions=Trueand snapshots the last step'sflex_data_providerso the on-disk CSV layout matches what the legacy disk fixtures shipped.- Session-cached per
(scenario, db_fixture)— each scenario pays its ~2 s cascade cost once per test session, no matter how many tests use it. - Seven
db_fixturevalues map to per-fixture session DBs:main(tests.json),stochastic,lh2,case14,h2_trade_parity,multi_ts_branch1,stochastics_pbt_inflow,branch2_parent_period. tests/fixtures/_augment_phase3d.pyadds five new scenarios to existing fixture JSONs via proper alternative overlays (coal_ladder_annual,coal_ladder_cumulative,wind_battery_invest_lifetime_renew_inflation_2pct,delay_source_coef,2_day_stochastic_dispatch_no_storage). All idempotent.tests/fixtures/case14_dc_power_flow.json(8173 lines) is a committed export of the PGLib IEEE case14 MATPOWER fixture used bytest_flex_dc_power_flow.py.- Dynamic-discovery cluster tests
(
test_arithmetic_cluster,test_block_cluster,test_block_layout,test_npv_cluster,test_profile_cluster, the opt-in sweep intest_orchestration) replacediscover_workdirs()glob with a curatedPARITY_SWEEP_CASESlist in a newtests/engine_polars/_parity_sweep.py.
The gitignored tests/engine_polars/data/work_*/ snapshot dirs
remain on .gitignore for back-compat, but no migrated test
depends on them.
One incidental production fix — e5b784ef
While exploring an unrelated issue an agent stumbled into a real
LP bug: v_current was indexed on a larger union than current_idx
in rolling / nested-multi-invest scenarios, causing
v_current - v_forward to crash with "Array conditional must be
same shape as self" inside the nodeState change emission.
Reindexed v_current to current_idx like the other v_prev_*
series.
Coverage
tests/engine_polars/ full sweep: 1824 passed, 538 skipped,
6 xfailed, 0 failed (~27 minutes). The four CI verify hooks
report clean. No regressions in tests/test_scenarios.py
(65 scenarios pass).
Release 3.46.0 (23.5.2026) — flextool/flextoolrunner/ retirement (Tier 4 sweep)
Minor release on top of 3.45.0. Completes the multi-tier sweep that
started in 3.33.0 when the GMPL pipeline was retired: the
flextool/flextoolrunner/ package itself is now gone. Every piece of
the old solve-coordinator has been relocated into a home that reflects
its current responsibility, and the top-level
flextool.__init__ re-export of FlexToolRunner is dropped. Thirteen
audit commits land the move end-to-end (8baab773..1776dd09).
Module moves
precision.py→flextool/common_utils/precision.py(Tier 4 Commit 1,ec0dfacb).timing_recorder.py→flextool/cli/_timing.py(Tier 4 Commit 2,0a620dbd).solver_runner.py→flextool/engine_polars/_solver_base.py;solve_handoff.pyshim deleted (Tier 4 Commit 3,4813b490).scaling.py/scaling_report.py/blocks.py/runner_state.py/solve_handoff.pyconsolidated intoflextool/engine_polars/—scaling.py+scaling_report.pymerged with the engine-side copies,blocks.pyrelocated,RunnerState+PathConfigconsolidated intoengine_polars/_solve_state.py, runner-side scaling deleted as dead code (Tier 4 Commits 4-6,53557311/37309a90/2f8ca342).region_decomposition.py+region_filter.py→flextool/decomposition/(Tier 4 Commit 7,f862eeec).flextoolrunner.py→flextool/engine_polars/_db_loader.py(carries theFlexToolRunnerclass).db_reader.py→flextool/engine_polars/_db_reader.py.- Dead modules deleted:
solve_config.py,timeline_config.py,tests/test_commodity_ladder_smoke.py(smoke had no live coverage beyond what the integration suite already exercised). flextool/__init__.pyno longer re-exportsFlexToolRunner; callers that still need the class import it fromflextool.engine_polars._db_loaderdirectly. All in-tree call sites were updated; the only external surface affected isfrom flextool import FlexToolRunner, which is nowfrom flextool.engine_polars._db_loader import FlexToolRunner.
Doc sweep
- Cleanup passes A-D refreshed
flextool.flextoolrunner.*references acrosstests/,specs/,docs/,migration/, and the top ofRELEASE.md. Current-tense references now point at the new homes; historical narrative ("ported from", "lived in flextoolrunner/ through 3.32") was preserved intentionally. docs/dev/architecture.md— the dedicated "Solve Coordinator" section, the top mermaid diagram, the repository layout block, and the public-APIs example all rewrite around the new module map.
Release 3.45.0 (22.5.2026) — RP-blended-weights state coupling + logging polish
Minor release on top of 3.44.0. Closes the long-standing
bind_using_blended_weights gap in engine_polars — the
representative-period storage binding declared by the schema and used
by flextool.representative_periods.preprocess had no constraint
emission, so any node with that binding silently lost state-continuity
and the RP cost diverged from the full-model reference. Twelve
commits land the full mechanism end-to-end, matching the deleted
Pyomo flextool.mod reference (blob c04afa59, lines 1689, 1691,
2197-2200, 2965-2997). Three smaller commits polish run-log /
memory-checkpoint output.
Representative-period state coupling — full implementation
- New LP variables (
flextool/engine_polars/model.py): v_state_inter[n, b]— long-run state at each base-period boundary, indexed overnodeState_rp × rp_base_period_set.v_state_rp_start[n, d, t]— free starting state at each RP-block-first step, indexed overnodeState_rp × rp_block_first.- Intra-period state change inside
nodeBalance(mirrors .mod:2197- 2200): forn ∈ nodeState_rp, within-timeset lag for(d, t) ∉ rp_block_first, and(v_state_rp_start − v_state) · unitsizefor the first step of each RP block. - Inter-period constraints:
rp_inter_period_balance(.mod:2965-2975) — couplesv_state_interalongrp_base_chainvia the weighted sum of intra-block state changes.rp_inter_period_cyclic(.mod:2978-2988) — closes the chain by equating the first-to-lastv_state_interdelta to the same weighted sum keyed atb_first.rp_inter_period_max_state(.mod:2991-2997) — capacity bound onv_state_interat every(n, b, d).maxState_rp_start— sibling ofmaxStatethat boundsv_state_rp_start(the .mod expressed this as a per-rowVar.upper, which the polars engine doesn't yet support).- New
storage_bind_using_blended_weightsprojection helper +SIMPLE_PROJECTIONSmapping entry in_projection_params.py— mirrorsstorage_bind_within_timeset/storage_bind_within_solve. - Eight new FlexData attributes (
nodeState_rp,rp_base_period_set,rp_base_chain,rp_base_first,rp_base_last,rp_block_first,p_rp_last_step,rp_base__rep) wired through the loader, the warm-carry list, and the region filter. Load-time invariant fires aValueErrornaming the missing field whennodeState_rpis non-empty but any of its tightly-coupled siblings is empty.
Supporting RP-set restoration
- Reverses the relevant parts of
752dff3fto bring back six derived RP sets/params (rp_base_chain,rp_base_first,rp_base_last,rp_block_first,rp_block_last,rp_block_start_last) inside_compute_rp_framesand moves therp_base_period_set/rp_rep_period_setderivation into the same place so it can seerp_weights(the previous derivation lived inemit_per_solve_setswhich runs BEFOREemit_rp_datain the cascade — always wrote empty frames). - Adds nine
K.SOLVE_DATA_RP_*/K.SOLVE_DATA_NODE_STATE_RPconstants to_provider_keys.py(no.csvsuffix, per the Phase 3b convention) and migrates the three existing RP emit sites to use them.
Decoder fix (_decode_rp_weights)
- The DB-side
params_to_dictreturnsrepresentative_period_weightsas flat triples[base, rep, weight]when the 2-level nested Map is flattened viaconvert_map_to_table, not as[base, inner_map_or_list]. Both copies of_decode_rp_weights(engine_polars/_timeline.pyandflextoolrunner/timeline_config.py) now detect and decode the flat-triple shape; the nested-list shape stays in place. Without this fixstate.timeline.rp_weightswas silently empty, the RP gate in_native_run_modelfell through toemit_empty_rp_data, andbind_using_blended_weightssilently degraded to no state-continuity.
Tests
tests/test_representative_periods.py::TestRPAllRepresented::test_all_represented_matches_full— was strict-xfailat the original +5.84% cost gap; now passes within the 1% tolerance.- New hand-calculable toy fixture
tests/engine_polars/test_rp_blended_weights_minimal.py: 2 base periods (b1 → b2chain), 1 rep block, 1 storage node, inflow configured so the cyclic constraint forces exactly 2 units of unavoidable slack. Cost golden 2.0 derived by hand and matched by HiGHS to 1e-6. Capacity-binding probe confirmed: dropping the storage cap from 100 → 0.5 raises cost 2.0 → 7.0, provingmaxState_rp_startbinds.
Logging / memory-checkpoint polish
Solve start:lines now include a sub-solve counter[name, N/total]so cascade progress is visible at a glance.[mem]checkpoint table is narrower and uses a two-column process/system layout with a descriptive header. Adds system-swap column; drops the redundant HiGHS-internal column that was always zero outside the solve.- Run-log entry gets "Run start" / "Available solvers" banner, blank line between solves, and several routine writer chatter lines moved to debug level.
UnboundLocalError fix in _emit_per_solve
- Drops a dead function-local
from flextool.engine_polars import _provider_keys as Kinsideemit_per_solve_sets. Phase 1 of this release added a module-levelKimport whose use at line ~169 trippedUnboundLocalErrorbecause the dead function-local re-binding madeKfunction-local across the whole body.
Release 3.44.0 (22.5.2026) — auto user_bound_scale + scaling cleanup
Minor release on top of 3.43.2. Bumps the polar-high dependency to
v1.4.0 (breaking removal of Problem.peek_lp_ranges) and rewires
flextool's user_bound_scale handling around the new stream-time
scaling path. Five flextool commits + two polar-high commits land
together.
polar-high opt-in auto-scaling (new default for flextool)
polar-highv1.4.0 addsProblem(auto_user_bound_scale=True): during the streaming feed to HiGHS, accumulate LP coefficient ranges over the per-family arrays we already build (matrix, cost, col-bound, row-bound) and apply auser_bound_scalerecommendation viasetOptionValuebeforeHighs.run(). The embedded heuristic is col-bound-only with a 6-decade gate and[-10, 0]clamp — identical policy to flextool's Rivendell-bug-1+5+6 fix, deliberately ignoring row-bound spread to avoid crushing tight column bounds on energy-system LPs with wide cumulative-resource RHS magnitudes. Ranges are exposed onSolution.streamed_lp_rangesfor callers that want to inspect.- All three real flextool
Problem()construction sites opt in toauto_user_bound_scale=True: the cascade cold-build branch in_orchestration._drive_cascade, the standalone single-solve in_orchestration.run_single_solve_from_db, and the warm-LP first-build in_warm._build_warm_problem.
Input-data heuristic disabled by default
recommended_highs_options(apply_user_bound_scale=...)defaults toFalse. The legacy input-data heuristic was firing on every solve and clamping toN=-10on DES-class scenarios because RHS family ranges routinely reach 1e+6 — producing the "User-scaled problem has some excessively small bounds" warning chain on energy-system LPs (the exact failure mode flagged in theproject_user_bound_scale_geo_midpointdesign note).- The new resolution order is:
--user-bound-scale NCLI flag (env varFLEXTOOL_USER_BOUND_SCALE).- DB
solve.user_bound_scaleparameter. - polar-high stream-time recommendation
(
Problem(auto_user_bound_scale=True)). - No
user_bound_scaleemitted — HiGHS does its own internal scaling and prints"Consider setting the user_bound_scale option to <N>"when its own analysis recommends one.
Dead-code cleanup (test coverage migrated, then code deleted)
- Deleted from
flextool/engine_polars/scaling.py:recommend_user_bound_scale(input-data heuristic),recommend_user_bound_scale_from_lp,recommended_highs_options.apply_user_bound_scale/lp_rangesparameters and their branches, and the now-orphaned constants (BOUND_SPREAD_THRESHOLD,BOUND_ABS_MIN_EFFECTIVE_ZERO,BOUND_ABS_MIN_FLOOR_RATIO,USER_BOUND_SCALE_TRIGGER_DECADES). - Deleted from
flextool/engine_polars/_orchestration.py: the threeFLEXTOOL_PEEK_LP_RANGESenv-var-gatedpeek_lp_ranges()call sites (cascade warm path, cascade cold path,run_single_solve_from_db). - Test reorganisation:
tests/model/test_invest_chain_regression.pynow readsstep.solution.streamed_lp_rangesand callspolar_high.engine._recommend_user_bound_scaledirectly, instead of rebuilding the LP viaProblem()+build_flextool()+pb.peek_lp_ranges().- New
tests/engine_polars/test_polar_high_recommend_user_bound_scale.py— Rivendell-bug-1+5+6 unit tests now live against polar-high's helper. Replaces the deletedtests/engine_polars/scaling/test_user_bound_scale_recommendation.py.
Net diff across both repos: −452 lines of code with full test coverage
preserved (tests/engine_polars/test_solve_config_parity.py 80/80,
the migrated test_invest_chain_regression.py 6/6, the new
test_polar_high_recommend_user_bound_scale.py 4/4,
tests/test_streaming_parity.py 16/16 on polar-high).
Memory-checkpoint metric
[mem]checkpoints in_orchestration._MemoryRecordernow reportRssAnon + VmSwap(anonymous resident set plus the same anonymous pages that have been swapped out) instead of totalVmRSS. This excludes evictable file-backed pages — including the polars/Arrow memory-mapped buffers — which were overstating the process's true memory commitment by tens of GB on a typical run. The new number is closer to whatsystemd-oomd's PSI signal effectively responds to and what the system monitor's "Used" line shows. Display labels are nowmem=/Δmem=(previouslyrss=/Δrss=).
Release 3.43.2 (22.5.2026) — per-level Provider memory fix
Patch release on top of 3.43.1. Five-commit fix for the cascade peak-RSS
climb that surfaced on the multi-invest fixtures and was causing OOMs on
1-year South Africa DES (storage → dispatch transition). Implements
Design A from the per-level Provider memory investigation: one
FlexDataProvider per distinct LP shape, reused across iterations
within the same level; fresh Provider on level transition.
Per-level Provider redesign (Design A)
compute_level_key(solve_name, complete_solve_name, solve_config, timeline_config)returns a hashable tuple(timesets, new_step_duration, rolling_times, solve_mode). Two sub-solves with the same key share LP matrix shape and may share a Provider; different keys require a fresh Provider on transition._native_run_model.pylooks up / creates one Provider per distinct level_key, stored onstate._level_providers. Two consecutive iters with the same level_key reuse the same Provider; level transitions build a fresh one. On the multi-invest fixture this collapses 80 per-iter Provider constructions into 6 (4 invest + 1 storage + 1 dispatch shared across 72 rolls).- Level transitions explicitly drop the cascade solver's
_warm_problemand_prior_databefore the warm-LP fingerprint check fires. The fingerprint check already nulled these on shape change (which a level transition always implies), so this is defence-in-depth; it also makes the level boundary an explicit lifecycle event for the upcoming subprocess-per-chain work. state._level_providersis initialised explicitly at both engine entry points (_fast_load.fast_load_single_solve_from_dband_orchestration.run_chain_from_db), beside the existingstate.handoffs = {}init. The lazyhasattrprobe in_native_run_modelstays as a defensive fallback.
Solution slimming during cascade
- Every per-iter
polar_high.Solutionused to be parked onOrchestrationStep.solutionin_FlexpyCascadeSolver._all_stepswith its full_varsdict (oneVar.framepl.DataFrameper variable, sized to the LP). The slim block at_native_run_model's tail (step.solution = Nonefor non-last steps) only runs AFTERnative_run_modelreturns — so during the cascade every Solution was fully retained. On 1-year South Africa DES (two sub-solves at very different shapes) this was tens of GB and the cause of the storage → dispatch OOM: the dispatch sub-solve tried to allocate its own ~30 GB LP on top of the storage sub-solve's still-parked Solution. Solutions are now slimmed in-cascade.
Release 3.43.1 (22.5.2026) — schemas consolidation + test fixes + CLI overrides
Patch release on top of 3.43.0. Three themes: a final package-data reshuffle that finishes the PyPI-readiness layout work (3.41.3), test-suite housekeeping fallout from the provider-consolidation work, and two HiGHS knobs surfaced as CLI flags.
Package data — flextool/schemas/ consolidation
flextool/version/andflextool/textual_templates/both retire, replaced by a singleflextool/schemas/directory:flextool/version/{AXIS_CONTRACT.md, comparison_settings_template.json, flextool_axis_contract*.json, output_*_template.json}→flextool/schemas/flextool/version/flextool_template_master.json→flextool/schemas/spinedb_schema.json- 13 pre-v26
flextool_template_*.jsontemplates →flextool/schemas/pre_v26/ flextool/textual_templates/*(YAML/TXT configs +canonical_databases/) →flextool/schemas/- Every caller (Python, docs,
pyproject.tomlpackage-data globs) updated. The RELEASE notes for 3.41.3 PyPI Blocker 1 were updated to reflect the final layout.
Cleanup
- Remove dead per-sub-solve writers that had no consumers in the cascade after the 3.42.0 writer→emitter migration and the 3.43.0 provider-consolidation work.
- Drop
scale_the_state.csvfrom test whitelists; it is no longer emitted.scale_the_objectiveread routed through the Provider (B2 cascade fix). - Revert an accidental
git addthat pulledspecs/markdown files into version control; restore.gitignoreintent.
Performance
peek_lp_ranges()skipped by default. This call materialised the full LP arrays a second time after the streaming build. Its only consumer,recommend_user_bound_scale_from_lp, intentionally inspects onlycol_boundand returns 0 for FlexTool LPs in practice — the work was discarded. All three call sites in_orchestration.pygated behindFLEXTOOL_PEEK_LP_RANGES=1for the rare case where the diagnostic is wanted. Saves ~16 s of redundant Polars work per solve on the PES-Hydro-dispatch-1week fixture.
New CLI flags
Two HiGHS knobs surfaced as run_flextool CLI overrides:
--user-bound-scale N— power-of-two exponent for HiGHS'suser_bound_scaleoption (multiplies col bounds and RHS by2**N). Use when HiGHS prints "Consider setting the user_bound_scale option to" in its scaling warning, or when the auto-heuristic returns 0 but the actual LP has wide RHS spread. Resolution priority: CLI > DB solve.user_bound_scale> input-data heuristic.--presolve {on,off,choose}— override the determinism-pinnedondefault for HiGHS's presolve. Useful for memory or numerical diagnostics;offwill run slower.
Test housekeeping
tests/test_self_describing_reader: skip when the example XLSX is absent (test was failing on clean checkouts before the GUI materialises the file).tests/test_representative_periods::TestRPAllRepresented: xfail with 5.84 % gap — golden was generated against the legacy preprocessing path; the engine_polars cascade hits a different alternate optimum within tolerance but outside the strict golden bound. Reproducer left in place; revisit once the cascade scoring rationalises the alternate-optimum drift.
Release 3.43.0 (22.5.2026) — provider-consolidation (Phases 1-6) + deferred-B objective term
Major refactor on top of 3.42.1. Lands the full provider-consolidation
arc that the Phase 0 groundwork in 3.42.1 was setting up — collapsing
the four parallel cross-iteration data-transport mechanisms (typed
SolveHandoff, CSV round-trip, CROSS_SOLVE_KEYS extract+seed loops,
implicit bare↔qualified Provider key fallback) onto a single typed
translator pipeline. In the same window, closes the deferred-B work
left as a placeholder in the writer→emitter migration: the
use_reference_price objective term and node_storage_usage_fix_le
constraint are now LP-wired end-to-end.
Provider consolidation — typed handoff translator (Phases 1-2)
- Phase 1a: relocate
invest_periods_of_current_solvefrom theemit_periodswriter chain to an inlineprovider.put(K.X, derive_periods(...)). Both consumers (emit_invest_divest_sets,_emit_entity_annual) already route through the canonical Provider key via_read_singles, so no consumer change is required. - Phase 1b: drop empty cumulative-carrier seeds.
- Phase 2.1: introduce
_provider_translators.translate_handoff_to_provider. Called at iteration start, it fans eachSolveHandofffield into a dedicatedhandoff/<field>.csvProvider key:realized_invest,realized_existing,divest_cumulative,cumulative_co2,cumulative_commodity,cum_sim_hours. Empty header-only frames written when the field isNoneso consumers can read unconditionally and checkframe.height > 0. - Phase 2.2: migrate
_emit_co2_accumulatorsto the translator. - Phase 2.3: drop the
prior_handoffparameter from the preprocessing cascade — every consumer now readshandoff/<field>from the Provider.
Provider consolidation — kill the legacy paths (Phases 3-4)
- Phase 3a: delete
capture_post_solvedead code. - Phase 3b: drop the
.csvsuffix from key constants — keys are Provider lookups, not paths. - Phase 4.0-E/F: trim vestigial
CROSS_SOLVE_KEYSentries; tighten the chain-cumulative handoff test. - Phase 4.1a-l: 12 sub-phases migrating the
fix_storagecross- iteration path. Splits the wideSolveHandoff.fix_storagefield into narrowfix_storage_quantity/fix_storage_pricecarriers via the translator; deletes the disk pipeline (write_fix_storage_files_from_handoff,_fan_out_fix_storage) and the parent-overlay shim that read wide fields. Ladder accumulators (cumulative_commodity,cum_sim_hours) routed through the same translator with canonical column names. - Phase 4.2-0/4.2-1a-g: 8 sub-phases migrating
roll_end_state,p_entity_invested/divested,p_roll_continue_state,derive_ed_history_realizedpair set, CSV fallback arms in_emit_chain_params,SolveContext.p_entity_period_existing_capacity, the_read_capacitylegacy fallback, andladder_cum_*CSV fallbacks. - Phase 4.2-1 (closeout): delete
CROSS_SOLVE_KEYSplumbing —state.cross_solve_carriers, the iteration-start seeding loops, the iteration-end extraction loop, and theCROSS_SOLVE_KEYStuple itself. The per-sub-solve Provider is now constructed exclusively fromcascade_input_provider+ the translator pipeline. - Phase 4.2-2: drop the
FlexDataProviderbare↔qualified key fallback. Every lookup is now parent-qualified; the dual-key fallback that 3.42.1 Phase 0a deprecated is fully gone.
Provider consolidation — override pipeline (Phase 5)
A second translator surface in front of handoff/, for external
overrides:
- Phase 5a: add
override/*Provider key namespace (10K.OVERRIDE_*constants parallel toK.HANDOFF_*). Newtranslate_overrides_to_provider(overrides_dict, provider): maps user-facingK.HANDOFF_Xkeys toK.OVERRIDE_Xwrites, raisesValueErroron unwhitelisted keys.read_handoff_framechecksoverride/<field>first and falls back tohandoff/<field>, so existing consumers automatically respect overrides with no migration. - Phase 5b: wire the override translator into the orchestrator.
- Phase 5c: end-to-end override test.
- Phase 5d: document override precedence + transport.
Provider consolidation — source tagging (Phase 6)
- Phase 6a: opt-in
sourceparameter onFlexDataProvider.put(key, frame, *, source=None)+ companionget_source(name)accessor. Eviction clears the source entry too.translate_overrides_to_providerpassessource="external_override"so the override layer is traceable. - Phase 6b: env-var-gated audit-source dump (
FLEXTOOL_AUDIT_SOURCES=1) consuming the source-tag accessor.
Deferred-B — use_reference_price objective + fix_storage_usage constraint
The writer→emitter migration left two placeholders unfilled, both flagged by inline TODO comments in the engine_polars code. Closed end-to-end in B1a–B4:
- B1a: load
p_storage_state_reference_priceintoFlexData(the_emit_arc_unionsproducer was emitting-and-forgetting; the consumer placeholder attests/.../decomposition/_components.py:847was already gated ongetattr(...)). - B1b: add the
use_reference_priceobjective term per legacyflextool.mod:2107-2111:Fixtures exercisingobj += − Σ_{nodeState, period_last, last_t} p_storage_state_reference_price[n,d] * v_state[n,d,t] * unitsize[n] * op_factor * pdt_branch_weight[n,d,t]use_reference_pricewill see their objective shift to reflect the previously-missing term. - B2: implement the
fix_storage_pricedual-extraction producer inbuild_handoff_from_flexpy(the inline comment atinput.py:5108-5119said it was left unfilled). Extracts row duals from the node-balance constraint forfix_price-method storage nodes at theirfix_storage_timesteps, applies normalization (-1 / inflation_factor * period_share / scale_the_objective), and populatesSolveHandoff.fix_storage_pricewith the canonical[node, period, step, p_fix_storage_price]schema. Downstream routes throughhandoff/fix_storage_price → derive_p_storage_state_reference_price → p_storage_state_reference_price → B1b's objective term. No fixture exercisesfix_priceyet (B5 territory); existing fixtures unchanged. - B3: mirror B2 for
fix_storage_usage— extractv_flowprimals forfix_usage-method storage nodes over the dispatch window, weight bystep_duration, populateSolveHandoff.fix_storage_usagewith canonical[node, period, step, p_fix_storage_usage]schema. - B4-pre + B4: load
p_fix_storage_usageintoFlexData; add the LP-sidenode_storage_usage_fix_leconstraint per legacyflextool.mod:2775-2800— net summed energy flow over the dispatch window forfix_usage-method storage nodes ≤ storage-solve target usage. LHS uses the full legacy formula with efficiency corrections for sink flows; RHS sumsp_fix_storage_usageviadtt_timeline_matching. Closes Phase B's LP-wiring gap. B5 (fixture/test) optional.
Release 3.42.1 (21.5.2026) — bug-fix sweep + provider-consolidation groundwork
Patch release on top of 3.42.0. Clears the test-suite fallout from the
writer→emitter refactor, lays the groundwork for the next refactor
(provider-key consolidation), and fixes a small batch of preprocessing
and reader bugs uncovered while scoring the cascade against the
tests/test_scenarios.py suite.
Bug fixes
process_outputs.write_outputs: provider-aware backfill fornodeGroupDispatch__*/nodeGroup*indicator sets. After the writer→emitter refactor (3.42.0) the per-sub-solve cascade keeps these 15 frames in theFlexDataProviderand only flushes to disk under--csv-dump. Toolbox runs and the test suite (which drive the cascade viarun_chain_from_db) ended up with an emptyworkdir/input/and partialworkdir/solve_data/, so the_backfill_group_indicator_setsloop trippedFileNotFoundErrorbefore emittinggroup_flows__dt.csv. Lookups now consult the Provider first via the parent-qualified"<parent>/<stem>"keys used by every_emit_*call site, and fall back to the on-disk CSV so--csv-dumpand other disk-emitting paths still work.engine_polars.spinedb_backend.tabular_reader: column-type assignment forSolvesequence parameters. The tabular reader was applying the wrong dtype toSolve's ordered-sequence columns during ingest, causing downstream Enum vocab cast mismatches.- Objective-decomposition invest/divest joins: align Enum vocabs on both sides of the join so the union-axis cast doesn't silently drop rows.
tests/test_b18_7hand-calculation refreshed after the earlier BUG A4 unit-conversion fix flowed through.
Pre-refactor groundwork — provider consolidation Phase 0
Two preparatory commits that re-shape how cascade producers register their output keys, ahead of the full Phase 1 consolidation (scheduled for 3.43.0):
- Phase 0a: drop dual-key registration. Every producer that used to register both a parent-qualified key and a bare-stem key now registers only the parent-qualified key. Bare-stem lookups never worked (they were always a fallback for callers that didn't yet know the parent), and removing the dual-write shrinks the registry by ~30 %.
- Phase 0b: introduce the
_provider_keysmodule — a single source of truth for the parent-qualified key strings, replacing scatteredf"{parent}/{stem}"literals across the emitters and callers.
Preprocessing fixes
pdGroup_penalty_capacity_margin: densify againstgroupCapacityMarginso the row coverage matches the constraint emission (closes SCEN-6).investment_index: includeperiod_in_usein the index socapacity_margingolden joins on the full (entity, period) grid (closes SCEN-1 + SCEN-2 — golden regenerated).reserve__upDown__*.is_activeorphans get assigned thereserveparameter_group instead ofnull, which was failing the export-to-tabular YAML round-trip.solve_advancedexport-to-tabular YAML extended with thev44+v52params it was missing.
Test infrastructure
tests/engine_polars/conftest.py: lift_reset_global_axis_enumsto the top-levelconftest.pyso the Enum reset fires between every test (not just withinengine_polars/). Closes SCEN-3 — the global-axis Enum cache was carrying state across test modules.- pytest collection collisions cleared via
importlibmode + top-levelpytest_plugins(closes WF-1). - Test-path bumps to track the 3.41.3 file move (closes WF-8).
- Dead-test cleanup: six
execution_manager_wrapretired-cap paths (closes WF-2);test_base_dat_declares_unidirectional(closes WF-4). _reset_global_axis_enumslifted to top-level conftest (closes SCEN-3).
Dead-key cleanup
- Drop the unread
p_years_represented_d_calc.csvwriter. - Drop six unread
rp_base_*/rp_block_*writer paths.
Cascade & golden regen
- 9 of 10 remaining SCEN-1 goldens regenerated for the alternate-
optima drift + post-fix cascade output (the 10th lands as the
nodeGroupDispatchProvider backfill above).
Release 3.42.0 (21.5.2026) — writer → emitter refactor (Provider-first preprocessing emission)
Major refactor on top of 3.41.3. Renames and re-cuts the entire
preprocessing-writer surface so that every cascade producer emits
into the in-memory FlexDataProvider first, with disk writes
gated behind --csv-dump. This is the architectural step that
makes the always-in-memory cascade (started in 3.37.0 / 3.40.0 /
3.41.0) the only path; the on-disk CSV chain is now a
diagnostic-only side channel.
Phase 1 — rename _writer_*.py → _emit_*.py
Pure rename, no behaviour change. Every preprocessing-writer
module under flextool/engine_polars/ gets the new _emit_ prefix,
matching the verb used by the call-site contract. Updates
~50 import sites across the cascade and the test suite.
Phase 2 — add emit_*(provider=..., ...) parallel to write_*
Each preprocessing writer gains a sibling emit_* function with
the same compute body but writing to provider.put(key, frame)
instead of df.write_csv(path). The original write_* stays in
place; both paths run side-by-side during Phase 2 so the
transition is bisectable per call site.
Phase 3a — migrate call sites to emit_*(provider=...)
Every live call to a write_* in the cascade flips to its
emit_* sibling, threading provider and the parent-qualified
key prefix. ~70 call sites across _native_run_model.py,
_orchestration.py, _emit_solve_time.py, _emit_dispatchers.py,
and the chain-cluster path.
Phase 3b — delete write_*, _write, capture_frames, _PATCH_MODULES
With every call site migrated, the legacy write surface is gone:
the write_* functions, the shared _write helper, the
capture_frames decorator that wired CSV-snapshot diagnostics,
and the _PATCH_MODULES monkey-patch table all delete. Disk
emission still happens — but only via the Provider's
--csv-dump path, which iterates the registry once at the end of
each sub-solve.
Phase 4 — csv-dump round-trip test + snapshot audit
A new tests/engine_polars/test_csv_dump_round_trip.py runs a
fixture through the cascade twice — once with --csv-dump, once
without — and asserts that every key landing in output/ matches
the corresponding solve_data/ path byte-for-byte. Plus a
snapshot audit catches drift on the 174 registered emitter keys.
Phase 5 — strip vestigial paths, dead helpers, stale docs
Closeout cleanup, three classes:
(a) 11 orphan disk-write helpers (_write_keyed, _write_keyed_2,
_write_csv, _write_singles, _write_tuples,
_write_csv_rows, _write_5col) — every one a dead
_write(df, path) consumer that became a NameError after
Phase 3b deleted _write itself.
(b) 26 vestigial Path parameters on emit_* signatures, plus
the 32 call sites that still passed them. Each was confirmed
unused via AST walk of the function body.
(c) Stale docstrings, header comments, and specs/ references
that still pointed at the write_* surface.
User-visible impact
- Cascade memory footprint drops further (no per-key
df.write_csvround-trip). --csv-dumpis the only way to materialise the preprocessing CSVs to disk; that contract is now load-bearing for the test suite via the round-trip test.- The
_writer_*.pyfilenames +write_*symbols are gone from the public API surface offlextool.engine_polars. Downstream code calling them directly needs to switch to theemit_*siblings (no backwards-compat shim).
Release 3.41.3 (21.5.2026) — PyPI-readiness (package data + canonical-DB materialization)
Patch release on top of 3.41.2. Lands the two structural changes
needed before FlexTool can ship to PyPI: package data moves into
flextool/ so it survives a wheel install, and the canonical
.sqlite blobs are no longer tracked in git but materialised from
JSON at runtime.
PyPI Blocker 1 — package data via importlib.resources
Runtime path walks like
Path(__file__).parent.parent.parent / "templates" only worked
from a source checkout. After pip install flextool from PyPI
those walks resolved to site-packages/ and broke. Layout
changes:
- Ships in the wheel:
flextool/schemas/{default_plots,default_colors}.yaml,flextool/schemas/flextool_location.txt,flextool/schemas/canonical_databases/*.json,flextool/schemas/*.json(incl.spinedb_schema.json),flextool/schemas/pre_v26/*.json,flextool/bin/highs.opt.template. - Gitignored, materialised in CWD at first run (Spine Toolbox
refs + user-editable files):
./templates/*.sqlite(fromschemas/spinedb_schema.json+schemas/canonical_databases/*.json),./templates/*.xlsx(NEW — derived viaexport_to_tabular),./how to example databases/*.sqlite,./bin/highs.opt(seeded from package template).
Mechanism: new flextool/_resources.py exposes
package_data_path(rel) / package_data_text(rel) — thin
wrappers over importlib.resources.files(flextool) that work in
both editable and wheel installs. Every consumer of the moved
data was switched over (color_template, write_outputs, the GUI
YAML/JSON lookups, the CLI commands, the canonical-DB tooling).
flextoolrunner.PathConfig and engine_polars._orchestration
drop the hardcoded _REPO_ROOT = /home/jkiviluo/sources/flextool
fallback; default flextool_dir is now the installed package,
bin_dir and root_dir default to CWD. GUI flextool_root
(used as subprocess cwd) becomes Path.cwd(). Top-level
subprocess calls switched from python run_flextool.py /
python write_outputs.py to
[sys.executable, '-m', 'flextool.cli.cmd_...'] so the wheel
install (no top-level scripts) still works.
Canonical-DB JSON-as-source
Ten committed .sqlite blobs in version/ and
canonical_databases/ retired from git history; the canonical
source is now the matching JSON file. f694f35a adds the
migrate-all JSON-canonical export/materialize helpers and
e7c2bb7d documents the workflow in CONTRIBUTING.md. The
.sqlite files are materialised on first use into
./templates/ and ./how to example databases/ (gitignored)
from the bundled JSON.
Diagnostics
[mem]phase log: also bracket the per-sub-solve preprocessing writer chain and thepreprocessing_solve_time.rundispatcher, so the cascade-load checkpoints cover the new emitter chain.
Release 3.41.2 (20.5.2026) — GMPL retirement + canonical-DB hardening + scaling-output gate
Patch release on top of 3.41.1. Closes out the GMPL-pipeline
retirement (started in 3.33.0) by deleting the last stub modules
that survived the backend swap, gates scaling_analysis.json
behind --csv-dump, and threads more sub-checkpoints through the
[mem] phase log.
GMPL retirement closeout
Ten cleanup commits — every one removes a file or symbol that the 3.33.0 backend swap left orphaned. Deleted (or trimmed):
flextool/flextoolrunner/lagrangian.pyand three other fully- dead legacy modules — the GMPL stubs left in place at 3.33.0 as placeholders for the native rewire (now landed viaengine_polars._lagrangian).FlexToolRunner.run_model+ the legacymainentry point + theorchestrationimport — only reachable via the deleted--engine=gmplpath.solver_runner.resolve_relax_feasibility/resolve_ipmhelpers + their env-var constants — both mapped the removed--relax-feasibility/--ipmCLI flags onto HiGHS options. Only consumer wastests/test_solver_options.py, which deletes alongside.- Dead-letter GMPL solver knobs in
FlexToolRunner. - The uptime/downtime lookback + pdt-set CSV machinery (was the GMPL pipeline's pre-solve scratchpad; the native cascade computes these in memory).
flextool/flextoolrunner/solve_writers.py— legacy preprocessing writers shadowed by the native_writer_*ports. The one remaining live caller was repointed at the native module.- The
capture_post_solvemonkey-patch — belt-and-suspenders diagnostic from the GMPL→polars transition. - Two retired test modules:
legacy-vs-polarsparity tests + the broken manual fixture-regeneration scripts. - Untracked-but-committed harness/scratchpad files cleared.
Scaling output
scaling_analysis.jsongated behind--csv-dump. Was always-on diagnostic noise on production runs; users who want it can opt in via the existing dump flag.
Diagnostics
[mem]phase log: bracket the pre-load_flextoolentry sequence, and split theload_flextoolentry into five sub-checkpoints (then refined to three in 3.41.1 follow-ups).
Release 3.41.1 (20.5.2026) — Toolbox/cascade bug-fix sweep
Patch release on top of 3.41.0. Clears four user-visible regressions that surface when running from Spine Toolbox or comparing many Rivendell scenarios.
Bug fixes
cmd_run_flextool: default work folder to cwd in both code paths. When--work-folderwas omitted the CLI kept two variables (work_folder=Nonefor the solver,wf=cwdforwrite_outputs), sorun_chain_from_dbspun up its own/tmp/flexpy_run_chain_*whilewrite_outputslooked foroutput_raw/under cwd and tripped aFileNotFoundErroronv_obj.csv(output_csv/,output_parquet/,output_excel/,output_plots/were skipped on the failure path). Visible from Toolbox runs as awrite_outputs failedwarning right after the solve reported optimal.- Toolbox DB URL handling: six normalisation sites
(
_orchestration,_solve_config,_spinedb_reader,_timeline,spinedb_backend._backend) used to whitelist onlysqlite:/postgresql:schemes and prependedsqlite:///to anything else, turning Toolbox's engine-server URLs (http://127.0.0.1:<port>) intosqlite:///http://...and erroring withunable to open database file. The guard now passes through any URL that already carries a scheme and only prependssqlite:///for bare filesystem paths —spinedb_api'sDatabaseMappingalready resolves Toolbox server URLs natively viaget_db_url_from_server. - Scenario comparison: cross-scenario combine no longer fails with
KeyError: '[nan] not in index'inprepare_plot_datawhen some scenarios produce empty result frames.calc_connectionsused to build emptyconnection_d/connection_losses_dwithout a column- level name; once the per-scenario parquet writer wrapped them withpd.concat({name: df}, axis=1, names=['scenario'])they shipped with names['scenario', None]. The cross-scenario combine then collided with['scenario', 'process']from scenarios that do have connections, pandas resolved the conflict toNaN, and the comparison plotter blew up on the level-name lookup. Empty connection frames now carry the same'process'column name as the non-empty branch. - Same anti-pattern (empty / scalar result frames shipping without a
column-level name) cleared on four more producers that would have
hit the same
KeyErrorunder different comparison configs:out_ancillary.largest_flow(nodeGroup_inertia_largest_flow_dt_g),out_ancillary.years_d(years_represented__d),out_group.results_dt(nodeGroup_VRE_share_dt_g, both empty and non-empty branches),out_costs.co2_summary(CO2__).
Diagnostics
[mem]phase log: align columns into a single table, drop the duplicated(rss=…, peak=…)block, and split theload_flextoolentry into five then three sub-checkpoints so the cascade-load breakdown is readable in the always-on output.
Release 3.41.0 (20.5.2026) — Phase E: lazy broadcast cascade + always-on phase log
Built on 3.40.0. Lands Phase E of the Step 3 cascade-memory work —
the lazy-broadcast rewrite of the eight target Params and the
on-demand rebuild of the persistent cross-join scratch frames
(pss_dt, nodeBalance_dt, nodeState_dt, nodeState_first_dt,
process_indirect_dt). Together these drop the cascade-load peak by
~80 % on the H2_trade fixtures. Also unifies all phase-progress
logging into a single user-visible format, with section timing and
ΔRSS attributed per phase out of the box (no env-var opt-in).
Phase E.1 — lazy broadcast helpers (_param_shapes.py,
_direct_params.py)
broadcast_to_period_time/broadcast_to_periodnow returnParamwith dims matching the actual authored shape:SCALAR → (entity,);MAP_PERIOD → (entity, d);MAP_TIME → (entity, t);MAP_PERIOD_TIME / MAP_TIME_PERIOD → (entity, d, t). No eager.collect()inside the helpers — the underlying LazyFrame chain carries the period filter as an inner-join on the natural-dim axis instead of a cross-join withdt._filter_param_by_periodsoperates onp.lazy(NOTp.frame, which would trigger polar_high's eager cache), shape-aware (filters on whichever of{d, t}are present inp.dims), returnsParam(p.dims, lf).p_process_availability_from_sourcepromotes per-class parts (unit + connection) to union dims via lazy joins onperiod_filterfor missing axes, then concats lazily — preserves a single consistent dim shape across mixed authoring without forcing eager materialisation.
polar_high handles the (entity, d, t) broadcast lazily at
constraint emission via its shared-dim inner-join contract; the
dense cross-product only ever lives in the per-term collect that
polar_high streams to HiGHS one row at a time.
Phase E.2 — consumer-side promote_param_to_dt
The eight target Params (p_node_availability,
p_storage_state_reference_value, p_co2_price,
p_co2_max_period, p_process_availability, p_commodity_price,
pdt_max_instant_flow, pdt_min_instant_flow) have zero
direct .frame.<op> consumers in engine_polars/ — they're all
consumed via polar_high algebra (*, over=, rhs_terms=) which
inner-joins on shared dims and cross-joins on disjoint. But a
handful of cascade sites do hand-rolled eager joins for
"left-join with default 1.0" semantics that polar_high's *
doesn't natively express:
model.pyflow_upper_rhs availability fold, storage_state reference value chain._region_filter._inject_half_flowsconcat of virtual half-flow availability rows._dump_csvspdt*.csvslice writers (the dump path needs the fully-expanded(entity, d, t)frame so output CSVs match the pre-Phase-E layout consumers expect).process_outputs/read_parameters._pdtX_per_entitypandas pivot.
For these, a new helper promote_param_to_dt(param, dt) in
_param_shapes.py returns a LazyFrame with both d and t
columns — joining on dt for whichever axes the Param is missing
(cross-join when both absent). Each consumer calls the helper
once at the top of its function and re-uses the result.
Phase E.3 — drop persistent cross-join scratch
The five cross-join scratch frames previously held in FlexData
(pss_dt, nodeBalance_dt, nodeState_dt, nodeState_first_dt,
process_indirect_dt) were duplicates of the data that
v_flow.frame / v_state.frame / etc. already hold after
add_var. On y2050 the pss_dt alone was ~1.75 GB (43M rows ×
5 cols × 8 bytes); the four siblings each contribute ~0.5-1 GB.
- New module
flextool/engine_polars/_pdt_join.pywithcompute_pss_dt(d),compute_nodeBalance_dt(d),compute_nodeState_dt(d),compute_nodeState_first_dt(d),compute_process_indirect_dt(d). Each lazily joins constituents (process_source_sink,nodeBalance,nodeState,dt) and collects on demand. _fast_load._populate_pss_dt_and_balance_dtstops populating the fields; slow-pathFlexData(...)calls passNone.- 8 consumer sites in
model.py(add_var domains for v_flow / vq_state / v_state;Sum/add_cstrover=arguments; one explicit join withpd_neg_cap) call the compute helpers. Per-function locals cache the result so repeated reads don't rebuild the cross-join. _region_filter._inject_half_flowsusescompute_pss_dt(rd).- 12 test files updated to compute their own slices.
Always-on phase log
Previously [mem] phase-progress log lines were gated on
FLEXTOOL_MEMORY_DIAGNOSTICS=1 — users following a normal run saw
only the cryptic input pass NN: 0.007s lines with no memory
attribution.
_MemoryRecordernow emits log lines unconditionally (RSS reads from/procare essentially free).FLEXTOOL_MEMORY_DIAGNOSTICS=1additionally enables tracemalloc (sotraced_peakbecomes a real number rather than-) and writes the per-checkpoint CSV undersolve_data/memory_diagnostics.csv. Without the env var the log lines still appear but showpeak=-/Δpeak=-.- Module-level
set_phase_recorder(rec) / get_phase_recorder()lets deeper modules (input.py::_apply_db_overrides) emit checkpoints in the unified format without each carrying a recorder kwarg. input.py::_timedroutes through the recorder when one is active, falling back to the legacy plain print only when no recorder is registered (e.g. unit tests outsiderun_orchestration).- Three new sub-phase checkpoints inside
input_derivation.run: Spine DB loaded, DB-driven derivations done, Preprocessing writers done. - User-visible labels replace the cryptic internal identifiers:
cascade_start→ Run start;write_workdir_inputs_end→ Input data prepared (after malloc_trim);first_load_flextool_end→ Model cascade built;first_lp_build_end→ LP problem built;first_solve_end→ Solver finished. - Each line shows section time + Δrss + Δpeak + absolute (rss, peak). Sizes auto-format MB ↔ GB.
CSV schema (solve_data/memory_diagnostics.csv) unchanged —
downstream tooling that parses it keeps working.
Measured cascade-load impact (H2_trade test_24h, fast path):
| Checkpoint | Pre-Phase-E (3.40.0) | Post-Phase-E (3.41.0) | Δ |
|---|---|---|---|
cascade_start |
276 MB | 275 MB | parity |
Spine DB loaded (new) |
— | 476 MB | first sub-checkpoint |
Preprocessing writers done (new) |
— | 489 MB | — |
Input data prepared (post-trim) |
1284 MB | 392 MB | −69 % |
Model cascade built (first_load_flextool_end) |
3320 MB | 667 MB | −80 % |
LP problem built |
3340 MB | 667 MB | −80 % |
y2050 cascade-load (from Phase E.1+E.2 commit before E.3 landed):
3041 MB at Model cascade built vs. pre-Phase-E baseline of
16-20 GB (process was killed at the 5-min timeout). E.3 is
expected to drop this further; the user re-measures on their side.
Test results — 0 regressions on the targeted Phase E gate. 631-test inner-loop gate and the full v3.32.0 byte-parity suite will be re-verified before tagging.
Release 3.40.0 (20.5.2026) — Step 3 cascade memory (Tracks A + B.1) + post-Phase-4 bug-fix sweep
Built on 3.39.0. Lands the first two Step 3 memory tracks
(SpineDBBackend parsed_value eviction + chunked row accumulator;
FlexDataProvider lifetime + eviction infrastructure) plus the
post-Phase-4 bug-fix follow-on that cleared ~30+ tests across
Phase 4 dtype reconciliation, profile cluster parity, anchor-window
nested-Map indexing, capacity-margin × 1000 stale goldens, and the
fast-path / synthetic-solve cascade.
The remaining Step 3 broadcast-cascade peak (the ~3.3 GB
first_load_flextool_end checkpoint on test_24h) is addressed by
Phase E in the next release — see specs/phase_e_handoff.md.
Step 3 Track A — SpineDBBackend memory (spinedb_backend/_backend.py)
- Track A: SpineDBBackend.parameter_values drops each
MappedItem's cached parsed_value (Map / TimeSeries / Array
Python object) as the materialiser advances through params.
Spinedb-api's parsed_value is a lazy property; setting
_parsed_value=None releases the parsed object while keeping the
raw value/type for any defensive re-access. Each (class, param)
is touched once per input_derivation pass, so the
generator-wrapped eviction achieves the full win at zero
re-parse cost.
- Track A.5: row_origins is only consulted by _maybe_cast on
axis-enum cast failure. When axis_enums is None (the default
input_derivation path) the list is pure waste — replaced raw
list.append with a bound _origin_append that no-ops when no
cast is requested.
- Track A.6: chunked row accumulator — some specs (notably
('profile', 'profile') on H2_trade.sqlite) flatten into
multi-million Python row-lists. Flush rows into a polars
sub-frame every _ROWS_FLUSH_THRESHOLD (default 200 000) entries
and clear the list. Sub-frames are concatenated before
_maybe_cast runs. Gated to axis_enums is None; the
axis-enum-supplied callsites are tests with small fixtures where
the peak doesn't bind.
Step 3 Track B.1 — FlexDataProvider lifetime + eviction infrastructure
- EvictedFrameError(KeyError) — raised by get() on a name that
release_unused() has dropped. Carries frame name +
responsible item-group token. Deliberately not caught silently
anywhere; a hit signals an incomplete READS declaration or a
handler reading outside its declared scope.
- FlexDataProvider(rss_budget_mb=, retain_all=) — constructor
takes the threshold budget and the CSV-dump retention flag;
budget also reads from FLEXTOOL_RSS_BUDGET_MB env var.
- register_handler(handler_id, *, reads, groups=None) — handler
declares its frame reads and (optionally) the item-groups at
which it fires. groups=None = pinned to the last group
(conservative).
- precompute_lifetimes(item_groups) — walks registered handlers;
computes _last_needed[name] = (group_token, group_idx) per
frame. Re-running resets the evicted-frame markers.
- release_unused(*, after=group) — drops frames whose
_last_needed is at or before after in iteration order.
No-op when retain_all=True, when the threshold gate is closed
(rss_estimate_mb() <= rss_budget_mb), or when
precompute_lifetimes hasn't been called.
- rss_estimate_mb(), is_evicted(), reset_lifetimes() —
helpers.
- get() checks the evicted set first and raises
EvictedFrameError (with the correct breadcrumb) on a hit; the
bare/qualified key promotion logic in the existing lookup path
is extended to also recognise an evicted qualified key.
- No production-call sites plumb the new API yet — Phase B.2 will
declare READS on the cascade handlers; Phase B.4 will wire
release_unused into the LP build loop (deferred — Phase E
removes the underlying broadcast-cascade peak that motivated the
eviction; Track B.2-B.8 becomes regression-protection rather
than peak-reduction).
Measured impact on H2_trade test_24h (Tracks A + A.5 + A.6 + B.1)
| Checkpoint | Pre-Track-A | Post | Δ |
|---|---|---|---|
input_derivation peak (probe) |
3447 MB | 1091 MB | −2356 MB (−68 %) |
| process maxrss (probe) | 3625 MB | 1091 MB | −2534 MB (−70 %) |
write_workdir_inputs_end (post-trim) |
4796 MB | 1284 MB | −3512 MB (−73 %) |
traced_peak @ write_workdir_inputs_end |
(high) | 516 MB | −82 % |
Wall-clock cost +4 s / +16 % on test_24h input_derivation
(25 → 29 s) — chunking adds a small fixed cost per spec.
Bug fixes (post-Phase-4 sweep)
- Stochastic profile lazy cascade — forecast-branch period
handling (
_derived_profile.py+_writer_provider_io.py). Cleared 4 stochastic-fixture test_profile_cluster_parity failures (max-abs-diff 0.987 → 0). Lazy cascade now correctly handlesperiod1_upper / _realized / _lower / _midforecast branch rows. - autouse fixture resets global
axis_enumsContextVar between tests.load_flextool's finally clause sets the ContextVar on success so post-load consumers see the live vocabulary, but the ContextVar persisted across tests in the same pytest worker — pinning Enum dtypes that differ from the next test's fixture's vocabulary, surfacing asenum on left does not match enum on rightSchemaError. Autouse fixture intests/engine_polars/conftest.pyclears it before/after each test. Unblocked ~20 previously-flaky tests acrosstest_warm_chain_runner,test_orchestration_parity,test_axis_rename_helpers, Rivendell scaling, synthetic toys, loaders. - pbt_node_inflow + dump_csvs_roundtrip + anchor-window (10
tests). Cluster A (3): cascade uses
capture_framesso disk CSVs the pbt parity tests assumed weren't written — rebuilt the tests on top of a provider from the captured frames. Cluster B (3):apply_branch_clusteroverwrote fiveflex_datafields with provider-only reads, nuking seeds that_load_branch_artefactshad populated via disk fallback for static fixtures. Made overlay seed-preserving (only overwrite when override yields non-None OR the seed is already None). Cluster C (3 invest_5weeks_p* sub-solves): examples.sqlite'sinvest_5weeks.invest_periodsMap has BOTH levels named"x"soSpineDbReader._discover_index_colsemitted["x", "x"]and_emit_leafcollapsed them deepest-wins, silently dropping the outer anchor index. Disambiguate colliding nested-Map index names by suffixing deeper levels with_<depth+1>(xouter +xinner →x+x_2). - network coal-wind capacity-margin + multi-year-no-invest (4
tests). Sub-cluster A: capacity-margin × 1000 stale parquet
goldens (cascade
_group_slack.py:1233correctly applies the unit conversion per the canonical model spec; legacy GMPL parquet goldens missed it). Regenerated. Sub-cluster B: availability 1.44× real cascade bug in_param_shapes.pyandmodel.py— producer-side fix applied. Sub-cluster C: multi-year wind no-invest —_cumulative_invest.pyjoin-key mismatch onpEnum (cross-vocab); appliedalign_join_dtypes / cast_dimpattern. - Phase-4 dtype reconciliation in
_load_process_topology,_group_slack,_dc_power_flow. Three cascade producer-side fixes surfaced by post-Phase-4 enum activation, all following the establishedcast_dim / align_join_dtypespattern. Cleared ~30 tests acrosstest_warm_chain_runner,test_warm_param_autoupdate, Rivendell,test_scaling_bench, toys,test_orchestration_parity,test_native_cascade_parity,test_output_writer, emission, capacity-margin, loaders. - Phase-4 omnibus cleanup (15 tests). 3 obsolete
@pytest.mark.xfail(strict=True)ontest_scaling_parityremoved (thep_unitsizeexcludes-node-unitsizes bug was fixed by Phase 4 vocab union)._cumulative_invest.pyPhase 4.8h boundary-cast pattern applied to 4 more emit sites. NewFlexData.process_source_toSink_dcfield carries the one-direction toSink frame for DC arcs.input.py:1787threadedprovider=to_read_active_solvein_load_investsynthetic-solve gate detection.cmd_run_flextool.pypassesregions=regions_detectedtosolve_lagrangian. Various test-sidecast_dimreads to match production Enum. rename_to_axisentity→f wide-CSV cast + synthetic-solve dtt seeds. Two bugs in block-aware multi-fullYear storage —_read_wide_per_entity's long-CSV branch and the synthetic-solvedttseed routing.- Threaded
provider=to chained-existing-capacity readers. Restored the cross-solve handoff chain forp_entity_all_existing(similar shape to v3.39.0'sapply_derived_ffix). - Unscale
sol.objin place sostep.solution.objmatchesv_objparquet. Objective values written to parquet were unscaled but the in-memorystep.solution.objwas left scaled, surfacing as a parity gap downstream of the solver step. - Lazy NPV port — add missing
ed_lifetime_fixed_costarms. Three arms missing from the lazy NPV port (task #17). - Widen Enum vocab at injection in
_inject_half_flows. Half-flow injection produced Enum dtypes with narrower vocabularies than their union axis, breaking downstreamis_insemi-joins. - Fast path runs preprocessing in-memory, unifies with slow path.
The fast load path previously bypassed
input_derivation.run— unified so fast + slow paths share the same preprocessing semantics.
Test infrastructure
- Cherry-picked Track A + B.1 commits from the step-3-memory
worktree onto new-outputs (linear history, no conflicts).
tests/engine_polars/test_provider_lifetime.py (15 tests),
tests/spinedb_backend/test_parsed_value_eviction.py (4 tests),
tests/spinedb_backend/test_memory_budget.py (1 test) added.
Release 3.39.0 (19.5.2026) — enum dtype refactor + cascade perf
Built on 3.38.0. Completes the enum dtype refactor (Phase 0 → 4.9
plus the polar_high companion) so that every cascade axis-aware
column carries a polars Enum dtype end-to-end. The refactor +
follow-on perf work delivers a ~98 % reduction in per-loader cascade
memory and a ~9× speed-up in input_derivation.run on the H2_trade
test_24h fixture (the canonical large-fixture diagnostic).
Phase 4 enum dtype refactor (final pass — 4.7d → 4.9)
- 4.7d: align_join_dtypes adoption sweep + _load_edd_history
consumer audit
- 4.7e: Pattern 5 lit_axis for fill_null on axis-aware columns
(input.py block-compat joins)
- 4.7f: relax mixed-vocab n → e in flow_to_n / flow_from_n;
restore axis types post-Utf8 compare for d/b cross-Enum filter
- 4.8a: structural Enum-typing in _inflow_scaling
pbt_node_inflow producer/fold (dict-LazyFrames now declare
explicit Enum schema)
- 4.8b: schema_dtype(...) in empty-frame fallbacks across
_solve_context.py and writer modules — empty + populated frames
now agree on dtype under activation
- 4.8c: block axis vocab key_kind: "values_plus_default"
substrate fix (scalar-per-entity new_stepduration was returning
empty vocab); cascade block_coarse → "bk" cascade fix; together
clear 84 lh2_three_region failures
- 4.8d: defensive d / t Enum re-cast at p_profile_value_lf
entry — stochastic dt_lf paths cleared 25+ derived_e failures
- 4.8e: defensive d/t re-cast across _derived_arithmetic,
_derived_branch, _inflow_scaling, _derived_params,
_projection_params, _derived_block — 22 sites
- 4.8f: cast_frame_axes at cascade-helper entry — 17 sites
- 4.8g: cross-Enum is_in semi-join in model.py + _direct_params
entry cast
- 4.8h: model.py cross-Enum is_in → semi-join sweep across
18 sibling sites
- 4.8i: cross-Enum-different-vocab join fixes for wind_battery /
lh2 / network_coal_wind_reserve (model.py per-Var down-casts
reading target dtype from the FlexData schema, since
_LIVE_AXIS_ENUMS_CTX was empty inside build_flextool)
- 4.9 (substrate): polar-high-opt ships _align_enum_join_keys —
a generic subset-aware up-cast helper invoked at every internal
join site in polar_high/engine.py. Cascade can now drop ~25
per-Var down-cast lines in model.py; the DSL stays unchanged.
Documented in polar-high-opt/README.md "Enum dtype handling"
section + 12 new tests in
polar-high-opt/tests/test_enum_dtype_align.py
Cascade performance (perf-fix follow-on)
- SpineDBBackend.parameter_value cache:
find_parameter_values(class=, param=) under spinedb-api's
scenario filter scales with the in-memory parameter_value table
(~1.85 s per call on H2_trade), not with filter selectivity. One
bulk find_parameter_values() (~2.7 s for ~25 k rows) + Python
partition into {(class, param): [rows]} replaces ~100 individual
filtered calls in input_derivation.run. ~60× speed-up on
the parameter-fetch hot path. Cache is lazy per-backend-instance,
invalidated on close().
- Columnar _unroll_rows in SpineDbReader: replaced the
row-of-dicts → pl.DataFrame(out_rows) pattern (~503 ms on the
hot profile/profile param) with dict[str, list] →
pl.DataFrame(columns, schema_overrides=…) (~58 ms). Recursion
uses a positional idx_path mutated via append/pop, eliminating
per-recursion dict(base) copies. Tolerant of mixed-type Map
indexes via strict=False + per-leaf last-wins consolidation in
_emit_leaf. ~10 % per-parameter speed-up end-to-end
- Persist _LIVE_AXIS_ENUMS_CTX past load_flextool's return so
cascade helpers calling cast_dim(..., None, axis) see the live
enum vocabulary inside build_flextool (substrate-level fix that
unblocked _delay.py:324 and several latent cross-Enum joins)
- Forward provider= through apply_derived_f's three
*_from_workdir calls — restores the multi-solve handoff chain
(p_entity_all_existing accumulates correctly across sub-solves)
Measured wins on H2_trade test_24h (vs 2026-05-13 baseline)
- cascade_start: 276 MB → 276 MB (parity)
- input_derivation.run wall-clock: 33 s → 25.9 s (~21 % faster)
- Per-loader cascade ΔRSS: ~950 MB → ~21 MB (~98 % reduction):
_load_node -99 %, _load_process_topology -96 %,
_load_varcost -99 %, _load_profiles -32 MB
- Broadcast cascade (write_workdir_inputs_end →
first_load_flextool_end): +2.2 GB growth in baseline → −0.8 GB
drop here
- 631-test gate: ~23 s → ~17 s (incidental)
Cascade robustness
- seed_provider_from_dir is now tolerant of malformed
solve_data/*.csv files — stray dev artefacts with bad headers
no longer block the whole loader; a warning is logged per skipped
file
- Numeric-column → Enum cast guard at SpineDBBackend +
SpineDbReader emit boundaries: polars' numeric → Enum cast
reinterprets values as positional indices into the enum's
categories; the guard skips the cast for numeric source columns
(which are never dim columns under the contract)
- polar_high (3.39.0-companion): defensive if nm is not None:
guard on h.passRowName(i, nm) mirrors the existing col-name
guard — null row names from pl.format(...) on null axis columns
no longer crash highspy
Test suite
- Retired tests/engine_polars/test_db_direct_parity.py (~95 %
of its 981 tests were CSV-vs-DB-direct migration scaffolding —
the migration is established). Salvaged 45 survivor tests into
tests/model/test_db_direct_solve_parity.py: the LP-solve-vs-
parquet-objective parametric matrix, InMemoryReader /
load_flextool entry-point edge cases, test_resolved_default_landed
default matrix, and seven literal-value singletons. 37 pass; 4 of
the 6 remaining failures fixed mid-session (see "Bug fixes" below);
the 4 leftover failures are documented in specs/model_bugs.md
(two pre-existing LP-objective baseline issues, two stochastic
period-vocab cascades — none are enum-pattern fixes)
Bug fixes (parity-test survivors)
- _delay.py:324 cross-Enum compare: substrate ContextVar
persistence (Fix 1 — see above)
- apply_derived_f missing provider= forwarding to three
*_from_workdir handoff loaders (Fix 3 — pre-existing bug from
commit 5f9c50809a 2026-05-05, surfaced now)
- polar_high defensive passRowName guard (Fix 2 — masks but does
not fix the underlying stochastic period-vocab gap; tracked in
specs/model_bugs.md::PARITY-3)
- Mixed-type Map indexes regression from columnar refactor
(commit a98caa1c) — strict=False on the two
pl.DataFrame(columns, schema_overrides=…) call sites + last-wins
consolidation in _emit_leaf for duplicate index-column names
Documentation
- specs/memory_diagnostic_results.md — appended "Post-perf-fix
re-measure (2026-05-19 — commit e6797b17)" section with the
numbers above
- specs/model_bugs.md — added PARITY-1/2/3 (3 LP-objective parity
failures with legacy-trust discussion), RESERVE-1 (prundt
populator missing on fullYear_roll), ARITH-1
(flow_upper_rhs * p_process_availability Utf8 leaf on
test_a_lot), REGRESS-1 (FIXED), PERIODS-1 (_solve_periods
expects "i" column that examples.sqlite::invest_5weeks doesn't
produce), and TASK COVERAGE-1 (add automated e2e for three
examples.sqlite scenarios that surfaced four of the bugs above
— fixture data already in place, pure test-wiring)
Release 3.38.0 (18.5.2026) — Rivendell bug-fix suite
Built on 3.37.0's in-memory cascade. Fixes a cluster of regressions that surfaced on the Rivendell customer database after the cascade rewrite, plus a final round of test ports.
This release is the last known-stable point before the in-flight enum dtype refactor (Phase 0 → 4.8); pin to 3.38.0 until that lands green.
Bug fixes
- Rivendell bug 1+5+6: engine_polars/scaling.user_bound_scale recommendation is now column-only (a previous row+col recommendation collapsed solver progress on S17/B0)
- Rivendell bug 2 / BUG A2: engine_polars casts user_constraints coefficients to Float64 — previously left as int when authored as integer, breaking polars joins downstream
- Rivendell bug 3: co2_price shape probing uses value-domain inspection instead of column count when the shape is ambiguous
- Rivendell bug 4: regression test added for the process_online_dt UC column carry
- BUG A1: blocks + output writer route input/block reads through the Provider so they observe in-memory writer output rather than a stale on-disk snapshot
- BUG A3: process_outputs.process_source_sink_varCost keyed by alwaysProcess (previously dropped rows for processes that only exist in some subsolves)
- BUG A4 / A2-followup: branch-weight Provider-routed read + 1000× cap-margin restored on the cascade scaling/state path; provider threaded into cascade scaling/state helpers
- BUG B1: cascade fans rolling-ladder accumulators back into the FlexData Provider on each roll so per-roll cumulative quotas no longer drift
- BUG B2: MATPOWER → Spine converter sets node_type=commodity on the per-generator fuel nodes it synthesises (previously emitted with the default type, breaking node_balance_eq on imported MATPOWER cases)
- BUG p_online_dt_empty_no_blocks: p_online_dt_set falls back correctly when UC is enabled but no process_block is authored
Test infrastructure
- tests/perturbation/* ported to the native cascade harness
- tests/emission/* ported to polar_high Problem inspection (the underlying MPS-emission path was retired in Δ.22)
- Solver license probe at startup is skipped under test to silence the Xpress LicenseWarning on CI
- tests/model: invest-chain regression test (Pre-work 0b R1+R2) + LP-bound-range smoke for invest + work_base (Pre-work 0b R7)
Release 3.37.0 (17.5.2026) — In-memory cascade (FlexData Provider/Accumulator + package refactor)
This release retires the on-disk CSV round-trip that previously sat between every cascade stage. FlexData is now built, threaded, and consumed entirely in memory across the loader, writers, solver, and output writers. --csv-dump is the only path that still emits the intermediate CSVs (for debugging / GUI inspection); the production path keeps everything in polars.DataFrame form.
Two new packages factor the cascade I/O surface: spinedb_backend (Spine DB → FlexData), and input_derivation (preprocessing derivations that previously lived in flextoolrunner/preprocessing/*). flextoolrunner/preprocessing/* and flextoolrunner/input_writer.py are deleted.
FlexData Accumulator (Phases C–H)
- Phase C: per-sub-solve FlexDataAccumulator
- Phase D: load_flextool(seed=FlexDataAccumulator)
- Phase E (a–j): cascade consumes accumulator via seed=; 8 batches of monolith-writer "lifts" so writers populate the accumulator rather than emit CSVs (_writer_arc_unions, chain_params + co2_accumulators, _writer_pdt_params, _writer_period_params, dispatchers + calc_params, the remaining 4 monolith writers, _writer_solve_writers, period_calc + per_solve + reserve); --csv-dump gating; cross-solve carriers lifted into capture_frames; seed-aware csv.reader + path.exists() audits in writer + loader modules; fixes for Rivendell B0_base_hourly_rp and S08_co2cap_slice regressions surfaced by the lift
- Phase C.5: slimmed OrchestrationStep with keep_solutions opt-in
- Phase F: parquet-bundle registry + manifest.json for hand-off between sub-solves
- Phase G: process_outputs disk reads migrated to in-memory kwargs
- Phase H: end-to-end verification pass
FlexData Provider (Step 1 + Step 2 + Step 2.5)
- Step 1-a → 1-g-7f: FlexDataProvider scaffolding; pilot migration of _read_p_flow_max; full migration of input.py, writer-side reads, process_outputs reads; Provider-exclusive writer population; per-writer provider= threading through _writer_per_solve, _writer_period_calc, _writer_inflow_scaling, _writer_dispatchers, _writer_lp_scaling, _writer_chain_params, _writer_arc_unions, _writer_solve_time, _solve_context, _pdt_lookup, _derived_params, _derived_profile
- Step 2a–c: deleted the seed funnel + transitional shims; rewired --csv-dump and removed the CSV-emission gate
- Step 2.5-E (Phases A+B+C): Provider through the timeline + averaged-timeseries cascade
- Meta-test for post-Step-2 cascade invariants
- Tighter RAW_INPUT_FALLBACK_ALLOWLIST after the disk-arm purge; meta-guard against new disk fallbacks creeping in
Disk-fallback purge
- Disk-fallback arms deleted from 12 writer modules: _provider_open, _provider_lookup_positional, _derived_params, _invest_seeds, _dc_power_flow + _commodity_ladder, _derived_profile, _delay, _reserve, _group_slack, _derived_branch, _derived_existing, _timeline, _inflow_scaling
- _load_handoff_aux_pair callers fixed; path.open retired in the same area
- flextoolrunner/blocks routes write_block_data through _PATCH_MODULES
- Test verification: workdir-empty smoke + --csv-dump round-trip
input_derivation package
- Skeleton package created; validators extracted to _validators submodule
- Ported derivations: _write_process_method, _write_dc_power_flow_data, commodity ladders + ladder_sets — all now in-memory
- run() entry point introduced; write_input rewired through it; write_workdir_inputs routes through input_derivation directly
- flextoolrunner/preprocessing/* and flextoolrunner/input_writer.py deleted (superseded)
spinedb_backend package
- Skeleton package + entity / parameter / default materialisers
- _ENTITY_SPECS, _PARAMETER_SPECS, _DEFAULT_VALUES_SPECS migrated onto Backend.entities / Backend.parameter_values / Backend.parameter_defaults
- Provider threaded into write_input, _writer_leaf_sets, _writer_mid_sets, _writer_calc_params
- flextoolrunner plumbs Provider through solve_writers.write_all_branches, averaged-timeseries, and timeline_config.py disk read
In-memory region decomposition (item 2.6)
- flextoolrunner region decomposition is now fully in-memory across all entry points
Tests migrated to native cascade
- Migrated: test_commodity_ladder_smoke, test_commodity_ladder_rolling, test_years_represented, test_timings_csv, TestPGLibCase14Integration, test_cost_aggregation_semantics, test_non_anticipativity, test_lh2_three_region, test_obj_decomposition
- Retired: test_mps_parity.py deleted (legacy MPS parity dead); tests/emission/* skipped (flextool.mps emission gone in Δ.22 — ported to polar_high in 3.38.0); perturbation/* skipped (harness incompatible — ported in 3.38.0); test_solve_handoff.py trimmed (9 retired PoC tests dropped)
- test_integration_parquet_matches_csv_on_base_scenario skipped (legacy parquet/CSV parity is no longer meaningful)
Miscellaneous
- Commodity ladder reads routed through the Provider
- apply_npv signature mismatch fixed; provider threaded
- engine_polars/scaling: geometric-midpoint user_bound_scale + 6-decade gate
- GUI: Debug checkbox passes --debug --csv-dump to scenario runs
- Dropped redundant _native_leaf_set_override in _drive_cascade
- engine_polars: ported co2_max_total + fixed minimum_downtime invest tightening
- read_parameters: densified reserve_upDown_group_reservation to LP domain
- process_outputs / read_variables: stream parts via ParquetWriter row-groups
Release 3.36.0 (15.5.2026) — Output-writer hardening + LP determinism + Surface A audit
Hardening release. The engine_polars cascade is pinned to a deterministic HiGHS solve and the LP column/row ordering is now canonical, so re-runs and goldens are stable across PYTHONHASHSEED and across CI workers. Many process_outputs regressions surfaced during the polars-cascade adoption are fixed; golden CSVs that drifted on the glpsol→HiGHS transition are regenerated with REGEN_LOG documentation. Surface A loader audit lands 50+24+13 focused tests.
LP determinism
- engine_polars/scaling: pin HiGHS to deterministic simplex options
- Canonical LP column/row ordering via add_var / add_cstr wrappers
- Post-unique() set frames sorted before LP id assignment
- engine_polars keys _all_steps by per-roll solve name, not parent solve
- tests/db_utils: numeric columns cast to float in round_for_comparison
- assert_frame_equal gains atol=1e-4 to absorb the rounding step
- scaling._scale_cache cleared between scenarios in tests
- Two scenarios' time_budget bumped to absorb the determinism overhead (incl. wind_battery_invest_lifetime_renew_4solve 4.0 → 6.0 s)
Output writers (process_outputs)
- costs_discounted: multi-roll inflation/lifetime values now correct
- dtt MultiIndex sorted by (solve, period, time, t_previous)
- Per-period years_represented threaded through FlexData
- d_realize_invest filtered to realized periods only; divest periods included
- summary_solve.csv: solve names natural-sorted; Investment discount factor written over period_in_use
- VRE_potential + group_flows CSVs restored after a regression
- process__ct_method built from process_min_load_eff
- dt_realize_dispatch / d_realized_period use realized_dispatch
- unit_capacity__d.csv lag in rolling solves fixed
- (entity, period) lookups filtered to v.invest's actual index
- par / s unioned over full_dt for rolling/multi-solve
- Self-discharge multiply fixed on rolling/multi-solve scenarios
- unit__{input,output}Node wide-pivot columns sorted
- Both flow directions emitted for direct-method arcs
- commodity_node sets derived from FlexData flow_* frames
- nodeGroupDispatch arc-union sets backfilled for group_flows
- Sub-femto HiGHS LP residuals clipped in out_flows before CSV emit
- Native engine + process_outputs fix for nested invest+dispatch cascades
- Rolling-storage hand-off fix for non-forward-only storages
- Forecast-branch rows emitted when output_horizon=yes
- Reserve None-handling hardened; process_reserve column emission gated on scheduled reservation
- calc_group_flows: .squeeze() dropped — was crashing single-timestep solves
- engine_polars: detects period column under user-renamed Map.index_name
Goldens regenerated (HiGHS-clean vs glpsol residuals)
- 6 scenarios routed from retired glpsol to HiGHS
- coal_co2_limit goldens regenerated against alt-optima
- cost-penalty goldens regenerated (HiGHS-clean zeros vs glpsol residuals)
- multi_year_wind_no_investment unit_capacity golden regenerated
- 5 alt-optima goldens regenerated against canonical LP column ordering
- test_a_lot + 5weeks_battery_intraperiod_blocks alt-optima goldens regenerated
- 3 + 4 summary_solve.csv goldens regenerated (HiGHS precision + chronological Investment-factor order)
- hyphenated_entity_names regen documented
- tests/REGEN_LOG documents Phase-2 candidate verification (1 regen + 5 flags)
- Test infrastructure handles two-row-header / Unnamed: CSV forms
Surface A audit fixtures + tests
- Base fixtures for loader / constraint / objective audit
- 50 focused loader tests for Surface A audit
- 24 model-run constraint tests for B.1–B.15
- 13 cost-term isolation tests for B.16–B.20
- step_duration=3 derived-fixture regression guards
- Hand-computed step_duration LP objective tests
- engine_polars/scaling: two-sided guard on user_bound_scale recommendation
- engine_polars: cascade-vs-seed parity diagnostic
plot_outputs performance
- _plot_simple_bars vectorised; FixedLocator / FixedFormatter for bar tick axes
- _sum_row_heights vectorised
- Stacked & grouped bar paths vectorised
- Sub-pixel-wide bars skipped in draw path
- Single-level expand groups counted in pagination
- Stale on-disk plans invalidated via schema_version
- Phantom NaN rows/cols dropped after dim-rule pivot
Other
- native_run_model: write timeline_matching_map.csv from returned dict
- GUI: stable input-source numbering with orphan reuse + visual marker; source-number suffix always written in output folder names
- GUI: ResultViewer style overrides scoped to its own plot tree
- GUI: status / peak / timestamp columns widened in jobs tree
- tests/scenarios wired to engine_polars cascade
Release 3.35.0 (13.5.2026) — Cascade memory + GUI font system + plot performance
Performance, GUI and developer-experience release on top of 3.34.0. Long cascade runs no longer balloon RSS across rolls; the GUI handles per-monitor DPI scaling and user-tunable font sizes; dump_csvs no longer writes 7 GB debug oracles by default. Includes preparatory work for the engine-wide dtype refactor (axis-vocabulary discovery + canonical pl.Enums) — the end-of-load Enum sweep was activated and then parked in the same release as a precaution.
Cascade memory
- Scaling diagnostic gated to once per base solve — 38 % wall-clock speedup on large cascades
- Opt-in tracemalloc + RSS checkpoints to localise OOM phases (env-var gated)
- libc.malloc_trim called after heavy allocators — 38 % RSS drop
- Heap trimmed at end of each iter too (multi-roll heap accumulation)
- dump_csvs: skip 7 GB-scale CSVs by default; env var restores the debug oracle
dtype refactor — preparatory
- Phase 1+2: axis-vocabulary discovery + canonical pl.Enums
- Phase 3+4+5 (partial): load_flextool end-of-load pl.Enum sweep — activated, then disabled in the same release as a guard while downstream fallout was understood
- Dtype-flexible scratch frames in engine_polars (preparatory for Enum activation)
- cast_dim helper + populated-branch alignment; cast_dim sweep across cascade (3 files + remainder)
- process_outputs: normalise enum/utf8 join keys in _entity_all_capacity
Path B (rolling-handoff perf)
- Rolling-handoff CSV reads deduplicated (Path B, Category A)
- WriterSnapshot for top-7 per-solve preprocessing (Path B, Cat B)
- ctx threaded through period/branch helpers + _dt_period_active_steps + dtttdt
GUI font + DPI system
- Phase 1: centralised font metrics in ui_metrics.py
- Phase 2: per-role named fonts via setup_fonts
- Phase 3: saved geometry + sash clamped on restore
- Phase 4: rescale saved positions by font metric on restore
- Phase 5: Reset window layout action
- Phase 6: em-ified dialog widths
- Phase 7 + 7c: user-tunable UI font size + UX size tweaks
- Phase 8: per-monitor DPI font rescale
- Header fonts bound to named fonts so live size changes propagate
Other
- engine_polars: respect solve.new_stepduration in apply_derived_a
- engine_polars: preserve legacy entity-level rows in dump_csvs slices
- engine_polars: native p_inflow override in apply_derived_a disabled (regression guard)
- engine_polars: end-of-load FlexData → Enum cast disabled (preparatory work parked)
- tests: pbt_node_inflow multi-time_start + parent-period fixtures (Phase C)
Release 3.34.0 (12.5.2026) — Multi-solver support + documentation overhaul
This release ships the two user-facing additions on top of 3.33.0's engine swap: per-solve commercial-solver selection and a documentation refresh organised around the Spine Toolbox GUI as the primary FlexTool interface.
Multi-solver support
- New per-solve parameters (v52 migration) on the solve entity: solver, solver_io_api, solver_options, solver_time_limit, solver_mip_gap, solver_threads, solver_log_level. HiGHS remains the default — existing scenarios with no solver parameters are unaffected.
- Backends supported via polar-high: HiGHS (default, ships with FlexTool), Gurobi, CPLEX, Xpress, COPT. Each commercial solver requires its own Python wrapper + license; FlexTool itself never imports the commercial wrappers and never inspects licenses (vendor's discovery path handles it).
- The three convenience knobs (solver_time_limit, solver_mip_gap, solver_threads) are normalised across solvers; FlexTool translates them to each backend's native parameter name. solver_options passes raw key/value pairs through unchanged.
- Cascade startup probes each available solver with a trivial 1-var LP and logs a per-solver license status line, e.g. Solver license status: gurobi=licensed, cplex=licensed, xpress=licensed, copt=not-installed, highs=licensed.
- Cold rebuilds in the cascade dispatch through polar_high.solvers.solve(...) and normalise the result via a new LiteSolution adapter so downstream output writers stay unchanged. Warm-start and Lagrangian decomposition remain HiGHS-only by polar-high design — selecting another solver on a warm cascade logs a warning and falls back to cold rebuilds; selecting another solver on a Lagrangian-decomposed scenario raises a clear configuration error.
- Per-solver documentation pages under docs/solvers/ (install, licensing, common errors, how to set the solver in FlexTool).
Documentation overhaul
- Site reorganised around the Spine Toolbox GUI as the primary FlexTool interface; CLI flows are documented but no longer the default story.
- New developer guide and how-to recipes for the engine_polars-era codebase.
- decomposition_method parameter description tightened; superseded GUI screenshot removed.
Bug fixes
- _writer_mid_sets.derive_commodity_node_co2 no longer crashes with ComputeError: cannot compare string with numeric type (f64) on customer DBs where p_commodity.csv's value column is inferred as Float64 by polars. _read_csv now forces every column to Utf8 on read via infer_schema_length=0.
- process_outputs.calc_storage_vre no longer raises KeyError when node_self_discharge_loss is authored on nodes that have no v_state LP variable (supply-curve / commodity nodes). The self-discharge multiply is now restricted to the intersection of authored nodes and storage nodes.
- CLI cascade exception handler distinguishes FlexToolUserError (configuration problem — clean message, exit 1) from other exceptions (real flextool bug — full traceback). Users hitting unknown-solver / missing-license errors no longer see a stack trace.
- update_flextool now refreshes declared dependencies for editable installs too — pip install -e . with new core deps in pyproject.toml would previously be missed, causing ModuleNotFoundError on the next solver invocation. The --upgrade is now passed without --upgrade-strategy=eager so transitive dependencies aren't churned.
Performance
- Spine DB read: SolveConfig.load_from_db_url and TimelineConfig.load_from_db_url now pre-warm with db.fetch_all("entity") + db.fetch_all("parameter_value") before any find_* call, mirroring the legacy FlexToolRunner.__init__ pattern. Measured 5.5–6.4× speedup on large customer DBs (~1.8 s saved per cascade run); sub-MB test DBs see no change.
Other
- v52 schema migration renames the legacy solver value list to solvers in place: members expanded from [glpsol, highs, cplex] to [highs, gurobi, cplex, xpress, copt]; pre-existing solve.solver = "glpsol" values are rewritten to "highs" (GLPK retired in Δ.22). Parameter_definition foreign key is preserved through the rename so no data is lost.
Release 3.33.0 (12.5.2026) — glpsol retired; HiGHS via polar-high becomes the sole LP backend
This release closes out the largest architectural change since FlexTool went open source: the GLPK/GMPL pipeline that built the LP via glpsol, wrote MPS files to disk, and re-loaded them into HiGHS is gone. The new path builds the LP in process via polar-high on top of the polars DataFrame engine, hands it to HiGHS through highspy, and stays in memory for the entire solve → handoff → output-writer chain.
There are no binary build artefacts left to ship: glpsol is gone, the bin/glpsol* binaries that the previous releases bundled have been deleted, and the flextool.mod GMPL model file is deleted. FlexTool is now a pure-Python install — pip install flextool is sufficient.
The version is held at 3.x rather than bumped to 4.0.0 until the new engine reaches full parity with the old one across the test suite. A 4.0.0 semantic bump will follow once that bar is met; this release ships the backend swap behind the existing 3.x API surface.
Breaking changes
- bin/glpsol* removed; flextool/flextool.mod and flextool/flextool_base.dat removed
- --engine=gmpl hard-rejected with a clear retirement banner
- Legacy GMPL-pipeline CLI flags removed: --use-old-raw-csv, --ipm, --auto-scale, --relax-feasibility, --glpsol-timing, --report-near-duplicates
- The stubbed flextool/flextoolrunner/lagrangian.py (legacy GMPL coordinator) is deleted; --decomposition lagrangian now drives the native polars coordinator (see below)
- --highs-threads accepted as a no-op stub for GUI/Toolbox compat; the native cascade is single-threaded
New solver backend (engine_polars)
- Whole-system FlexData is built directly from a Spine DB or pre-staged workdir CSVs; the LP build, solve, and output handoff all happen in memory through engine_polars.run_chain_from_db
- HiGHS is consulted via polar-high.Problem / WarmProblem / LagrangianProblem; no MPS roundtrip
- New experimental --fast-single-solve CLI path for simple single-solve workloads — bypasses write_input entirely and reads inputs from Spine via SpineDbReader
- Native engine_polars/_writer_* ports of 150+ preprocessing sets and calculated parameters (Writer Phase 1-4: L0-L9, follow-ups 1-8, closeout, Phase 2 sub-dispatches 1-8, Phase 3 cascade adoption, Phase 4 Gap F handoff)
- Automatic LP scaling — analyser, scaling-report, two-sided cost-band guard, geometric-centering fallback, objective and bound scaling
- Δ.31: in-memory FlexData + solution threaded into process_outputs.write_outputs so the output writers no longer round-trip through solve_data/*.csv
Spatial Lagrangian decomposition — native rewire
- --decomposition lagrangian CLI rewired onto engine_polars._lagrangian.solve_lagrangian: per-region builds via LagrangianProblem, damped subgradient outer loop, primal averaging on the cross-region pipeline flows
- Smoke-test coverage on the LH2 three-region fixture pins the CLI contract within a 2 % gap-to-monolithic tolerance
Flex-temporal decomposition (carried over from new-outputs 3.29.0, fully integrated)
- Per-entity temporal blocks make mixed-resolution dispatch possible (hourly power + daily hydrogen in the same solve)
- v50 + v51 migrations land solve.new_stepduration and group-level new_stepduration + decomposition_method
- Constraints made block-aware: storage, conversion, flow capacity, DC flow, UC, ramp, profile, reserve; node balance generalised via overlap-set aggregation
- Output writers expand coarse-block variables back onto the fine timeline for the user
Test infrastructure
- Layer-1/2/3 test pyramid scaffolded, with golden objectives pinned on 10 scenarios and per-scenario timing budgets
- @pytest.mark.solver and @pytest.mark.smoke markers
- GitHub Actions CI on the smoke tier
- LH2 three-region JSON fixture + per-fixture native parity tests
- MPS-parity harness retired alongside the GMPL pipeline
Packaging / installation
- All binary dependencies retired — FlexTool is now a pure-Python install
- polar-high is now a core dependency (was an optional engine-polars extra)
- pip install flextool is sufficient on Linux, macOS, and Windows (HiGHS arrives via the highspy wheel)
- PyPI release in preparation (see specs/pypi_release_checklist.md for the remaining steps)
Release 3.32.0 (5.5.2026)
Bug fixes
- Excel template link-sheet retention: drop link sheets where the link's own class has no surviving data (e.g. connection_node when constraint is unselected)
- group.{include_stochastics, new_stepduration} retagged from model/timeline to solve_advanced
- timeset.{timeset_weights, representative_period_weights} retagged to solve_advanced
- Quadratic linear scan in blocks::derive_blocks::_assign_entity_to_group replaced with a precomputed (entity → group) lookup
New features
- --groups CLI flag on cmd_export_to_tabular to restrict the Excel template to a parameter-group set; sheets with no surviving columns are dropped
- GUI parameter-group picker on "Add empty FlexTool input Excel" — DB-driven checkbox tree, required groups (timeline, model, solve_basics, basics) highlighted with hover tooltip; theme-aware colours
- Default-value row in v2 Excel sheets where any parameter on the sheet has a default (Map/Array defaults stringified)
- Representative-period clustering input drops scalar-valued profiles/inflows
Note — new-outputs also landed four hot-path optimisations on the legacy flextool/flextoolrunner/preprocessing/* writers (is_static fast-path on PdtLookup, sparse-emit + default 0 on the six dense pdt* CSVs, pandas.read_csv swap in _read_pdt_at_param, sparse iteration in pssdt_varCost_*, the O(N²×T²) → O(N + |pairs|) fix on node_inflow_scaling_params.has_node_time_inflow, and side-node indexing for process_arc_unions::write_node_group_dispatch_sets). Those edits target a code path that the engine_polars writer port (Writer Phase 1-4) is retiring, so they were not carried across in the merge — the performance lessons are preserved as actionable items for the native polars writers in specs/sparse_writer_lessons_for_engine_polars.md.
Release 3.31.0 (4.5.2026)
Bug fixes
- Stochastic per-branch profile values previously lost in pdtProfile are restored
- Plot dataframe fragmentation in dispatch column-alignment fixed
- Dispatch view reads group flags from nodeGroupDispatch (not nodeGroupIndicators)
- solve.timeline_hole_multiplier now declared in the v44 schema (default 1.0)
- db_migration v42: robust legacy value-list cleanup via find_parameter_value_lists
- Migration steps tolerate NothingToCommit so a second run doesn't abort the chain
- RP clustering input: scalar-valued profiles/inflows dropped
New features
- mod → Python preprocessing migration: ~150+ sets and calculated parameters previously declared in flextool.mod now live under flextool/flextoolrunner/preprocessing/, written per-solve to solve_data/
- Migration scaffolding: phase-0 inventory + MPS parity harness + lint rules + DAG of derivations
- 70 incremental migration batches with MPS parity verified at 7-sig-fig precision across multiple baselines (rolling, multi-solve, contains_solve, h2_trade, test_a_lot)
- Per-class param taxonomy in _param_taxonomy.py (PROCESS_TIME_PARAM, NODE_PERIOD_PARAM, etc.)
- Order-determinism lint rule (tests/test_preprocessing_ordered_set_lint.py) — bare set() / set-literals / set-comprehensions blocked in preprocessing/
- templates/examples.sqlite rebuilt from tests.json and migrated to v51
Release 3.30.0 (28.4.2026)
Bug fixes
- read_highs_solution: strip GLPSOL quotes from ISO 8601 timestamps in parsed variable names
- handoff_writers: load resolved-set CSVs from solve_data/ (Phase A follow-up)
- plot_canvas: cap rendered DPI to keep figure pixmaps under X11's limit
- RP: representative-period indices sorted into chronological order
- plot_bars: bar thickness constant per row regardless of bar count
- GUI memory-watchdog: kill only when BOTH free-RAM reserve AND swap allowance are breached
- GUI: refuse to open the same input source twice (flash row red)
New features
- result_viewer Phases B–E: single rebuild path with filter-only tree toggles, per-scenario availability union, lazy plan-parquet union, comparison-only plans
- GUI per-job memory budget + watchdog + admission control + status bar
- GUI: drag-to-reorder + Alt+Up/Down on result-viewer scenarios tree
- GUI: shared CheckTreeController for every check tree + generation tokens for comparison-data updates
- GUI: View button always shown; opens single mode focused on the scenario
- GUI: persist per-variant duration with template default + clamp-safe save
- GUI: robust delete + prune dangling scenario state from settings.yaml
- .mod-resolved set files moved from input/ to solve_data/; set_ prefix dropped
- --glpsol-timing diagnostic flag for per-constraint matrix-gen timing
- nodeBalance_eq and conversion_indirect use tuple binding instead of overlap equality-filters
Release 3.29.0 (24.4.2026)
Bug fixes
- v50 item-lookup bug fixed while restoring unidirectional in the master template
- export_to_tabular whitelists synced with master template schema
- Lagrangian: defensive name-map consistency check per iteration
New features
- Flex-temporal decomposition: per-entity temporal blocks for mixed-resolution dispatch (e.g. hourly power + daily hydrogen in the same solve)
- v50 migration: new_stepduration moved from timeset to solve
- v51 migration: group-level new_stepduration + decomposition_method
- blocks.py: derive per-entity blocks + overlap set + block predecessors + boundaries
- Generalised node balance via overlap-set (M-matrix) aggregation; constraints made block-aware (storage, conversion, flow capacity, DC flow, UC, ramp, profile, reserve)
- Output writers expand coarse-block variables back to the fine timeline for the user
- Spatial Lagrangian decomposition: regional input filter (cross-region flows as half-flows), lagrangian.py scaffolding + subgradient loop with primal recovery, CLI flag + docs
- LH2 three-region fixture + golden integration test (Lagrangian vs monolithic)
- HighsModelHandle persistence helper for repeated subgradient solves
Release 3.28.0 (24.4.2026)
Bug fixes
- Default HiGHS to serial simplex (parallel=off, threads=1) to avoid non-determinism and occasional stalls on small models; defaults reinforced in solver_runner as belt-and-suspenders
- Restore 0.05 discount_rate default and related advisory defaults that had drifted
- Revert slack primary+escape split — back to single-variable slacks with penalty acting as a valve
New features
- HiGHS upgraded to 1.14 (highspy>=1.14)
- Auto-scaling of LP/MIP numerics: row scaling via node_cap/group_cap, objective scaling, and bound scaling with diagnostics printed
- --auto-scale CLI flag that gates objective/state auto-apply behaviour
- --highs-threads CLI flag
- HiGHS mip_detect_symmetry enabled — ~15× speed-up on unit-commitment with identical units
- virtual_unitsize documented as a speed lever for UC with identical units
- unidirectional transfer method for connections
- Cost-aggregation semantics fixed — weighting factors applied per variable class
- Divest salvage included in objective; pre_existing renamed to all_existing; storage valuation documented
- Auto-seed missing output_info, output_settings and comparison_settings databases on first run
- Seed bin/highs.opt from tracked template on first run
- scaling_benchmark harness relocated to benchmarks/scaling; unit tests moved to tests/
- min_load_efficiency section-term test promoted from xfail to passing
Release 3.27.0 (22.4.2026)
Bug fixes
- Annualize nodeGroup_flows_d_gpe to match the node_d_ep convention
- Column misalignment in ed_lifetime_fixed_cost[_divest].csv writers
- Harden axis-manifest override against None active_scenarios
- Sum only over stack_levels in shared axis-bound resolution
- Reserve provision with a timeseries reserve requirement
- Reserve provision active only when there is demand
- Delete dead plot functions
New features
- Parameter groups — every parameter now assigned to a group; "Outputs" renamed to "output"
- Parameter-group colours re-tuned for readability in both light and dark theme
- Colour template infrastructure for plots: category-based colouring (costs, node_flows) and entity_class.group colouring (flowGroup)
- Cross-scenario axis-bounds manifest — viewer reads shared bounds so y-axes stay stable across scenarios
- Per-scenario axis manifest with subset filter
- Composite colour lookup for nodeGroup_flows plots
- Colour template plumbed into the batch render path
- Plot plan JSON cleanup (redundant timestep fields dropped)
- Group-output parameters renamed; output_results split; flow-group indicator stub added
- Alphabetical sorting of subplots
- Periods ordered left-to-right in vertical p-variant bar plots
Release 3.26.0 (21.4.2026)
Bug fixes
- Negative-remaining infeasibility in cumulative price-ladder cap
- Bug in realized_invest uniqueness
- Parameter_type_list corrections across migrations and master template
New features
- Commodity price ladder — price_method, unitsize, price_ladder parameters for commodities
- v_trade variable (MWh × unitsize) with tier caps and objective routing
- Rolling cumulative-quota handoff for price_ladder_cumulative — per-period accumulators preserve remaining quota across rolls
- Two-roll cumulative-ladder validation scenario
- Rolling-aware co2_max_total with per-period accumulators
- Node-type consolidation aligned with the ladder work
Release 3.25.0 (20.4.2026)
Bug fixes
- Align fix_storage_*.csv header between init and phase 3
- Carry fractional tail in write_years_represented (tested at R=2.5)
- canonical_sort sorts stably by solve_pos only, preserving within-solve order
- Drop _round_to_sig from the parquet read path
- Reindex extract_variable output to canonical phase-1 row order
New features
- Direct HiGHS → parquet extraction replaces the phase-3 glpsol reader round-trip for solver outputs and solve handoffs
- Phase-3 glpsol retirement — derived-parameter printfs moved pre-solve; readers repointed at input/ and solve_data/
- Canonicalise row order by solve-creation order across all readers
- has_balance, has_storage and node_type consolidated into a single node_type enum
- Parquet VariableSpec column names aligned with CSV readers
- empty_variable_frame helper for same-shape empties
Release 3.24.0 (19.4.2026)
Bug fixes
- storage_state_start first-timestep semantics corrected for cyclic bindings
- Goldens regenerated for the sink-flow coefficient flip
New features
- coefficient on capacity constraints split into flow_coefficient, max_capacity_coefficient, min_capacity_coefficient
- Sink-side flow_coefficient flipped from division to multiplication — both sides of the balance now use the same convention
- New pre_built capacity-coefficient term alongside the invested term
- Growth-cap recipe documented
- Lifetime-aware dispatch capacity bound
- max_flow_for_unconstrained_variables model parameter replaces the previous 1e6 literal
- CO2 duals exposed; rp_cost_weight threaded into constraints; horizon-vs-annual outputs distinguished
- timeset_weights wired through the runner (populates rp_cost_weight.csv)
- no_investment handled as a lifetime_method
Release 3.23.0 (17.4.2026)
Bug fixes
- Pre-processing now also updates the solve period_timeset
- Step duration correctly applied in period aggregations of flow-derived outputs
- Reserve Excel handling fixed
New features
- Greedy convex-hull clustering for representative-period selection
- Representative-period storage binding via bind_using_blended_weights (renamed from bind_using_rp_weights)
- bind_intraperiod_blocks storage binding method for LTS-style intra-period blocks (blocks defined by gaps in timeline indices)
- Multi-year wind no_investment scenario test and goldens
- Base-weighted scenario test and goldens
- Delays for units and connections (constant or map)
- 5weeks_battery_intraperiod_blocks scenario test and goldens
Release 3.22.0 (7.4.2026)
Bug fixes
- Parquet loading strips the scenario column level
- savefig works for Figure() objects not registered with pyplot
- Threading errors from plt.figure() replaced with Figure() usage
- Dispatch colors and nodeGroup filtering
- Cancel pending draw_idle to prevent redundant re-renders
- Numerous canvas / toolbar jitter fixes (freeze/thaw around draws, layout locking)
- Legend clipping, tree navigation, canvas clearing
- Stacked-area plot axis limits
- Empty years_represented for rolling dispatch solves
- CO2 emissions parquet key mismatch
- Scenarios without connections
New features
- PlotPlan — pre-computed plot plans saved to disk
- Threaded figure building with prefetch cache and memory-based GB cache limit
- Time-series downsampling via tsdownsample with a numpy fallback
- Network graph visualisation from the Spine database
- Cross-scenario dispatch y-limits and column consistency
- Comparison-mode parquet pipeline
- Availability manifest for three-level variant display
- Redesigned three-level variant navigation (Shift+Up/Down to next row with data at focus column)
- PlotPlan unit tests
- YAML config restructured with entry names as top-level keys
- Per-variant plot_name and explicit variant field in configs
- Dispatch mode in Result Viewer
- combine_scenario_parquets and comparison checkboxes in the viewer
- Parquet file-size reduction by dropping DataFrame multi-index bloat
Release 3.21.0 (1.4.2026)
Bug fixes
- Handle scenarios without connections
- Connection output for method_2way_1var (DC power flow)
- Guard against all-NaN values in plotting
- Reserve provision active only when there is demand for the reserve
- Mac glpsol binary naming and selection across platforms
- Fixed and cleaned-up test suite
New features - DC power flow B-θ formulation in the GMPL model - Method resolution moved from GMPL to Python - DC power flow data pipeline and database migration - Output processing and parquet passthrough for DC power flow - MATPOWER import CLI wrapper - PGLib-OPF IEEE 14-bus integration test - 24-test DC power flow pytest suite - glpsol built for macOS, Linux and Windows via GitHub Actions (including macos-15 arm64)
Release 3.20.0 (29.3.2026)
Bug fixes
- Excel migration edge cases
- Direct execution from Excel (.xlsx) corrected
- GUI updated to handle different input Excel versions
New features
- FlexTool 2.0 importer — reads old FlexTool 2.0 Excel .xlsm files into a Spine database
- Import of sensitivity scenarios from FlexTool 2.0
- CLI entry point for FlexTool 2.0 sensitivity import
- Version-aware importer that handles older FlexTool 3.0 Excels as well
- invest_method support in the FT2 importer
- _inflow renamed to _node; _storage used where applicable
- base used instead of Base as the default alternative
- Stochastic sheets in Excel
- Improved Excel read/write roundtrip, including DB-to-Excel export
- Dialog to migrate Excel files
- Single source of truth for the FlexTool input DB version (flextool/update_flextool/__init__.py)
Release 3.19.0 (14.3.2026)
Bug fixes - Execution runs from the FlexTool root with isolated work folders - Comparison Excel written to the correct directory - All scenarios no longer produce identical results - Output-actions column order, drag-select and spinbox sync - Foreground-color crash on start - Several threading and layout issues
New features
- Result Viewer window with scenario listbox, plot tree and variant panel
- PlotCache with memory-based GB limit
- PlotCanvas with PNG display wired into ResultViewer
- Dark mode with sv_ttk and a theme-toggle radio
- Font-relative and DPI-aware GUI sizing
- Plot-settings dialog with YAML config parsing
- Execution menu window with subprocess pool and parallel workers
- Database version checking and auto-upgrade from the GUI
- Custom file picker with last-modified column and sorting
- Visual feedback: green/grey/red highlights, boxed outputs, auto-check
- Keyboard shortcuts (including Ctrl-A to select all) and rearranged move arrows
- DB-editor integration with process tracking
- --work-folder support for parallel scenario execution
- --parquet-base-dir for comparison without a database
- output_db_url optional in cmd_run_flextool
- Dual variant state in the viewer: desired (dashed) + shown (solid)
- Animated hourglass spinner on output-action buttons
- Persisted checkboxes, View button, sizing adjustments
- Parent-directory button in the file-picker dialog
Release 3.18.0 (27.2.2026)
Bug fixes
- Fix reading input files in run_flextool.py
- Many small issues surfaced by the refactor fixed so all examples execute
New features
- Major refactoring into deep modules — package structure reorganised for separation of concerns
- New subdirectories to keep the FlexTool root uncluttered
- Initialisation now also creates settings databases
- Updated update_flextool.py
- Walked back global flextool CLI commands — they do not work with multiple installs
- Added output_info_template.sqlite
- flextool_location.txt for runner discovery
Release 3.17.0 (14.1.2026)
Bug fixes
- Float-to-string handling in the import spec
- Node-summary column alignment
- nodeBalancePeriod uses pdtNodeInflow (sum over period) instead of annual_flow (annual_flow should not carry a sign)
- Rolling-model fixes including p_roll_continue handling
New features - Excel import specification - Direct Excel-to-DB read pipeline - Calamine engine for faster spreadsheet reading - Full FlexTool execution from Python — no Toolbox required - Removed data checks from later solves (they belong in the first solve)
Release 3.16.0 (5.12.2025)
Bug fixes
- Fixed costs split off from investment costs and their calculation corrected
- years_represented with a discount factor >1 now calculated correctly
- Timestep durations checked to be positive and non-zero
New features
- Speed-up in Python output writing
- Result list refactored
- Old write_outputs.mod glpsol output code removed from flextool.mod
- Further plotting enhancements and minor refactoring
- solve_progress.csv with timings and scenario name
- Vertical plots, sums and means, better subplot spacing
- write_outputs can be used to plot a single ad-hoc time series
Release 3.15.0 (2.12.2025)
Bug fixes
- costs_discounted.csv calculation for multi-year models
- Empty sets and rolling-model crashes
- Negative capacity units handling in outputs
- Inertia calculation in outputs
- Missing fix_storage_time_lists
- Several output-processing bugs (ramp, investment, storage, group results)
New features
- Python post-processing pipeline reaches parity with the old glpsol write_outputs.mod — all result CSVs replicated except node ramp envelopes
- Scenario-comparison specification (scenario_comparison.json) and combined-scenario results framework
- Removed redundant fix_storage constraint that used dt_realized_dispatch
- First ugly-duckling plotting on top of the Python pipeline
Release 3.14.0 (6.8.2025)
Bug fixes - Fixing problems with group results (wrong signs) - Fix broken importer - Fix calculation of VRE shares in the results - Other operational costs for units had gone missing in 3.12.0 (22.5.). Now they are back.
New features - There is a new migrate button in the workflow to update the active input database to the latest version.
Release 3.13.0 (27.6.2025)
Bug fixes - Added missing fixed costs to operational models - Catching infeasible models better
New features - Time structure has been simplified, see ´https://irena-flextool.github.io/flextool/reference/#how-to-define-the-temporal-properties-of-the-model' - Changed the time structure to have timeset instead of timeblockSet - Added periods_available for the model entity, to have periods in the data that are not used without domain errors - Added assumptions about the model timestructure to ease the defining of it.
Release 3.12.0 (22.5.2025)
Bug fixes - Decimal points in node_balance_t were causing some grievance. - Issues with lower case / upper case filenames when importing results - Added missing parameters inertia_constant, ramp_cost, ramp_speed back to the model (caused by release 3.10.0) - Fixed a output processing crash in a rolling model without VRE
New features - Delays for units and one-way connections. New parameter delay, measured in time-steps (be careful), which can be a constant or a map of (integer timesteps, weight) where the weights should add to one. The map allows to spread the delay like it might happen in river systems. - An order of magnitude speed-up in reading inputs
Release 3.11.0 (4.3.2025)
Bug fixes - Preventing crashes on curtailment calucaltions to units without capacity and MIP stochastic solving - Correcting nested storage state passing
New features - Timeseries prices for commodity and co2
Release 3.10.0 (31.1.2025)
Bug fixes
- Prevent HiGHS from hanging after founding a solution
- Fix profile limits (issue when efficiency != 1)
- Made to work with new Toolbox DB API
New features
- New HiGHS versio (1.9.0)
- Script to see the differences in outputs (or inputs) between two model runs (meant for testing)
- FlexToolRunner.py reads input data directly from DB (speed-up)
Release 3.9.0 (28.11.2024)
Bug fixes
- Fixing Excel input template
New features
- Updated documentation to match Toolbox changes
- New HiGHS version (1.8.1), should help with model getting stuck.
- Reorganising file structure to remove clutter from FlexTool root
- Some name changes in the workflow
- FlexTool has requirements.txt and pyproject.toml (allows pip install -e ., but not in pypi)
- Improved installation instructions (switched from miniconda to venv)
- Linux (Ubuntu tested) should work out of the box (but FlexTool execution only in work directory one scenario at a time!)
Release 3.8.0 (29.8.2024)
Bug fixes
- Excel input file to use entity_alternative
- unit_curtailment_share fix to Excel input
New features
- Existing capacity can be set for periods
- Cumulative limits for investments in nodes, units and connections
- New node_type that has period limit for inflow
- New highs solver version
- All parameter now have valid types. These are not enforced, but Spine Toolbox highlights parameters with a wrong type.
Release 3.7.0 (24.6.2024)
Bug fixes
- Several bugs related to the migration to Spine Toolbox 0.8.
- CPLEX call without pre_command fixed
- Timeline aggregarion without all timesteps
- User constraint including capacity fixed to include unitsize
- User constraint including flows fixed to include step_duration
- Creating multiple invests with 2D maps fixed
- Update_flextool.py/ migrate_database.py crashing if nothing to commit
- Nested structure:
- Pass end state only from dispatch level
- Storage fix_quantity overrides other storage options
New features
- Added non-anticipatory constraint to stochastic branches. The brnaches now start from the first timestep.
- Storage_reference_value: Now possible to set seperately for multiple end times as time step map.
- Nested structure storage_nested_fix_method:
- Added fix_usage
- Fix_price changed to set the price to the objective function of the lower level solve
Release 3.6.0 (15.5.2024)
New features
- Upgraded FlexTool to Spine Toolbox v0.8
- Entity alternative replaces is_active parameter
Release 3.5.0 (29.4.2024)
Bug fixes
- Stochastic and rolling weights
Release 3.4.0 (13.4.2024)
Bug fixes
- Inertia with MIP units added missing unitsize
- Result ordering improvements
- Fixing how flow variables are limited
- Min_load to work with multiple inputs/outputs¨
- Capacity limit includes the efficiency
New features
- Output of inertia seperately for units
- Default penalty values for reserve, inertia, capacity margin and non-synchronous limits
- Large model data processing speed-ups
Release 3.3.0 (23.3.2024)
Bug fixes
- Investments: maxState to only sum over the investments in this node, not all the nodes
- Ramp constraint now correctly calculates the investments done in previous periods
- Min and Max Invest/Divest constraints to only affect investment periods
- Efficiency works with profile units
- Allow upward and downward slacks to be more than the inflow
- Potential VRE generation now includes efficiency and availability
- Non-sync group constraint now excludes the flows inside the group
- Two-way MIP connection to allow only one direction at a time
- Connection variable cost to the objective function
- Allow investing with just
invest_periodswithoutrealized_periods - unit_inputNode:
coefficientapplied correctly to units with multiple inputs
New features
- Two-stage stochastic modelling of future uncertainty
- Better infeasibility and required parameter checks to inform the user what is wrong with the model
- Small changes to some result data parameters
- Added availability (constant /timeseries) parameter for entities
- Several outputs to be optional to hasten the data transfer.
- Added group flow output parameter
output_aggregate_outputsto give the flow of the desired nodes in a grouped format - Option for loss of load sharing
share_loss_of_loadbetween nodes in a group to better describe the system where loss of load is present - A workflow item to display the summary file from model runs
- Database with pre-made time settings for the users to start with time_settings_only.sqlite
- C02 max total costraint
- Default values for:
- Penalty values
- Efficiency
- Constraint constant
- Reserve reliability
Documentation
- Introduction
- Improved tutorial (including how-tos split of into their own section)
- Installation / update instructions
- Theory slides
- How to section to guide on making specific parts of the model
Building parts of the model:
- How to create basic temporal structures for your model
- How to create a PV, wind or run-of-river hydro power plant
- How to connect nodes in the same energy network
- How to set the demand in a node
- How to add a storage unit (battery)
- How to make investments (storage/unit)
- How to create combined heat and power (CHP)
- How to create a hydro reservoir
- How to create a hydro pump storage
- How to add a reserve
- How to add a minimum load, start-up and ramp
- How to add CO2 emissions, costs and limits
- How to create a non-synchronous limit
- How to see the VRE curtailment and VRE share results for a node
Setting different solves:
- How to run solves in a sequence (investment + dispatch)
- How to create a multi-year model
- How to use stochastics (represent uncertainty)
General:
- How to use CPLEX as the solver
- How to create aggregate outputs
- How to enable/disable outputs
- How to make the Flextool run faster
Release 3.2.0 (23.8.2023)
Bug fixes - Storage state, unit flows and ramps were not properly limited in multi-period investment models. - Removed limits from vq_state (they were causing infeasibilities in some case). - Added time changing efficiency to profile based flows. - Flows from node to unit in unit__node are marked negative (were positive).
New features
- Breaking change: realized_periods (solve objects) is supplemented with a realized_invest_periods so that the user can state from which solves the results should take investment results and from which solves dispatch results.
- New parameters for solve objects: solve_mode, rolling_duration, rolling_solve_horizon, rolling_solve_jump and rolling_start_time that enable to build rolling window models. See How to use a rolling window for a dispatch model.
- New parameter contains_solves that enables nesting solves inside solves (e.g. to calculate shadow values for long term storages or to implement rolling dispatch inside a multi-year investment model). See How to use Nested Rolling window solves (investments and long term storage).
- New outputs: For groups: VRE_share_t. For unit__nodes (VRE units): curtailment_share, curtailment_share_t.
- Name changes to outputs: flow to flow_annualized, sum_flow to sum_flow_annualized.
- Add migration for results database (parameter descriptions can be migrated).
- Documentation updates: new how-to sections.
- Faster outputting of ramp results.
- update_flextool.py will leave the Toolbox project untouched.
- Disable execute project button from the FlexTool workflow.
Release 3.1.4 (14.6.2023)
Bug fixes - Cancelling of plot_results will not freeze Toolbox
New features
- Lifetime_method (reinvest_automatic and reinvest_choice) so that user can choose whether assets will be automically renewed at the end of lifetime or the model has to make that choice
- Commercial solver support (CPLEX)
- Database migration. init.sqlite and input_data.sqlite will be updated to the latest version when update_flextool.py is run. It is also possible to update any database using migrate_database.py. After git pull, run python -m update_flextool.py. In future, just python -m update_flextool.py is sufficient. Best to update Spine Toolbox before doing this.
Release 3.1.3 (1.6.2023)
Bug fixes - Use of capacity margin caused an infeasibility - Certain inflow time series settings crashed the model (complained about ptNode[n, 'inflow', t]) - Investments did not consider lifetime correctly - units stayed in the system even after lifetime ended - Lifetime is now calculated using years_represented - Fixed how retirements work over multiple solves
New features - Added support for commercial solvers (CPLEX explicitly at this point)
Release 3.1.2 (23.5.2023)
Bug fixes - Node prices were million times smaller than they should have been (bug introduced when scaling the model in 3.1.0) - Non-synchronous limit was not working.
New features - Documentation structure was improved - Model assumes precedence between storage_start_end_method, storage_binding_method, and storage_solve_horizon_method (in that order)
Release 3.1.1 (13.5.2023)
Bug fixes - The calculation of the p_entity_max_capacity did not consider which investment method was actually active. It also limited capacity in situations where investment method was supposed to be 'invest_no_limit'.
Release 3.1.0 (13.5.2023)
Bug fixes - Division by zero capacity for units with cf - Output fixed costs for existing units - Fix output calculation for annualized fixed costs - Fix error in importing CO2_methods for groups - Add other_variable_cost for flows from unit to sink
New features - New investment method with a limited horizon (model used to assume infinite horizon) - Braking change: new parameter 'years_represented', which replaces 'discount_years'. It is a map to indicate how many years the period represents before the next period in the solve. Used for discounting. Can be below one (multiple periods in one year). Index: period, value: years. - Added sidebar to documentation (gh-pages branch) - Default solver changed from GLPSOL to HiGHS - Documentation improvements - Scaling of the model resulting in very big performance improvement for linear problems - Improved plotting for the web browser version - In Spine Toolbox, plotting is based on the specification from the browser version - More results: group flows over time - More results: costs for whole model run - More result plots
Release 3.0.1 (17.3.2023)
Bug fixes - CO2_method for groups was not imported correctly from Excel inputs - Fix other_variable_cost in units for flows from unit to sink (source to unit was working)
New features - Output fixed costs for existing units - Added outputs to show average capacity factors for periods
Release 3.0.0 (11.1.2023)
All planned features implemented
TEMPLATE
Release (dd.mm.yyyy)
Bug fixes - foo
New features - bar