Release notes
Release 4.4.0 (13.11.2026) — capacity margin, units of measure, start-up inputs, period-total constraints, FT2 import, stochastic input checks (no future information), per-project solver options, solver limits kept with warnings, Xpress MIPs, objective options in model units and many fixes (schema v79)
Input databases move to data version 79. The FlexTool window, the Spine Toolbox workflow and flextool-run
--migrate migrate them; upgrading prints a notice for each behaviour change that affects the database. FlexTool now
needs polar-high 3.14.0 or newer (installed with FlexTool).
Behaviour changes — read before upgrading
Each line says what changes for a model that ran before and what to do; the details are in the sections below.
- Capacity margin rewritten. Units count at capacity derated by
availability(VRE by its profile), storage-fed units, connections and consumption by their flow, exports add to the requirement; an investment solve's shortfall and penalty are now reported in an investment → dispatch chain. Check the margin in the newcapacity_margin__d. discount_rate0 now means 0 % (before: 5 %), a negative rate is an error, and an entity that invests or retires with a cost needs alifetime. Set 0.05 to keep the old results.- A zero or missing
invest_costno longer blocks investment (nor a missingsalvage_valueretirement) whereinvest_methodallows it. To forbid a period, use a _period method withinvest_max_period0, or not_allowed. - A solve-level
new_stepdurationcombines every time series by rule (mean, harmonic mean for efficiency, sum forinflowandenergy_margin_adder); before, most took the first time step's value. To keep the oldenergy_margin_adderdemand, divide it by the number of time steps a solve step combines. - VRE curtailment, VRE potential and a flowGroup's
cumulative_flowper period are MWh per year like the flows (a 2-day period: 182.5 times larger). Update scripts that read these tables. - A period-map limit that leaves out a period of the solve is an error (before: a CO2 cap of 0, no inertia requirement, no investment limit, ...). Fill in the missing periods.
- A mistyped period key is an error (y2020 for p2020, numeric or time-step keys on a period parameter;
before, the value was dropped). Correct the key; data for extra years goes in
model.periods_available. - A profile link without profile data is an error (upper_limit or fixed; before, it held the flow at 0). Give the profile a value in the scenario's alternatives, or remove the link.
- More input errors stop the run: out-of-range
efficiency,min_load,availability,capacity_max_coeff,lifetime,ramp_cost, a minimum above its maximum,existingas a time series, a used timeline withouttimestep_duration, a time series with no time step on a timeline, a timeset block that starts off its timeline, a CO2 method without its value, arealized_invest_periodsoutsideinvest_periods, partial or ungrouped stochastic inflow / reservation, a roll of a stochastic rolling solve that reaches no forecast, arealized: yesrow that switches the realized branch between rolls, a contained single solve that would run under one name with different periods, a time-onlyenergy_margin_adder, capacity-margin groups across Benders regions or with a balance_within_period node (see "Input checks" and the entries below). Each error names what to fix. - New warnings name data that ran silently: nodes with nothing to balance them, untied zero
conversion_flow_coeffflows, negativeexisting, time-series keys off the timeline, a CO2 cap on a method without one, aninvest_methodon a non-storage node, percent-like values, forecasts declared where no solve or roll fans out (ignored). The model is unchanged; check them. - Profile and time maps without
index_nameare read as written. A profile keyed by periods, or a period × time profile with an unnamed level, read 0 before; a node penalty,availabilityorother_operational_costtime map withoutindex_nameno longer adds its time steps to the periods. Results of such models change; name the map's levels where the run says so. - A reserve on a group without nodes is dropped with a warning (before, it crashed the run or was met only by shortfall). Add the group's nodes if the reserve is meant.
- An explicit 0 in a flowGroup limit (
max_/min_cumulative_flow,max_/min_instant_flow) is now a limit of 0; before, it meant no limit. Delete the value if no limit is meant. ramp_costis now charged on unit flows withramp_methodramp_cost or both (new cost category ramps).- The engine refuses a database that is not at the current version. Migrate it with
flextool-run --migrateorflextool-migrate-database; the FlexTool window, the Spine Toolbox workflow andexecute_flextool_workflow.pymigrate first. - Per-period result tables of a rolling solve name the solve, not the roll, and the capacity tables the solve that realized the investments. Update scripts that filter on roll names.
- Excel sheet names and layout changed: long names keep their
__d/__dtsuffix, several shipped sheets are renamed, aunit of measureheader row or a unit block is added, and dispatch data has aperiodcolumn. Update Power Query and scripts (CSV names do not change). - The FlexTool window names a scenario's Excel workbook after its result folder,
output_excel/output_<scenario>_<source number>.xlsx(before,output_<scenario>.xlsx). Update scripts that open it; the command line and Spine Toolbox keepoutput_<scenario>.xlsx. - Smaller output changes: a storage node's
entity_annuity_d_pis per MWh (was per MW; values unchanged), no_reserve groups no longer have an empty reserve-shortfall column, and only groups whoseco2_methodcaps emissions are listed as CO2-limited. - FlexTool 2 import corrected (invested capacity becomes
existing, CHP output2, must-run profiles, storage start / finish,availability, the kW/kWh ratio,interest0). Re-convert FlexTool 2 workbooks. - Storage in stochastic solves follows each path. bind_within_solve closes the cycle on every forecast branch (before: only the one listed last), and forecasts opening in the solve's first period start from one level. The upgrade marks such databases and the run names the nodes; check their results.
- After the fan, a stochastic solve or roll reads only the forecast made at its fan, never a later one (a later roll's, or the forecast meant for the next solve of a chain). Before, a forecast given at the fan's analysis time read 0 in the later branch periods (so a wind unit could not run there), and a single solve's later periods read the forecast given at their own first step. A later time step that only a later forecast covers is now an error: give each fan's forecast to the end of the solve's or roll's horizon. Results of multi-period stochastic models change.
- The realization dispatch of a single stochastic solve dispatches only its realized periods, not the
look-ahead: the storage it hands to the next solve follows the storage node's end rules at the end of the realized
periods (as at the end of a roll's jump), not the realized data of the look-ahead. Results of stochastic single
solves with look-ahead change; rerun them. A
separate_dispatchsolve withoutrealized_periodsgets no realization dispatch. - Stochastic values before the forecasts branch out come from the forecast made at the solve's or roll's start. Every period before the reveal now reads the realized branch's forecast made at the start. Before, a period read the forecast given at its own first step, which is information the solve does not have yet; where none was given (a roll that starts inside a period, a period between the start and a later fan), it read 0 or the default without a message (the earlier GMPL model refused such inputs). In a fan without a realized branch, it silently read the first forecast in sorted order. The run now stops with an error naming the entity, the parameter, the period, the time steps and the solve or roll when the start's forecast does not cover a step, or when a fan without a realized branch would have to supply such data. Give the realized branch a forecast at each solve or roll start that covers the periods before the fan, designate a realized branch, or give the parameter a value without forecasts; rerun such models.
--solver-time-limitand--solver-mip-gapnow reach Gurobi, CPLEX, Xpress and COPT and win over the database and option file; the environment variables are nowFLEXTOOL_SOLVER_TIME_LIMIT/_MIP_GAP(theFLEXTOOL_HIGHS_*names still work for HiGHS only). Commercial runs with these flags can give other results.--threadsnow sets the thread count of Gurobi, CPLEX, Xpress and COPT too (the FlexTool window always sends its Threads per run, default 1), over the option file andsolver_arguments, as for HiGHS, even a value meaning all cores (0); the run prints a line when it replaces a value. A GUI run withThreads 0ingurobi.optnow uses 1 thread: raise Threads per run. Without--threadsandFLEXTOOL_THREADSthe option file keeps it; per-algorithm thread controls (XpressBARTHREADS, COPTBarThreads, ...) are not changed.- A Spine Toolbox run uses 1 thread unless
--threadsorFLEXTOOL_THREADSis given (before: all cores for FlexTool's processing, at most 8, and the solvers' own thread settings): polars, HiGHS, the thread count of Gurobi, CPLEX, Xpress and COPT (over the option file, with the printed line naming "Spine Toolbox run's default") and Benders region solves. Toolbox runs its tools in parallel and nothing shares the cores between them. For more, add--threads Nto the FlexTool tool's arguments. - At a search limit (time, node, solution, work or iteration) a MIP with a solution keeps it, with a warning
naming the limit, its option and the gap; an LP or a solve without a solution stops the run, for every solver.
So does an objective target the user set (HiGHS
objective_target, GurobiBestObjStop). A memory limit, an interrupt, or a solve not certified optimal still stops the run. A HiGHS MIP solved withobjective_boundgets a warning that its "optimal" may not be optimal. - Objective targets, bounds, cutoffs and absolute gaps are in unscaled currency (the currency of the model's
cost data), on every solver: HiGHS
objective_target,objective_bound,mip_abs_gap; GurobiBestObjStop,BestBdStop,Cutoff,MIPGapAbs; CPLEX cutoffs, objective stops and absolute gaps; XpressMIPABSCUTOFF,MIPADDCUTOFF,MIPABSSTOP; COPTAbsGap. Before, the solver compared the value with its scaled objective (scale_the_objective, default 1e-6, times the autoscaler's HiGHS factor), so with the defaults a value acted roughly as million currency. Divide an existing value by the solve'sscale_the_objective(with the default 1e-6: multiply it by 1 000 000), and a HiGHS value also by 2^user_objective_scale. A Benders solve refuses an objective target or bound. - Solver option files come from the run's
solver_configfolders, file by file (--solver-config-dir, next to the database, the project, the workspace, ...), not from the folder FlexTool starts in. A GUI project can have its ownsolver_config/: its runs take each solver's option file from the project, else the workspace, else FlexTool's default. Check theSolver config:line of the run log, which names the folder of each file, and move your.optfiles there. - Other solver-option fixes that can change results: a whole-number HiGHS option from the database (stored as
3.0) now reaches HiGHS (before, it was dropped with a warning; a non-whole value now stops the run); CPLEX now getssolver_mip_gap; one value per solver parameter, whatever its case; Xpress solves a MIP withmipoptimize(it returned the LP relaxation); Gurobi file-writing parameters are not passed on. A CPLEX option name starting withset(FlexTool adds it) is refused with a message naming it, from every source (CPLEX refused theset set ...line before); a UTF-8 byte order mark at the start of an option file is ignored (before, it made the first option's name invalid). - A solve that FlexTool splits or adds now runs on the solver you chose. Since 4.0, when a solve on Gurobi,
CPLEX, Xpress or COPT was split into one solve per period (a 2-D map in
invest_periods,realized_periods,realized_invest_periods,fix_storage_periodsorperiod_timeset), the split solves ran on HiGHS, withoutsolver_mip_gapand with thesolver_argumentsnames HiGHS also knows; since 4.1, the realization solve FlexTool adds to a stochastic solve (<solve>__realized_dispatch) ran on HiGHS too. The run did not say so. Results of such runs could have come from HiGHS; rerun them. Now every such solve runs on the original solve's solver, with itssolver_arguments,solver_mip_gap, option file and this computer's solver wrapper. - A solve split by a 2-D period map now runs as the solves you would write by hand. A 2-D map in
invest_periods,realized_invest_periods,realized_periodsorfix_storage_periodssplits a solve into one solve per row (<solve>_<row>). Before, the split solves silently lost parameters of their solve: since 4.0stochastic_branchesandstochastic_invest_method(they ran deterministic) andnew_stepduration(they ran on the coarser time steps with hourly data, at a fraction of the demand); since 4.1dispatch_realization_method(a separate_dispatch solve had no realization solve) andshared_operation_periods/shared_invest_periods. A 1-Drealized_periods,realized_invest_periodsorfix_storage_periodsnext to the 2-D map was read as the row's name. Now a split solve has every parameter of its solve; a 1-D period list keeps the split solve's own periods, andyears_representedits own periods (discounted from the start of the run, see the entry on discounting below). A solve listed after the split solve inmodel.solvesnow runs after the split solves (before, it ran before them). Results of such models change: rerun them. - Two set-ups of split solves now stop the run, and the message says what to change. A 1-D
realized_periods(orrealized_invest_periods) next to a 2-Dinvest_periodsthat looks ahead, such as {p2035: [p2035, p2040], p2040: [p2040]}: both split solves would realize p2040, and the later one would overwrite the results of the first. Makerealized_periodsa 2-D map with each period in one row: {p2035: [p2035], p2040: [p2040]}. A stochastic solve split so that one split solve has none of the periods where the forecasts start: that split solve would ignorestochastic_branches, so it stops, as the same solve written by hand does. Give the forecasts a start in a period of each split solve, or write the solves by hand and leavestochastic_branchesout of the solve that has none of their periods. A 2-Dperiod_timesetis now an error too: giveperiod_timesetall the periods and split the solve with a 2-D period list. - Forecasts that no split solve uses now stop the run. In a solve split by 2-D period maps, each split solve
now takes only the
stochastic_branchesrows of the periods it realizes (see "Solving" below). A row in a period that no split solve has, or that no split solve takes, was silently ignored before; it is now an error naming the solve, the period and the branches. Remove the rows, or add the period to the realized periods (realized_periodsorrealized_invest_periods) of the row of the 2-D maps whose solve should realize it. years_representedwithout an index name now counts for investments. A map left with Spine's default index name gave investments one year per period, while operations counted all the years, so investments were too cheap (every 4.x release). Investment results of such models change. A map whose index has another name (for exampleyear) now stops a model with investments: name the indexperiod, or leave it without a name.- Runs of several solves with an
inflation_rateother than 0: every solve now discounts its investments and its operations from the start of the run, as one solve over the same periods does (also split, rolling and nested solves that start after the run start). Before, a later solve discounted its operations, and when itsyears_representedheld only its own periods also its investments, from its own start. Where only the investments were discounted from the run start (hand-written solves with the wholeyears_representedmap), the balance between investment and operating costs was wrong and decisions change. Otherwise the whole objective of a later solve is scaled by one factor: decisions change only through absolute MIP gaps, objective targets or bounds and terms without a discount factor, but the printed objective, the investment duals (invest_marginal) and the cumulative CO2 cap price of later solves are now on the run's discount base. A fractionalyears_represented(e.g. 2.5) now counts as its fraction for the periods after it (before: a whole year, so the next period started at year 3), also in a single solve. Ayears_representedmap that lists the periods out of the solve's period order (or an unused period before a used one) discounted the investments in the period order and the operations in the map order, also in a single solve; both now follow the map order. The reported costs were already discounted from the start of the run. Rerun such models; the upgrade marks the databases this can affect. - The memory-saving solve (
--save-memory, or warm start off) could put values on the wrong variables; results of such runs can change. - Fixes that change results: a user constraint without capacity terms got a right-hand side of 0 when another
constraint had one (since 4.0); the reported fuel, CO2 and cost of a unit with a profile and an input were too
low; the reported reserve penalty could be doubled; the period node-group indicators and VRE share are now ratios
of the period energies; the
summary_solve.csvslack lines and the period reserve averages use the time-step weights; withoutrealized_invest_periodsa solve realizes only therealized_periodsthat are alsoinvest_periods;invest_period_totalcould retire andretire_period_totalinvest; avirtual_unitsize≤ 0 is now ignored; the invested capacity, investment cost and investment marginals of a period now come from the solve that realizes its investment (in some solve chains and stochastic solves they were missing or a later solve's 0). Details below. - Security: solver options are checked before a solve. Since 4.0, a database's
solver_arguments(or an option file from another person's project) could add commands to a CPLEX, Xpress or COPT run. FlexTool now refuses, for every solver including HiGHS, an option name or value with a line break, control character, space, quote,;,#or=; names are letters, digits,_and.(CPLEX: words separated by single spaces). The error names the solve and parameter, or the option file and line. Numbers (-1,1e-4,inf), single words and paths without spaces still pass; a value with a space, such as a file path, or ending in a backslash is now refused: use a path without spaces. An empty value is refused too, for every solver; for Gurobi, CPLEX, Xpress and COPT also an option-file line with a name but no value (before, the line was skipped or read wrongly); for CPLEX this includes a word where the parameter takes a number (mip tolerances mipgapwithout its value) and a group of parameters with a value (mip tolerances 0.01). An empty HiGHS value fromsolver_argumentsor a flag, which--save-memoryalready refused when the solve started, now stops every run before its first solve. - Solver options in the input database take no file paths or server settings. Since 4.0, a database's
solver_argumentscould make the solver, HiGHS included, write over or read files anywhere on your computer (for example HiGHSsolution_file/log_file, GurobiLogFile/InputFile, CPLEXworkdir, XpressTUNEROUTPUTPATH), or send the model to another computer. An option there that names a file or folder now takes only a plain file name, written in the solve's work folder (<work folder>/solve_data/subprocess/), and not the name of a file that a solver reads by itself from that folder (gurobi.env, a licence file such as*.lic, COPT'slicense.dat/license.key/client.ini, Xpress'sxpauth.xpr/nethasp.ini); a full path, a Gurobi Compute Server, Cloud, WLS, token-server or licence option, an Xpress remote-solving control (COMPUTE...), and a/,\or:in a text parameter that names no file (CPLEXcpumask), is refused with a message naming the option, its value and the solve. Move such options tosolver_config/<solver>.opt: the option files, flags and environment variables keep full paths and server options. Names are matched as the solver reads them (Gurobi ignores_, Xpress anXPRS_prefix, CPLEX reads.as a space); the full list is in the solver docs. Gurobi'sResultFileis FlexTool's own and is ignored, with a warning, wherever it is set. FlexTool's results are inoutput_parquet/<scenario>/as before; a commercial solver's log is saved as<work folder>/solve_data/subprocess/<solve>.log; HiGHS' log is in the run's log,output_parquet/<scenario>/_run.log(withflextool-runor the FlexTool window). - Solver parameter names in the input database must be the solver's own. FlexTool now has a list of every
parameter of HiGHS 1.15.1, Gurobi 13.0.2, CPLEX 22.1.2, Xpress 46.01 and COPT 8.0.4, and a solve's
solver_argumentsmay name only those. Any other name, in a solve the model runs, is refused before the first solve of the run, with a message that suggests the closest name: a misspelling (MIPGapp, which the solver ignored or refused before), a HiGHS name in another case (Presolve), a CPLEX abbreviation (writemip tolerances mipgap, notmip tol mipgap), or a parameter of a newer solver version. HiGHS names that your installed HiGHS knows as a number or a switch are taken too. The remote-solving and licence parameters are refused whatever their type, and so are Gurobi'sJobID/Dummyand CPLEX'sdefaults. FlexTool 3'ssolver,parallelandpresolve(moved intosolver_argumentsby the database migration, for every solve) are left out of a solve that uses Gurobi, CPLEX, Xpress or COPT, with one warning per run. To use a parameter that is not in the list, put it insolver_config/<solver>.opt(an unknown name there gives a warning and is passed on), or allow such names from databases on your computer withFLEXTOOL_ALLOW_UNLISTED_SOLVER_PARAMETERS=1or"allow_unlisted_parameters": trueinsolver_settings.jsonin FlexTool's user settings folder; no database, project or command-line flag can switch this on. Even with it on, such a name takes only a number, a switch or a plain file name, a server-like name (also CPLEXdistmip ...) is refused, and so is a CPLEX abbreviation of a listed name (d cfordistmip config). See "What the database may set" in the solver docs. - Solver option files that FlexTool finds by itself get the database's rules, unless your computer set up their
workspace. A run looks for
solver_config/next to the input database, in the workspace of a project database, in the Spine Toolbox project folder or in the folder it starts in, and the FlexTool window uses its workspace's. Such a folder may have come with a workspace, model or Toolbox project from someone else, so its.optfiles now get the same limits as the database'ssolver_arguments(only plain file names, placed in the work folder; no server or licence settings; only listed parameter names), unless the folder is in a workspace this computer set up. FlexTool remembers those workspaces insolver_settings.jsonin its user settings folder, outside the workspace:flextool-updateand the FlexTool window add their workspace when they create itssolver_config/, or find nothing there but FlexTool's own default files. A workspace whose option files were already edited is not added by itself, so if you editedsolver_config/*.optand use file paths, server settings or parameters that are not in FlexTool's list there, your runs now stop with a message naming the file, the line and what to do: tick "Trust this workspace's solver option files" in the FlexTool window (Solver options → On this computer), or pass the folder with--solver-config-dirorFLEXTOOL_SOLVER_CONFIG_DIR, which are trusted as before (also the way for CI and servers). Option files with plain settings (threads, gaps, time limits) work as before. The run'sSolver config:line and the run record say whether the folder was trusted. The same holds for a GUI project's ownsolver_config/, judged file by file: an untrusted project file used over a trusted workspace's file gets the database's rules. FlexTool notes a project it creates, or opens with no edited option file and nothing received waiting for review; an imported project's shipped files stay untrusted until you tick "Trust this project's solver option files" (Solver options → On this computer). A terminal user can runflextool-trust-workspace <folder>in a terminal, which shows the edited option files and asks (piped input is refused, and control characters in the files are shown as escapes); it trusts the files as shown, and nothing when one changed before the answer. The trust is for the folder as it was then, not for its path: a folder put at the same path later (for example a copy from someone else unpacked where yours was) is not trusted, and an option file changed afterwards is trusted only when your own account wrote it and no other account can change it; otherwise trust the folder again. Moving, restoring or copying a trusted workspace to another path or computer asks for the trust again, and so can a network drive mounted again. Spine Toolbox projects are code: open one only from people you trust. FLEXTOOL_USER_HOME,XDG_CONFIG_HOMEandXDG_CACHE_HOMEmust be full paths: a relative one is ignored with a warning, and the platform's user folder is used. A full path is used wherever FlexTool starts; a solver settings file that lies inside a folder judged for trust makes that folder's trust unreadable, with a message.- The solver settings file must be UTF-8 text and, on Linux and macOS, yours and writable by you only
(
solver_settings.jsonin FlexTool's user settings folder, which FlexTool writes so): a file another account owns or can write is not read, as a solver wrapper named there runs as you. The message says how to fix it (chmod 600). solver_precommandis not run, and a value now gives a warning. FlexTool 3 ran it in front of CPLEX (for example to reserve a floating licence); FlexTool 4 does not run commands stored in a database, for safety: a database from someone else could otherwise run any program on your computer. The parameter is kept only so that older databases still read; the warning comes once per solve. To hold a floating licence, use your solver's own licence server setup, or set the program as a solver wrapper on your computer (see New features).- Security: FlexTool runs only its own installed code. The Python processes FlexTool starts (runs, representative
periods, calibration, conversions, comparisons, the database editor, updates) no longer look for Python code in
the folder they start from, so a project folder from someone else cannot replace part of FlexTool. "Build
representative periods" and "Calibrate investments" now run from the workspace folder like the other jobs.
Importing a project package refuses one that holds programs or scripts (
.py,.exe,.dll,.bat,.shand the like) or files outsideinput_sources/,output_parquet/,solver_config/(.optfiles),settings.yamlandplot_settings.yaml; an export leaves such files out ofinput_sources/and the result runs, with a note. Solver option files travel only in the package's top-levelsolver_config/: a package with asolver_configorimported_solver_configfolder anywhere else (for example next to an input database, in any spelling of the name) is refused, because FlexTool reads such a folder by itself; an export leaves such a folder inside the project out, with a note. - Scenario names must be usable as folder names. A scenario name names its result folders, so a run (FlexTool
window,
flextool-run, Spine Toolbox, calibration, comparison) now stops with a message, before writing anything, when the name is empty, is.or.., holds/,\or a control character (a line break, a tab, an escape character), starts with-or with a drive letter and:(C:x), or is a name Windows reserves for devices (CON,PRN,AUX,NUL,COM1–COM9,LPT1–LPT9, also with an extension). On Windows also< > : " | ? *and a trailing dot or space are refused, as Windows does not allow them in file names. Rename such a scenario in the input database. Letters of any language, spaces and other punctuation work as before, and the result folders keep their names. FlexTool now also checks that every folder it empties or removes for a run is that run's own folder. - An imported project's input sources stay inside the project. Importing a package leaves out the input sources that its settings point at files outside the project folder, and says which ones; add such a file again in the FlexTool window if you need it.
- A results database server is used only from the command line. An
output-results-db-urlin the Output settings database must now be asqlite:URL (a file on any drive, a mapped network drive or a UNC share\\server\sharetoo). Another URL there stops a run that writes the results SpineDB (output-spinedb), before the solve, with a message; withoutput-spinedboff it is ignored with a warning. A settings database is a file that others may hand over, so it no longer sends results to a server it names. Pass a server URL with--results-db-url.
New features
- A solver wrapper set on your computer starts in front of Gurobi, CPLEX, Xpress or COPT, for example to hold
a floating licence or to lower the solver's priority: FlexTool window Solver options → On this computer, or
FLEXTOOL_SOLVER_WRAPPER_<SOLVER>(the program's full path, or a JSON list with its arguments). It is never taken from a database, a project or the command line. FlexTool runs it without a shell, followed by the solver program's full path and the solver's arguments. The run log names the wrapper program and the number of its arguments, not the arguments (the AI assistant can send the run log out); the run record on your computer keeps them in full and an exported project leaves them out. A.bat/.cmd, a PowerShell or Windows Script Host script and the command interpreters themselves are refused, on Windows alsowsl.exe,bash.exeand similar programs that read their arguments as a command line (use a program, orpythonwith a script). FlexTool 3 users ofsolver_precommand: set its program here once (see "FlexTool 3 precommand users" in the solver docs). The solver program itself is now looked for only in the absolute folders ofPATH, never in the current folder.
Units of measure and result files
- Units of measure for nodes (
node.unit_of_measure). A node can name the unit of its amounts (m3for water,tfor hydrogen,MWh_thfor heat; defaultMWh). It is a label only: FlexTool converts nothing and no number changes. The results of the node follow it in the parquet footer, the Excel workbook (unit row, unit block,Unitsand_output_metadatasheets),units.csv,datapackage.json, the results database and the plots: flows and the capacity of a unit on its side per hour (m3/h,MW_th), storage and inflow in the unit (m3), annual results per year (m3/a), prices per the unit (CUR/m3). A unit whose outputs are in different units shows how its capacity is used (MW + MW_thshared,MW; MW_thper output). One quantity summed over nodes of different units, such as a nodeGroup's flows or a connection's net flow between an electricity and a water node, is labelledMixed units of measure!; reserves, inertia, the non-synchronous share and the capacity margin stay in MW. Tables whose rows have different units get a per-rowunit of measurecolumn in Excel, arowcolumn inunits.csvand row units in the parquet footer, written only when some node is not MWh, so outputs of models whose nodes are all MWh are unchanged (except the annuity label below). The plot labels ofdefault_plots.yamlare unit templates ({A}/h,{R},CUR/{kR}) that read as before for MWh nodes; a label in your ownplot_settings.yamlwithout tokens is shown as written. The dispatch plots' y-axis and the GUI hover follow the node, and the AI assistant shows the units of the inputs and results of such a node (inflowin m3,penalty_upin CUR/m3,startup_energyin m3 per MW started) and does not add up values of nodes of different units. A node withoutunit_of_measureis MWh, so the assistant now states every unit plainly instead of adding "(default convention)" to it. Warnings when the input data is read: a connection, commodity, nodeGroup, flowGroup or group capacity limit over nodes of different units, a reserve / inertia / non-synchronous / capacity-margin group with a member that is not MWh, and a value that looks like a rate (m3/h). Costs entered per kW or kWh (invest_cost,fixed_cost,salvage_value) are per 0.001 of the node's rate or amount: 500 CUR per m3/h of capacity isinvest_cost0.5. See Units of measure and Unit of measure. - Units of measure in the result workbooks and CSV folder; layout change of the Excel sheets. The scenario
workbook and the scenario-comparison workbook (which had no units at all) open on a new
Unitssheet: one row per result sheet with its table, unit(s), semantics and description, the sheet names linking to the sheets;_output_metadatafollows it. A sheet with several header rows (two or more column levels; a comparison adds thescenariolevel, so this is every comparison sheet except those of entity-less tables such ascosts_discounted) gains one header row,unit of measure, as its last header row: a Power Query or script that reads such a sheet with a fixed number of header rows shifts by one. A sheet with one header row (in a comparison: an entity-less table, with the scenarios as columns) keeps its header atA1, but its data is now an Excel Tabletbl_<sheet>, its row index values are repeated on every row instead of merged, a set-membership sheet no longer has the 0..n row-number column, and a unit block sits to the right of the Table (pd.read_excelof the whole sheet also returns those columns: read the Table, or passusecols). The CSV files are unchanged; a newunits.csvlists the unit of every value column (file, position, header labels, unit, semantics), anddatapackage.jsonnow names each field by the header written to the file (labels joined with|as Frictionless joins them,headerRowsfor several header rows,commentRowsfor header rows that are blank in every column), so every file validates with Frictionless. The header cells no longer carry comments (LibreOffice could not save a workbook with them); theUnitsand_output_metadatasheets hold the same information, and in a comparison_output_metadatagives the scenario of a column in its own column. A mixed-unit table with unitless columns listsper unitamong its units in the results database, the plot labels and the GUI hover as on theUnitssheet (for exampleMWh/a; per unit).units.csvanddatapackage.jsonare reserved names in therenamesection. Fixed: the per-column units ofreserve__upDown__group__d(and of any mixed-unit table with several column levels) were missing from the parquet metadata, the_output_metadatasheet anddatapackage.json, and the result tools of the AI assistant gave the capacity-marginpenalty_costandcapacity_pricecolumns the unit MW. A single-result write updates only its own entries inunits.csvanddatapackage.json. See Units in the files. - New result table
period_share_of_year__d. For each realised period, the share of a year it covers (its hours / 8760 in the solve that realises it): the annualised per-period (…__d) tables are the period's sums divided by this share (a 48-hour period has 0.005479, so its per-period values are 182.5 times its 48-hour sums). Results saved before this version have no such table. See Costs.
Capacity margin and reserves
capacity_margin_methodshare_of_peak withgroup.capacity_margin_share. The margin is the share times the group's peak demand, taken over the period's full time series (before representative periods andnew_stepduration), so a sample that misses the peak still gets the real peak's margin. Periods that use different parts of one timeline each take the peak of their own part.- The capacity margin of each investment solve, explained.
capacity_margin__d(results databasegroup.capacity_margin) has, per solve, group and investment period, the binding time step (the largest deficit), the margin, the demand, the requirement, what firm and VRE units, storage-backed units, other units, connections and consumption count there, the shortfall, its penalty and the shadow price of the margin.capacity_margin_entity__d(group.capacity_margin_contribution) has what each unit, connection and node counts. An investment solve keeps its rows under its own name (look-ahead and forecast branch periods included), and the run log printscapacity margin shortfall: solve …, group …, period …: … MW at … (penalty … M CUR)after each solve that leaves a shortfall. The results database has both tables without the binding step (it is in the CSV and parquet tables). - The reserve requirement of each reserve group (#234).
reserve__upDown__group__dt(results databasegroup__reserve__upDown.requirement_t) has, per reserve, direction, group and time step, the requirement of each method the group has (timeseries, dynamic, large failure: the largest failure), the requirement that binds (the largest deficit), the reserve provided (×reliability), the reserve that requirement counts (a large-failure requirement does not count the failing unit's own reserve, so the shortfall is requirement − counted) and the shortfall.reserve__upDown__group__d(group__reserve__upDown.requirement) has the period's time-step-weighted averages of the requirement, the provision, the counted reserve and the shortfall and their step maxima;reserve__group__provider__dt(CSV and Excel only) what each unit and connection counts for each group. The requirements are the model's own constraints evaluated at the solution; the run log printsreserve requirement check: …for a step where they disagree with the solver's shortfall (at most 10 lines per solve, then a count). - A capacity-margin, inertia or non-synchronous group across Benders regions is an error. Each region subproblem required the whole requirement before; use one group per region.
- A
balance_within_periodnode in a capacity-margin group is an error (it balances per period, the margin holds per time step); a storage node with demand in a capacity-margin group gets a warning (its demand is left out of the margin).
Model
- Start-up fuel and start-up heat (
unit__inputNode.startup_input_method,startup_energy). An input arc can also (startup_and_input) or only (startup_only) supply the energy a unit's start-ups take:startup_energy[MWh per MW started, constant or period] × MW started, taken from the node in the step the unit starts in (an event, likestartup_cost). The start-up input can be fuel (a coal unit started on oil), heat (a CSP turbine warmed up from its tank) or electricity. It is priced as the node's commodity (also on a price ladder), counts in the CO2 price and caps (also when it is a group's only emitter) and in their rolling carry, and needs online variables (linear start-ups keep the model an LP). A startup_only arc has no flow: it takes no part in the conversion, capacity, input shares or ramps, so a coal unit with an oil start-up arc stays a one-input unit. Not part ofconstraint_flow_coeff, flow groups or the capacity margin; left out with the start-ups underreduce_unit_detail_methodonline / online_ramps, and a use of the online variables under integers. New outputsunit_startup_energy__dt/__d(results databaseunit__nodestartup_energy_t/startup_energy_annualized); the unit's input from the node, the commodity cost, the CO2 and the node balance include it. Input checks for the combinations that have no effect or no meaning. Default just_input: the model is unchanged. - User constraints summed over a period (
constraint.constraint_period_method). per_timestep (default) keeps the rows of before. period_total writes one row per period over the period's total: each step's flow and online terms are multiplied by its duration and timestep weight and divided by the period's share of a year, so flows count in MWh per year and units online in unit-hours per year, and theconstantis per year. Capacity terms and their existing capacity are multiplied by the hours of the year, so a flow − 0.3 × capacity row caps the annual capacity factor at 30 %. Shares such as "gas backup at most 15 % of the CSP output over the year" can now be written. A state coefficient on such a constraint is an error. A rolling solve whose rolls do not each realize whole periods leaves the constraint out with a warning; the run stops when no solve enforces it. Each forecast branch period has its own row. - Units online in user constraints (
constraint_online_coeff). A map onunitandconnection(index: constraint name) puts the number of units online in each time step on the left side of a userconstraint, for example "at least two of three units online":constraint_online_coeff1 on each unit andconstant2 withsensegreater_than. One unit isvirtual_unitsize(else the largestexistingcapacity, else 1000 MW), so the same row works for three entities or for one entity of three units. The count is a whole number with binary startup and continuous with linear; for MW online use the unit size as the coefficient. A constraint with an online term is written once per time step. The unit or connection needs online variables (startup_methodlinear or binary; on a unit also aminimum_time_method); a coefficient on one without them stops the run with an error naming it and the constraint. Databases are migrated automatically; nothing else changes. ramp_costis charged. A unit flow withramp_methodramp_cost or both paysramp_cost[CUR/MW] for every MW its step-average flow changes from the previous time step, up and down alike. A change is an event, like a start-up, so the step length does not enter: ramping 0 → X → 0 costs 2 ×ramp_cost× X. The previous step is the one the ramp limits use: no cost at the first step of a solve, after a gap in the timeline or at the start of a representative period, and a rolling window pays once for the change from the flow the previous window realized. ramp_cost works without anyramp_speed; both adds the limit. On aconstant_efficiencyunit with one input and one output, an input-side cost prices the change of the unit's output flow. A negativeramp_costis an error. The cost is reported in the new cost category ramps (a zero column in the cost tables of models without a ramp cost) and in the per-unit cost tables. Databases are migrated automatically (new descriptions oframp_methodandramp_costonly); a model whoseramp_methodwas already ramp_cost or both with aramp_costvalue now pays that cost.-
inertia_limitandnon_synchronous_limitper time step. Both group limits now take a constant, a period map, a time map or a period+time map (before: constant or period), e.g. a non-synchronous share that steps up by hour or season, or an inertia requirement that is higher in some hours. A time map (or the time level of a period+time map) must give a value for every time step of the solve, look-ahead included: a missing time step stops the run before its first solve (also in a rolling solve) with an error naming the group, the parameter and the missing time steps (a solve whosereduce_system_detail_methodleaves the limit out is not checked). An authored 0 is no inertia requirement and a non-synchronous limit of 0. Over a coarse time step (groupnew_stepduration) the averaged row uses the step mean of the limit (the check is on the original time steps), and the coarse-flow info line names a limit that varies within a coarse step. The hourly inertia shortfall table shows 0 instead of an empty cell in a time step without a requirement. The Excel export puts both limits also on the time sheets. Databases are migrated automatically; constant and period values are unchanged and give the same model. -
Representative periods in stochastic solves (#125). A stochastic solve can now use a timeset with
representative_period_weights(before: an error). Each forecast branch period uses the representative periods of the period it branches from, and the*_blended_weightsstorage methods work per branch: a branch's first period continues from the last base period of the trunk period (the shared period before the split), a later one from the previous period of the same branch; bind_within_solve_blended_weights closes once per path (every branch ends at the level the trunk period starts from); bind_within_period_blended_weights closes each branch period on itself. When the forecasts open in the solve's first period they start from the same level (bind_forward_only_blended_weights gets the new rowrp_inter_period_fan_start, also withfix_start). For a storage node in aninclude_stochasticsgroup the start levels of the representative blocks are tied across the forecasts inside the non-anticipativity window (non_anticipativity_rp_block_start). Two identical forecasts give the deterministic result. Databases are migrated automatically (new descriptions ofstorage_binding_methodandrepresentative_period_weightsonly); models that ran before are unchanged.
Faster solves: leaving out detail
- A solve can leave out unit detail (
reduce_unit_detail_method). Set it on asolveto make that solve faster, typically an investment solve followed by a full dispatch. none (default) keeps everything. integers: units withstartup_methodbinary get continuous units online andexactconnections behave as regular, so the solve is an LP. integers_ramps also leaves out ramp limits and ramp costs, integers_min_limitsmin_load, thecapacity_min_coefffloors and minimum up / down times, integers_ramps_min_limits both. online leaves out the online variables and with them the min limits and start-up costs;min_load_efficiencyunits use their full-load efficiency (the same fuel at full load, less at part load), ramp limits apply to the capacity, and a user constraint with aconstraint_online_coeffterm is left out of the solve, with a warning naming it. online_ramps is online without ramps. Under the integers levels a unit or connection whose online variables nothing in the solve still uses (no start-up cost, min load orcapacity_min_coefffloor, minimum up / down time, part-load curve, online term in a user constraint, reserve, inertia, ramp limit or parent commitment left) loses them, as withstartup_methodno_startup: the same result from a smaller solve. The run log names these units and connections, and their online cells of that solve are empty. A level never adds detail. Every roll of a rolling solve uses the solve's level, and a realization solve uses its parent solve's levels, whatever is set on the realization solve itself (a warning names a different value set on a declared realization solve). Databases are migrated automatically; models that do not set the parameters are unchanged. - A solve can leave out system detail (
reduce_system_detail_method). reserves leaves out the reserve requirements (and with them the units' reserve limits and the reserve ties of a stochastic solve), inertia the inertia limits, non_synchronous the non-synchronous share limits, and dc_power_flow the angles ofdc_power_flow_with_anglesgroups: their lines become lossless transport limited by their capacity, so loop flows disappear. inertia_non_synchronous, reserves_inertia_non_synchronous and reserves_inertia_non_synchronous_dc_power_flow combine them. The capacity margin is always kept. An investment solve without reserves invests less in flexible capacity. With either parameter set, the result tables of earlier full solves keep their online, reserve, inertia and DC columns when a later solve leaves them out, a dispatch solve that continues the ramp of a solve without online variables starts its commitment and ramp cold (an info line) instead of stopping, and a Benders decomposition can split connections whose reserve, online variables orexactmethod the solve leaves out (its warning about integer variables now points to integers). - The run log and the results say what a solve left out. A solve with
reduce_unit_detail_methodorreduce_system_detail_methodother than none writes one info line to the run log at its first roll: what each level left out, for how many units, connections and groups, and how it changes the detail that was kept (for example "the reserves of 1 unit (g1) now come from built capacity, not online capacity"); a level with nothing to leave out says so. Every roll's entry in_run_status.jsonrecordsreduced(the two levels and that line). In the result tables, the cells of a feature that a solve left out are empty, not 0, in the time steps that solve realized: units online and start-ups, reserves, reserve prices and shortfall, group inertia, its largest flow and shortfall, the non-synchronous shortfall and the DC angles. A per-period cell is empty when every solve that realized the period left the feature out. Costs are unchanged. Models that do not set the parameters get the same results as before.
Databases and projects
flextool-run --migrate(alsorun_flextool.py,python -m flextool.cli.cmd_run_flextool) migrates an older input database before the run, the same way asflextool-migrate-database; without the flag the run still refuses it, and the refusal now names the flag. A frozen database is not migrated: the run stops with the frozen message and exit code 2. Runs started at the same time with--migrateon one database migrate it once (a lock on<database>.migrate.lock); the others print one "Waiting for another migration" line, wait and then run. Every in-place migration takes the same lock:flextool-migrate-database, the Toolbox Migrate database tool, the FlexTool window's refresh andexecute_flextool_workflow.py. A database that is already current does not wait for the lock and gets no lock file. On Windows the wait is bounded (10 minutes, setFLEXTOOL_MIGRATION_LOCK_TIMEOUTin seconds); a file system without file locks stops the migration with a message.- Frozen databases.
flextool-freeze DB --label "Reference case 1.0"(or right-click → Freeze database… in the FlexTool window) marks a database at the current version as frozen,--unfreezeremoves the mark and--statusshows it. A frozen database is never migrated in place without consent:flextool-migrate-databaseexits with code 2 unless given--copy-to NEW.sqlite(migrates a copy that records the label, the old version and the SHA-256 of the original, which stays byte-identical) or--allow-migrate-frozen(migrates the file and keeps the marker with the new version);flextool-updateskips and lists frozen databases; the FlexTool window asks: migrate a copy (a new input source), migrate in place (second confirm) or cancel (the source is not runnable until the project is reopened and not asked about again). A frozen database that is already current, or newer than this FlexTool, is left unchanged. When the marker cannot be read (another program holds a lock on the file, an unreadable network path) the database is not migrated and the message says so. - Project packages. A project can be sent to another computer as one
<Name>_<YYYYMMDD>.flextool.zip: Manage projects… → Export… / Import… in the FlexTool window, orflextool-project export|import|verifyin the terminal. A package holds the input sources (external input files copied in under their own source numbers, so shipped and re-run results pair up),settings.yamlwithout the machine-specific settings,plot_settings.yaml, the solver option files the project's runs use (only the five solver files, no unedited templates) and the result runs picked in a checkbox list (with sizes; a run whose input changed after the run is warned about). Plots, CSV/Excel outputs,results.sqliteand scratch folders are left out.manifest.jsonrecords the FlexTool and library versions, each database's data version and frozen marker (frozen databases stay frozen), and the SHA-256, size and modification time of each file. Import checks every checksum before writing, refuses paths outside the project, keeps the files' modification times (so imported results are not flagged as made from a changed input), suggests<Name>_2when the name is taken (exit code 3 in the terminal), refuses a database newer than this FlexTool, migrates older ones on open (asking first for a frozen one), and unpacks the shipped solver options to the project'ssolver_config/, where its runs use them ahead of the workspace's (delete a file there to use the workspace's), with the input database's rules until you trust the project's solver option files. Export leaves out an option file with a line that names a file or folder by its path or connects the solver to another computer (a server, cloud or licence service, or its password) or sets a CPLEX name starting withset(a run refuses it), and names those lines; a server option's value is shown as<hidden>. A project imported by a 4.4 development build has its shipped options inimported_solver_config/, which no run reads: the first time the FlexTool window opens it, the files are checked as at import and the folder is renamed tosolver_config/(a log line says so). Import holds back exactly what the project's runs would refuse (the input database's rules, which an imported project's option files get until you trust them). The viewer's axis ranges file is cut to the shipped runs; run records have this computer's folder paths, user name and host name replaced and run logs stay home (on by default;--keep-run-records/ untick to keep them); a run still being written is refused (--results allleaves it out); a.sqliteinput that may be in the middle of a write is copied through SQLite,-wal/-shm/-journalfiles never; names Windows cannot hold, names that differ only in case and a package that does not fit on the disk are refused before anything is written; an existing package file is replaced only after asking (--forcein the terminal); errors are one message, no traceback; a stopped import's hidden.<name>.partialfolder is not listed as a project; import names inputs left out of the package;verifyprints onepip installline pinning FlexTool, polar-high, highspy and polars. - A project's own solver options, file by file. Put a
<solver>.optinprojects/<P>/solver_config/and the project's runs (FlexTool window, calibrator, terminal or Toolbox runs on the project's database) use it; a solver without a file there uses the workspace's, else FlexTool's default.--solver-config-dircan be repeated, andFLEXTOOL_SOLVER_CONFIG_DIRtakes several folders separated by:(on Windows;), highest first; only the folders given are read. WithFLEXTOOL_SOLVER_CONFIG_DIRset for the FlexTool window, the project's folder still comes first. A shipped option file with a line the input database's rules refuse (a path, a server, a parameter name not in FlexTool's list, a CPLEX name starting withset) is kept as<solver>.opt.review, never read by a run; to use it, check it, rename it and trust the project's solver option files; an unedited FlexTool template in a project folder is skipped, so it never hides the workspace's file.
Plots, timings and the parameter catalogue
- Plotted data as CSV. Each plot file can get a CSV file next to it with the data drawn in it (same name,
.csv; a plot split into files gets_01.csv,_02.csv, …), for the scenario plots, the comparison plots and the dispatch plots. Rows are the plotted time steps or bars (no gap rows at timeline breaks), columns the plotted series in one header row, asubplotcolumn when a file has several subplots, values aftermultiply_by; empty result cells stay empty. Turn it on with Write plotted data (CSV) in the GUI's Png settings,--write-plot-dataonflextool-run,flextool-write-outputsandflextool-scenario-results, or a top-levelwrite_plot_data: truein the project'splot_settings.yaml(the GUI checkbox then shows checked and greyed out). Off by default. A CSV that cannot be written gives a warning and the plots are still made. See Plotted data as CSV in Model outputs. flextool-time-runs, a timing check for exercises. Runs scenarios one after another (default: every example and how-to scenario that comes with FlexTool;--db PATH --scenario S ...for a project's own database) with the FlexTool window's default threads per run (1;--threads Nfor another) and plots off unless--plots on|both; a--dbdatabase is copied first and the runs read the copy. It prints the computer (processor, cores, memory) and a table of each run's seconds by phase: start-up, read input, preprocess, solve, outputs, viewer, plots and untimed, slowest first, with!on runs over--target(default 60 s). Exit code 1 when a run fails, 2 with--strictwhen a run is over the target;--csvwrites the table to a file. See Check that exercises run fast enough in Running FlexTool from the terminal.read_inputrow intimings.csv: the time to read the input database, prepare the input data and set up the run, up to the start of preprocessing.- Parameter catalogue (data dictionary). The web docs have a Parameter catalogue page (under Model
parameters): every input parameter in one table per entity class with its unit, allowed shapes, default, allowed
values, parameter group and definition, generated from the schema of the installed FlexTool.
python -m flextool.docs_build --xlsx catalogue.xlsxwrites the same as an Excel file: a Read me sheet (what the file is, the versions, what each column means and how to fill Source and How to replace), the parameters sheet (also a quantity type column, a frozen, filterable header and empty Source, How to replace and Notes columns for a project team) and a classes sheet that explains each entity class.--input-db FILE_OR_URL [--scenario NAME]adds used in DB, entities and alternatives columns from the keys of the stored values (the file is opened read-only; a missing file is an error) and lists the parameters a database has but this FlexTool does not define. See How to make a data dictionary for your model in the how-to recipes.
AI assistant
- New tool
explain_entity: one unit, connection or node of one run in one call. At the standard and advanced levels (and for agents connected withflextool-assistant --mcp) the assistant can read an entity's inputs and its results side by side: capacity per period, the flows to and from each connected node, capacity factor, curtailment, storage state, the prices at its nodes, its non-zero costs and its investment value, with hints that name the input a result is consistent with (an investment limit that binds, aninvest_methodthat allows no investment). The tool descriptions of the standard and advanced levels changed, so a chat resumed at those levels starts a fresh history with the AI service (its reasoning data from before is not sent again), and an evaluation run asks its unfinished cells again. Seeexplain_entityin "The AI assistant". - Ask a question again, as it was or changed. In the AI assistant window each question has two small buttons:
↻ asks it again and ✎ puts it in the box to change (Enter sends it, Escape keeps the question as it was); R and E
on a question line and its right-click menu do the same. The answer, and the questions and answers after it, are
replaced; when later questions go too, or with the R key, the window asks first. A question asked again goes to
the chat's own model. In the terminal chat,
/retryasks the last question again and/editchanges it first. See "Ask" and "Commands" in "The AI assistant". - The assistant knows the scenario's time steps, periods and latest run. With a chosen scenario (highlighted in
the window,
--scenarioin the terminal), each question carries the scenario's model facts, read from the input data and its latest run: the solves in their order (nested ones too) and how each solves (rolling window horizon and jump, stochastic forecast branches and weights, representative periods), the periods and the years each represents (and which of them each solve does not realise), the length of the time steps each solve really uses (the aggregated steps withnew_stepduration), the share of a year of each realised period (fromperiod_share_of_year__d, for runs saved with it, with which per-period results it scales) and whether the latest run succeeded, with its error when not. Steps of different lengths are pointed out, with how to treat per-step results. The facts are at most about 200 tokens, are sent only when they change, and go to the AI service like the rest of the context block. Agents get them inget_gui_context(model_facts), and the connection asks them to call it first. The units note no longer says that time steps are in hours. In the smallest context windows the basic level keeps 260 tokens free for the facts, so a tool result there holds a little less; an evaluation run asks its unfinished cells again. See "Ask" in "The AI assistant". - The assistant's tools give plainer, more complete answers.
find_result_tablesknows users' words (fuel, built capacity, unserved energy or loss of load, excess load) and lists the main table first for capacity, unserved energy, fuel and system-cost questions, the dual tables only when duals, marginal or shadow prices are asked. Placeholder values that a model puts in optional arguments (an empty time range,top_n0) are left out instead of giving an empty or refused result;list_runssays how many runs its filters hide,list_entitieswhen nothing matches, and a value outside a tool's list is refused in plain words with the allowed values. An entity the scenario does not use is said so first, with its values shown as stored, not used, and no default as if it applied. A note that depends on another parameter gives the conclusion for the entity's own value ("invest_method is invest_total, so invest_max_period is not used"), andexplain_entitysays first when a limit is set that theinvest_methoddoes not use. Large values come with a rounded copy in a larger unit (≈1.03 TWh/a, M CUR) beside the exact value, in the summaries and, for a table of one unit, inquery_resultandcompare_runs. The working notes a model keeps during a long answer stay its own: a leaked "Keep: 1, 3" line is left out of the answer. An evaluation checkpoint made with the earlier tools is refused. - Answers are written for a FlexTool user. At every level the assistant gives demand as a positive number (FlexTool stores it as negative inflow) and, for an entity the scenario does not use, says that first. At the standard and advanced levels it uses plain words with the FlexTool term in parentheses ("demand that could not be met (upward slack)"), gives the cause first in one sentence and then the numbers under "More detail:", names the alternative of each input value it cites, rounds to three significant digits in readable units (GWh or TWh, M CUR) and does not guess at problems the data does not show. The basic level, too, no longer guesses. The level texts changed, so a chat resumed starts a fresh history with the AI service, and an evaluation run asks its unfinished cells again.
Fixes
Settings, comparison and project files
- A settings database that is not found is no longer ignored silently: the run warns that its output settings are not used. A settings database on a network drive (a mapped drive or a UNC share) is read.
flextool-scenario-results <db_url>leaves out a scenario name from the Output-info database that cannot name a folder, with a warning, and compares the others (the--alternativesnames were already checked).- A plot plan's file name stays in its folder whatever the plot configuration's result key or sub-configuration
holds (
/,..). - A calibration solve resolves a relative database path or output folder against the folder it was started from (before: the FlexTool source folder for the solve, the start folder for reading its results).
- The dead
Open_summarySpine Toolbox item is dropped from an old project when FlexTool updates it, and its specification (which named a missingopen_summary.py) is no longer shipped.
Investment and discounting
- An
invest_methodoffers investment and retirement also withoutinvest_cost/salvage_value. A unit, connection or node whoseinvest_methodallows investing now has its investment variable in every investment period of the solve, also whereinvest_costis missing or 0; one that allows retiring has its retirement variable in every period with existing capacity, also withoutsalvage_value. Such capacity has no annuity; itsfixed_coststill counts (retiring saves it). Before, a period without a non-zero cost had no variable unless a user constraint had a capacity coefficient on the entity, so aninvest_methodwithout a cost silently offered nothing. A 0 in aninvest_costmap no longer blocks investment in that period (also a stochastic branch's own 0): it is now free investment there. To forbid investment in a period, use a _periodinvest_method(invest_period, invest_period_total and their invest_retire_ forms) and setinvest_max_period0 for it, or use not_allowed: the other methods do not readinvest_max_period. With neitherinvest_costnorfixed_costthe invested amount is arbitrary (any amount is optimal). A zero-cost investment needs alifetimeonly withlifetime_methodreinvest_choice/no_investment. The FlexTool 2 importer no longer reports a storage with amax invest (MWh)and noinv.cost/kWh: FlexTool 4 now adds it at no cost, as FlexTool 2 does. Fixed with it:invest_period_totalallowed retiring andretire_period_totalinvesting (the method lists left them out), and aninvest_period_totalentity with asalvage_valueand nolifetimegot a false lifetime error. The database upgrade writes the markerinvest_without_cost_offeredon a database with an entity that can invest (retire) without aninvest_cost(salvage_value) or with a 0 in it; the run then names the entities and periods that are newly offered. discount_rate0 is 0 % (annuity = invest_cost / lifetime); before it was 5 %. Anydiscount_rateof 0 or below used to be replaced by 0.05 in the annuity, and a missing or non-positivelifetimeby 20 years. Now 0 gives the straight-line annuity (salvage_value / lifetimefor a retirement) and a missing rate is the schema default 0.05, as before. A negativediscount_rateis an input error. A unit, connection or node that invests with aninvest_costor retires with asalvage_valuewithout alifetimeis an error, and so is one withlifetime_methodreinvest_choice/no_investmentthat invests, retires or hasexistingcapacity (its capacity ends after the lifetime). The database upgrade marks the entities whose results change (discount_rate_zero_is_zero, entities that can invest or retire with an explicit rate ≤ 0) and the run prints a notice for them. The FlexTool 2 importer now writes aninterestof 0 asdiscount_rate0 (before it was left out, so it became 5 %); alifetimeof 0 is still left out, so such an investing unit now needs a lifetime.- A realized investment period must be an investment period of the same solve (#319). A
solve whose
realized_invest_periodslists a period that is not in itsinvest_periodsnow stops with an error naming the solve and the periods. This includes a solve withrealized_invest_periodsbut noinvest_periods(before, a rolling solve only got a warning and a single solve ignored the value): a database with a leftoverrealized_invest_periodson a dispatch or rolling solve needs that value deleted. A 1Drealized_invest_periodson a solve whoseinvest_periodsis a 2D map was ignored; it is now an error too (use a 2D map with the same solve names). - Without
realized_invest_periods, a solve realized the investments of everyrealized_periodsperiod (#319). It now realizes therealized_periodsthat are alsoinvest_periods, so a dispatch-only period of an investment solve is no longer reported as an investment period. - The investment of a period that is in
realized_invest_periodsbut not inrealized_periodswas missing from the results. The solve made the investment, butv_invest/v_divesthad no row for that period, sounit_capacity__d(and the connection / node tables) had no row there and its investment cost was left out of the cost tables and the summary (the solve itself was right). The results of such solves now have these rows. - A later solve could report 0 for an investment an earlier solve made. When a solve realized the investment
of a period it did not dispatch and a later solve dispatched that period while investing in another, the results
took the later solve's 0: no invested capacity in
unit_capacity__d(and the connection / node tables), no investment cost and no capacity-margin shortfall for that period. The investment results of a period now come from the solve that realizes its investment. - The investment marginals of a period an earlier solve realizes came from a later solve. In the same chain,
unit_invest_marginal__d(and the connection / node tables) took the later solve's 0 for that period, also when the earlier solve did not invest there and the marginal was not 0. The marginals (the investment reduced costs and the per-period investment limit duals) now come from the solve that realizes the period's investment.
Time resolution
- A solve's
new_stepdurationcombines every time series over the new time step (results change). A solve with its ownnew_stepdurationused to take the value of the first original time step of each new step for every time map other than profiles and inflows: commodityprice,co2_price,availabilityof units, connections and nodes,efficiency,min_load,efficiency_at_min_load,other_operational_cost,penalty_up/penalty_down,reservation,self_discharge_loss,inertia_limit,non_synchronous_limit,max_instant_flow/min_instant_flow,storage_state_reference_value,timeset_weightsand profiles given as a period+time map. Each now follows one rule per parameter: prices, costs, limits, availability, min load, penalties, reserves, self-discharge, weights and profiles take the duration-weighted mean of the step's time steps; an efficiency takes the mean of 1/efficiency (so the fuel of a flow that is constant over the step is right: 0.4 and 0.5 give 1/efficiency 2.25, not 1/0.45);storage_state_reference_valuetakes the value at the step's end;inflowstays summed. A time map with values for only some hours of a new step uses the hours it has (a warning names the parameter), and amin_loadthat varies within a new step makes themin_load_efficiencycurve of that step approximate (a warning names the units). Results change for models that combine a solve-levelnew_stepdurationwith such time maps; models without a solve-levelnew_stepduration, and time series other than stochastic (branch) inflow that are constant within each new step, give the same model as before. Stochastic (branch) time series are combined the same way, each branch on its own, and a rolling solve's rolls use the combined values too. A stochastic (branch) inflow used to take its first time step's value once; it is now summed like a deterministic inflow, so it grows by the number of time steps each new step combines. The step means are exact (before, a profile whose hours in a step were equal was rounded to 6 decimals; it now keeps the exact value, a change of at most 5e-7). A database upgraded to version 79 that has a solve withnew_stepdurationprints a notice naming the parameters and entities whose values change. energy_margin_adderis summed over a solve'snew_stepdurationstep (results change). The adder is MWh per time step of the timeline. When the investment solve hasnew_stepduration, a solve step now gets the sum of the values of the time steps it combines: a period+time map is summed, and a constant or period value counts once per time step (on 4 h solve steps of an hourly timeline, a constant 10 adds 40 MWh to each solve step). Before, a solve step added the value of its first time step once, so a coarse investment solve added a fraction of the intended demand. The adequacy calibrator's uniform sizing already sized the adder per time step of the timeline, so its correction now injects the demand it computes on coarse investment solves too. The database upgrade rewrites the parameter description and, on a database with bothnew_stepdurationandenergy_margin_adder, writes a notice marker; to keep the old demand, divide the value by the number of time steps a solve step combines. A time-onlyenergy_margin_addermap (no period level), which was silently ignored, now stops the run with an error naming the shapes the parameter takes.
Capacity margin and reserves
- The capacity margin counts what each entity can really deliver. Before, every unit with an
output into the group counted at its full capacity, whatever its
availability, also a turbine on an empty storage, and connections did not count at all. Now, at each time step of an investment period, firm units count at capacity ×availability×capacity_max_coeff(a unit with several outputs into the group, such as a CHP, counts its capacity once, as the sum of its outputs is limited), VRE at capacity × min(profile,capacity_max_coeff) ×availability, and units fed from a storage node, units fed from the group's own nodes, connections and consumption by their actual flow. So only firm and VRE capacity can hold the margin: imports and storage help by what they deliver, exports add to the requirement, and energy dumped through a node's downward slack no longer counts, nor does energy that the balance slack of a storage node feeding the group makes. Connections with both ends in the group and lossy units inside it (efficiency below 1, e.g. an electric boiler) are left out, so their losses are not part of the requirement (and shedding load behind them cannot lower it); heat pumps inside the group count by their flow. Units fed from fuel or commodity nodes count at capacity (the fuel is assumed to last). The energy margin does not change the capacity requirement. Results of models with a capacity margin change. An upgraded database with a manual capacity-margin group gets the behaviour-change noticecapacity_margin_counts_by_flow. - An investment solve's capacity-margin shortfall was reported nowhere in an investment → dispatch chain.
The dispatch solve that realizes the period has no capacity margin, and its 0 replaced the
investment solve's shortfall, so
slack__capacity_margin__dshowed 0 and the costs no capacity margin penalty. Now both show the shortfall of the solve that decided the investments, as the invested capacity already did. Behaviour change: the total cost of such a chain now includes that penalty (it is part of the plan's cost); a model without a shortfall is unchanged. The forecast branch periods of a stochastic fan are not realized, so their shortfalls stay out of these tables and the costs. - A reserve on a group without nodes no longer crashes the run. A
reserve__upDown__groupwhose group has nogroup__noderow in the solve stopped the run with an internal error ("multiple values for keyword argument 'group_node'") when no group of the solve had a node. When another group had nodes, its requirement could only be met by shortfall, at the reserve penalty. Such a reserve is now left out, with a warning naming the group (none for a group withreserve_methodno_reserve). Models where every reserve group has nodes are unchanged. - A reserve group with
reserve_methodno_reserve no longer gets reserve variables. Such a group (also areserve__upDown__groupwithout a method, as no_reserve is the default) has no requirement, but every solve built an unused reserve shortfall variable for it at each time step, and a model whose only reserve groups were no_reserve switched the reserve constraints on. These are no longer built: results are unchanged, except that the reserve shortfall tables (slack__reserve__upDown__group__d/__dt) no longer have an empty column for such groups, and in a model whose only reserve groups are no_reserve the reserve-provision tables are empty. - A model without any variable is reported as infeasible when one of its constraints cannot hold. HiGHS
reports a model with constraints but no variables as empty and does not look at the constraints, so FlexTool
said the solve "has no variables or constraints". Now it checks them: a constraint that cannot hold with
nothing in it (e.g. a node balance with demand and
penalty_methodoff, but no unit or connection) makes the solve infeasible, and the message names the first such constraints. - The reserve shortfall penalty in the cost tables could be doubled. In a model with several reserve groups
(for example an up and a down reserve), the upward / downward reserve slack penalty of
costs__dtand the cost summaries multiplied each group's shortfall by the scale of every group of the same reserve when the groups were listed in another order, and so came out twice too large. The optimisation was right; only the reported cost was wrong. Each group's shortfall now takes its own scale. - The period average of the reserve a unit or connection holds ignored the time-step weights.
process__reserve__upDown__node__d_average(reservation_average) was the plain mean of the steps, too high or too low on representative periods with unequal weights. It is now the time-step-weighted average over the period's realized steps, and the steps of a solve that left reserves out (reduce_system_detail_method) no longer count as 0 MW. Models without time-step weights are unchanged.
Solving
- Solves split by a 2-D period map (see "Behaviour changes" for what changes in their results): a
construction_lead_timeno longer stops a split solve (4.2.0 to 4.3.0); it works as in solves written by hand (a decision whose capacity would come online after the split solve's last period is not offered, with a warning). The realization solve FlexTool adds to a stochastic solve now has its solve'sscaling(before, it used the run's scaling setting instead; 4.1.0 to 4.3.0). The first column of the map may have any index name (before, a name other than the defaultxsilently gave the split solves no investments); a row with more periods than the map has rows no longer crashes; a 2-D map on a solve that the model does not run no longer adds solves to the run (every 4.x release). A solve contained in a split solve now gets the name of the period that the split solve realizes (<solve>_<period>), whatever the row is called; before, with a row not named after its period, the contained solve kept one name in every split solve, so the results of its runs were filed under one name. New errors, for set-ups that gave wrong results or crashed: a split solve whose name is already a solve of the database (before, the two were silently mixed), a 2-D map on a solve that is only contained in another solve, and a 2-D map inshared_invest_periods. - A solve split by 2-D period maps whose rows are not period names keeps its capacity. With rows such as y2030 / y2040, the second and later split solves lost the existing and the invested capacity of the earlier ones and invested again (and their run history, which sets the discounting, had no earlier period). They now carry the capacity as with rows named after the periods.
- A split stochastic solve that looks ahead runs. With 2-D period maps that look ahead (such as
{y2030: [p2030, p2040], y2040: [p2040]}) and forecasts in several periods, every split solve got every period's
stochastic_branchesrows and the run stopped with "fans out only once". Each split solve now takes the rows of the periods it realizes, or, when none of them has a fan, the rows of its first period with a fan (a fan in its look-ahead), as the solves written by hand would. The recipe for a year-by-year chain of stochastic investment solves is in the stochastics how-to ("Investing year by year under uncertainty"). - A hand-written investment ladder that looks ahead and contains a storage or dispatch solve now runs.
With investment solves written by hand whose
invest_periodslook one period ahead (as the 2-D map example {p2020: [p2020, p2025], ...}) and that each contain the same lower solve, the lower solve kept its name under the first ones, ran the last one's periods, and the run stopped withKeyErrorin the solve below it. A containing solve now stands for the periods it realizes (realized_invest_periods,realized_periods,fix_storage_periods), not for its look-ahead, whether written by hand or generated from a 2-D map: the contained solve runs as<solve>_<period>and the hand-written ladder gives the same results as the 2-D map. - A contained
single_solvethat would run under one name with different periods now stops the run. When a single solve shares more than one period with each of its containing solves (two solves that realize several periods each, the rolls of a rolling solve, or a solve renamed to<solve>_<period>, which keeps all periods of its base solve, so a third-level single solve below such copies is not renamed per period), its runs wrote one set of results and the last run's replaced the others'. FlexTool now stops with an error naming the containing solves and their periods; make the contained solve arolling_windowsolve, or let each containing solve realize one period. Below a solve renamed to<solve>_<period>that second remedy does not help (the copy already realizes one period): make the third-level solve arolling_windowsolve, or contain it directly in solves that realize one period each. - Solver options can no longer add commands to a solver run (since 4.0). A
solver_argumentsentry or an option-file line with a line break,;, a quote or a space could add a command to the script piped to CPLEX, Xpress or COPT, or address another Gurobi or HiGHS option. Every option of every solver is now checked before the solve; a refused one stops the run with a message naming the solve and parameter, or the option file and line, and the option. The solver's command script and arguments are checked again just before it starts. - An empty solver option value is refused (since 4.0). For CPLEX, an empty value in
solver_arguments, or an option-file line without its value, made the CPLEX console take the next command as the value; Gurobi and Xpress dropped the option while the log showed it as passed, and an option-file line of one word was skipped without a word. HiGHS took an empty value in the normal solve but stopped at it with--save-memory. Such an option now stops the run with a message naming the solve and parameter, or the file and line. - Solvers get their parameters directly. The commercial solvers' arguments and command scripts, and the
--save-memoryHiGHS solve, are now built from the merged parameter list itself rather than from the<solve>.optfile written and read back;<solve>.optstays next to the model as the record (and HiGHS' child takes<solve>.options.json). What each solver receives is unchanged. - A database could point a solver at any file or at another computer (since 4.0). A
solver_argumentsentry such aslog_file,write_model_file,read_basis_file, GurobiInputFile/ComputeServer/CloudAccessID, CPLEXworkdiror XpressTUNEROUTPUTPATHcould read or write a file anywhere the user may, or send the model and credentials to another machine. Options from the input database are now trusted less than those of the user's own computer: a file option there takes a plain file name, placed in the solve's work folder, and a server option (Gurobi) or remote-solving control (XpressCOMPUTE...) is refused, with a message naming the option, its value and the solve and saying to set it insolver_config/<solver>.optinstead. The option files, flags and environment variables keep full paths and server options. Option names are compared as each solver reads them (Gurobi without case or_, Xpress without anXPRS_prefix, CPLEX abbreviations of its file options), so another spelling does not get past the check. solver_precommandis not used, and the docs now say so. The docs described it as a shell command run before the solver, but FlexTool 4 has never run it, and it will not: a command stored in an input database must not reach a shell. A value now gives a warning once per solve and run. The parameter is kept only so that older databases still read; its description and that ofsolver_argumentsare updated by the v79 migration.--solver-time-limitand--solver-mip-gapnow apply to Gurobi, CPLEX, Xpress and COPT (the GUI's time limit too), and win over the solve'ssolver_mip_gap, itssolver_argumentsand the solver's option file, as they do for HiGHS. Before, they set only HiGHS options: a commercial solve used the database gap and ran without a time limit, although the run header listed both flags (and the 4.3.0 notes said the time limit was passed to Gurobi; it was not). Results of commercial-solver runs started with these flags can change. Their environment variables are nowFLEXTOOL_SOLVER_TIME_LIMITandFLEXTOOL_SOLVER_MIP_GAP; the old namesFLEXTOOL_HIGHS_TIME_LIMITandFLEXTOOL_HIGHS_MIP_GAPstill work, for HiGHS only, so a setting made for HiGHS never limits a commercial solver.--threadssets their thread count (see Behaviour changes).- A solve that stops at its time limit: a MIP with a solution keeps it, with a warning (with the gap reached
when the solver reports it), and the run goes on; an LP, or a solve without a solution, stops the run. This is
the same for every solver (a Benders master or region solve stops the run at its limit, as before). Before,
HiGHS and CPLEX stopped the run at the limit even with a usable MIP solution, so such runs now finish; Gurobi,
COPT and Xpress reported a solve stopped at its time limit as optimal, without a word. With
FLEXTOOL_ACCEPT_NEAR_OPTIMAL=0a MIP stopped at its time limit now stops the run too, as at any other limit. - A solve stopped by another search limit (nodes, solutions, work, iterations) follows the time-limit rule: a
MIP with a solution keeps it, with a warning naming the limit, its option (e.g. Gurobi
NodeLimit, HiGHSmip_max_nodes) and the gap; an LP stops the run. Before, a CPLEX, Gurobi or COPT solve stopped by such a limit was reported optimal, and HiGHS's node, leaf and improving-solution limits (also with--save-memory) were rejected as an "unrecognised status". - A MIP stopped at an objective target (HiGHS
objective_target, GurobiBestObjStop/BestBdStop) keeps its solution with a warning, as at a search limit. A HiGHS MIP solved withobjective_bound(from any option layer) gets a warning, once per solve: HiGHS treats the bound as a cutoff and reports the best solution within it as optimal, so it may not be the optimum. - Objective-valued solver options are scaled to the objective the solver sees. A target, bound, cutoff or
absolute gap (HiGHS
objective_target,objective_bound,mip_abs_gap, and each commercial solver's equivalents, listed in Time and other limits) is written in unscaled currency; FlexTool and polar-high multiply it byscale_the_objectiveand HiGHS'suser_objective_scale(set by the autoscaler or by you) before the solver gets it, in-process, with--save-memoryand in the commercial solvers' option files. Before, the value reached the solver unchanged, so it meant a different amount for everyscale_the_objectiveand autoscale factor. TheSolver argsline shows both values, the solver's first:mip_abs_gap=0.5 (500000 in unscaled currency). Relative gaps are unchanged. A Benders solve with an objective target or bound (objective_target/objective_bound) is refused before it starts, naming the option and where it was set: such a value has no meaning for the master or a region; absolute gaps are passed, scaled. - The existing fixed cost constant is scaled with the rest of the objective. With
include_existing_fixed_cost(used by tests), the constant was added unscaled, so the objective mixed scaled and unscaled currency; and an objective read from a--save-memoryor commercial solve, or after autoscaling, left the constant out. Production runs do not add the constant: their results do not change. - A memory limit, an aborted solve, or a solution the solver does not certify as optimal stops the run, naming the solver's status, even with a solution. Before, a CPLEX, Gurobi or COPT solve stopped at its memory limit or aborted was reported optimal. Not certified are, for example, a CPLEX solution "optimal with unscaled infeasibilities" or with an "optimal face unbounded", and a COPT solve (or a Gurobi one with a text result file) whose log is turned off, since how it ended cannot be read.
- The warning of a solve kept at a limit now reaches the run log (before, it was dropped). The run record
keeps it once, as the solve's message. A
--save-memorysolve stopped by a limit before it found a solution is recorded as stopped at a limit (before, as not certified). - HiGHS's time-limit warning names the MIP gap with
--save-memory, as the commercial solvers' does; before, it pointed to the solver log. An in-process HiGHS solve names it too. A gap below 0.01 % is printed with significant digits instead of as0.0000%. - A
--save-memoryHiGHS solve that is killed stops the run with an error naming the cause. A solve process stopped by a signal, such as SIGKILL from the out-of-memory killer, went on to read its solution file and failed with a raw "file not found", or read the solution an earlier run of the same solve had left in the work folder. Now the error names the signal (a Windows status code in hex) and the folder that keeps the model file. A process that ends with exit code 1 and no solution file (an error inside it, or on Windowstaskkillor the Task Manager) gets the error "ended without writing a solution". Such a stopped solve no longer records a warm-start verdict (--warm-start): before, a killed first warm solve was recorded as "warm start helps" for good. A cold timing solve that is killed, stopped (on Windows with exit code 1), crashes or cannot start now leaves the verdict to the next solve on every OS. A solve name with\or other characters a file name cannot safely hold is turned into one file-name token, as for the commercial solvers, so its files stay in the work folder on Windows. - A whole-number HiGHS option in a solve's
solver_argumentsnow reaches HiGHS. The input database stores every number as a decimal (3.0), and HiGHS refuses that for a whole-number option such asuser_objective_scaleorsimplex_iteration_limit, and1.0for a true/false switch: an in-process solve dropped the option with a warning, a--save-memorysolve stopped with an error. FlexTool now gives each HiGHS option the type HiGHS declares for it, in-process and with--save-memory, from every source (highs.opt,solver_arguments, the flags). A value that is not a whole number for such an option (2.5), or not 0 or 1 for a switch, stops the run before the solve with an error naming the option and where it was set; the same holds for a whole-number parameter of Gurobi, CPLEX, Xpress and COPT (which already got3.0as3). - A solver that writes no solution no longer gets the previous run's solution. The work folder
(
solve_data/subprocess) keeps each solve's result file between runs. A commercial solver that ended without writing one (CPLEX "No solution exists" for an infeasible model, COPT without a solution, a crash or a solver wrapper that exits 0) had the earlier run's file of the same solve read as this solve's answer, possibly as optimal. The result files of a solve are now removed just before its solver starts, for HiGHS with--save-memoryas well. - Solver option files come from one
solver_configfolder chosen per run, no longer from the folder FlexTool is started from:--solver-config-dir(new) orFLEXTOOL_SOLVER_CONFIG_DIR, elsesolver_config/next to the input database or in the workspace of a GUI project database, else the Spine Toolbox project folder, else the current folder, else FlexTool's defaults with a warning. A Spine Toolbox run used FlexTool's default.optfiles instead of the workspace's, and a terminal run from another folder used the defaults and left a copy there that later runs took for your file. The run header and log show the folder used (Solver config:). Each run reads the files once at its start and copies them afresh to<work folder>/solve_data/solver_config/. A<work folder>/solver_config/left by an earlier version is no longer read as the work folder's copy (the run warns when it differs); when it issolver_config/in the current folder and holds only unedited FlexTool templates, the run skips it, uses the next folder or the defaults, and warns, naming it (not in the FlexTool folder itself, whosesolver_config/is always used). The GUI and the calibrator pass the workspace's folder to their runs. The run record (_run_status.json) names the folder and gives a SHA-256 of each file the run used, and whether it is FlexTool's unedited default, so two runs' options can be compared without their logs. - CPLEX did not get
solver_mip_gap. It was sent with the Python-API namemip.tolerances.mipgap, which the CPLEX interactive optimizer that FlexTool runs does not know; it is now sent asmip tolerances mipgap. CPLEX results with asolver_mip_gapcan change. - One value per solver parameter. Parameter names from the option file,
solver_argumentsand the flags are now matched as the solver reads them: without regard to case (mipgapandMIPGapare one parameter; for CPLEX a dot is read as a space; Gurobi also ignores_, Xpress anXPRS_prefix), and the highest layer's value is the only one passed. Before, both spellings reached the solver and the result depended on its argument order. - A time-limited commercial solve could fail with a solution in hand. With a
TimeLimitinsolver_arguments, FlexTool stopped the solver after exactly that time, possibly while it was writing its result. The solver now enforces its own limit, and FlexTool stops it only after twice the limit plus 10 minutes. - An unknown Gurobi parameter is reported. Gurobi ignores a misspelt name with a message; FlexTool now repeats
it as a warning naming where it was set. Gurobi's
ResultFileis not passed on, with a warning: FlexTool names its own result file and reads the solution from it. - Xpress returned the LP relaxation of a MIP. FlexTool's Xpress script ran
lpoptimizefor every solve. A solve with integer variables now runsmipoptimize, and its status comes from the Xpress log, not from the print solution (which says "Optimal solution found" after a search stopped by a limit): a MIP stopped byMAXTIME,MAXNODEorMAXMIPSOLwith an integer solution keeps it with a warning naming the limit and the gap; an integer-infeasible or unbounded MIP, or one that ends without an integer solution, stops the run and never uses the LP relaxation. FlexTool reads the log, so on a MIP it setsOUTPUTLOG 1(andMIPLOG 1overMIPLOG 0) after your controls, with a printed line.--solver-time-limiton a MIP is now a hardMAXTIME -<limit>(a positiveMAXTIMEstops a MIP only once it has a solution); aMAXTIMEinxpress.optorsolver_argumentsis passed as written. The log texts were checked with thexpressPython package, not yet with a licensedoptimizerconsole. - The
Solver argsline of a commercial solve showed HiGHS options; it is nowSolver args (<solver>): ...with what the solver receives, under its own names. - The log of a commercial solver is shown as the solver writes it. Gurobi, CPLEX, Xpress and COPT printed
nothing on a successful run (their log was read for the status and dropped), so the parameters they took, how
the solve ended and the gap a time-limit warning refers to could not be seen. The log now streams to the
console, the FlexTool window (greyed, like HiGHS') and the run log, after a
Running <solver>line, and is saved assolve_data/subprocess/<solve>.log. A failed solve no longer prints the log a second time inside its error message (nor the error twice), and a solve stopped by FlexTool's watchdog keeps its log. When FlexTool stops a solver (watchdog, Ctrl-C), the processes it started are stopped too.--solver-log-level silentnow also hides a commercial solver's log (FlexTool still reads and saves it);verboseremains HiGHS-only. - Whole numbers reach a commercial solver as integers (
Threads=4,TimeLimit=60, notThreads=4.0), fromsolver_arguments,solver_mip_gapand the flags alike (the database stores every number as a decimal). An integer parameter can refuse4.0: the Xpress library does. - A COPT solve stopped by a limit, or infeasible, without a solution was reported as a licence error under COPT's size-limited licence, or as a missing solution file; it now names the limit (or the infeasibility). With a solution, a COPT solve stopped by a limit is no longer taken as optimal: a MIP at its node limit keeps the solution with a warning, as at its time limit; an LP at its iteration limit stops the run.
- A CPLEX solve stopped at its time limit without a solution stopped the run with "no solution file"; it now says the solve stopped at its time limit.
- A work folder with a space in its name broke CPLEX, Xpress and COPT runs (FlexTool wrote the file paths
into their command script unquoted). The solver program now runs in
solve_data/subprocess/with bare file names, and its command script is saved there as<solve>.script, to replay by hand. CPLEX's and Gurobi's own log files (cplex.log,gurobi.log) are written there too, no longer in the folder the run started from. - A negative Xpress
MAXTIME(a hard limit) set no watchdog for the solver process; its absolute value now counts as the time limit. - The commercial solver option templates described how options were passed before 4.3.0 (
ReadParams, thesolver_optionsmap,solver_time_limit/solver_threads); their header now gives the actual mechanism and precedence. The FlexTool window (at start) andflextool-updatereplace a workspacesolver_config/<solver>.optthat is an unedited copy of an earlier FlexTool default with the current one; an edited file is never touched. No solve changes: the option values are those FlexTool already used (one early HiGHS default lackedparallel=off, which FlexTool sets anyway). A seeded.optfile no longer takes the template's file permissions, so a read-only installation gives an editable file. - Investment duals were wrong when autoscaling rescaled the model. When autoscale's per-quantity scaling
(Layer 2) rescaled an investment model that HiGHS solved inside the FlexTool process (the default), the reduced
costs of the investment variables were read in the rescaled units, so
v_dual_invest_unit,v_dual_invest_connection,v_dual_invest_nodeand thedual_invest_effective_*outputs built from them were wrong without a message. They are now in the model's own units; other results are unchanged. Runs where HiGHS solves in a separate process (--save-memory, or warm start off) and commercial solvers were not affected. A re-solve of the same model after a data change now starts from the previous solution's basis (no effect on current runs). - The memory-saving solve could put solution values on the wrong variables. With
--save-memory, or with warm start off, HiGHS solves the model from an MPS file in a separate process. When the model had a variable that no constraint and no cost used, the values read back after it were shifted onto the next variables, so results could be wrong without any message: one test model reported its capacity-margin shortfall (122.45 MW) as a 1224.5 MW reserve shortfall. An entity name with a space (Battery Farm) could also be misread in the file, which could change the model, for example force a unit to 0. Both are fixed: the file now uses generic names, every value is read back to its own variable, and a solution that does not match the model stops the run with an error. The first--save-memoryrun after the upgrade does not reuse a warm-start basis saved by an earlier version. - A crash on macOS could be reported as "killed". The run record,
flextool-runand the solver-stack check (python -m flextool.env_check) read a signal number with Linux's numbering, so on macOS a bus error (SIGBUS, signal 10 there) was recorded as a kill by SIGUSR1, and signal 7 as a bus error. Signals are now named by the computer's own numbering. - A rolling solve on representative periods with
*_blended_weightsstorage nodes runs (#126). It stopped with an error; now those nodes bind forward only (bind_forward_only) and the storage state is passed directly from roll to roll, with one warning per solve naming them (storage_binding_methodis not applied in a rolling solve). The same holds when such a solve mixes periods with and without representative periods.
Stochastic solves
- Storage in stochastic solves follows each path (results change). Plain bind_within_solve now closes the
cycle once per path: every forecast branch ends at the level the solve's first step starts from (before, only the
branch listed last did and the others ended free), and when the forecasts open in the solve's first period each
forecast continues from the end of its own path (before, it looped within its first period). With
bind_forward_only and nothing fixing the start (no
fix_start, no carried state) the forecasts of a fan in the solve's first period now start from the same level (before, each chose its own). Withfix_start/fix_start_endeach forecast starts from thestorage_state_startshare of its own storage capacity, so where the capacity differs by forecast (investment or divestment per forecast) the starting energy differs although the share is the same; without a fixed start all the forecasts start from the same level. Results of such stochastic models change; the upgrade marks the databases and runs name the nodes. - Stochastic values in the later periods of a stochastic solve. A stochastic-time value (
branch/analysis_time/timemap) given only at the fan's analysis time read 0 in the forecast branch periods after the fan's period, in the realization dispatch of those periods and, for entities outside everyinclude_stochasticsgroup, in the realized-branch read: a wind profile was 0 there and the unit did not run. A single solve and a roll of a rolling solve decide at their fan's analysis time, so every period after the fan's period (look-ahead periods and the later committed periods) now reads the forecast given at the fan's analysis time, even where a later forecast (a later roll's, or the next solve's) is given at that period's first step (before, a single solve read that later forecast, and a roll's look-ahead period too); a time step the fan's forecast does not cover but a later one does is an input error (see "Input checks"). A period before the reveal reads the realized branch's forecast made at the solve's or roll's start (see "Input checks"). The realization dispatch reads its branch the same way. - No information from the future in the realization of a single stochastic solve (results change). Its
realization dispatch (
<solve>__realized_dispatch) ran over the solve's whole horizon with the realized data, look-ahead included, so the storage it left at the end of the realized periods, and handed to the next solve, was decided knowing the realized data of the look-ahead. It now dispatches the realized periods only, as each realization roll of a rolling solve dispatches its jump only; the storage at their end follows the node's end rules. A period between two realized periods that is not realized is dispatched too but not reported. A realization solve you configure yourself keeps its periods, with a warning when it holds a period after the stochastic solve's last realized period (or before its first). Aseparate_dispatchsolve withoutrealized_periods(an invest-only solve) gets no realization dispatch (before, it got one that dispatched the horizon and reported nothing). - Two or more solves with a
realized: yesbranch run again (4.2.0 to 4.3.0). A run of two or more solves in which a stochastic solve designates arealized: yesbranch stopped with "periods ['p2030_rlz'] have no year from the start of the run". FlexTool's internal copy of the realized branch is no longer treated as a period of its own: its operations are reported by the realization solve, discounted at the year of the real period. Investment and operating costs of every solve of such a chain are discounted from the start of the run. - The investment before a mid-horizon fan was missing from the results when the fan has a
realized: yesbranch. The solve made the investment, butv_investhad no row for the period before the reveal, sounit_capacity__d(and the connection / node tables) showed no invested capacity there and the investment cost was left out of the cost tables and the summary (the total capacity was right). Runs without a realized branch were not affected.
Input checks
- Before the forecasts branch out, a stochastic-time value must come from the forecast made at the solve's or
roll's start. Every period before the reveal now reads only the realized branch's forecast made at the solve's
or roll's start, so 2030 and 2035 deterministic with 2040 stochastic works, and so does a roll whose fan opens
several periods after its start. Before, a period read the forecast given at its own first time step (a forecast
made after the start, information the solve does not have yet). Where none was given there (a roll starting
inside a period, a period between the start and a later fan, a forecast that did not cover the period), the
profile, inflow, availability, price, reserve or other stochastic-time value read 0 (or the default) without a
message. The earlier GMPL model stopped such runs, and the check was lost in the Python port. It is now a
configuration error raised before the LP is built when the start's forecast misses a time step these periods read.
A forecast given earlier or later is not used instead. A fan without a realized branch no longer lends its first
forecast in sorted order to the periods before it: stochastic-time data there is a configuration error ("designate
a realized branch, which provides the shared data, or give the parameter a value without forecasts"). Each message
names the entity, the parameter, the solve or roll, the period and the time steps, and the forecasts made at
other times that do have values there. Give the realized branch
forecasts at each solve or roll start that cover the steps before the fan, or align the rolls with the forecast
times (
rolling_solve_jump). Such models ran with zeros, later forecasts or another forecast's data before: rerun them. - A forecast fan declared later in a solve that already fans out is ignored with a warning. A solve or a roll fans out once, at its first reveal; forecast branches declared at a later period (or, in a roll, at a later time step where no roll fans out) were ignored without a message. They are still ignored, and one warning per run now lists them: the solve, the period, the analysis time, the branches and the solves or rolls whose horizon reaches them. Remove the rows, or put the later fan in its own solve or make a roll start there.
- A roll of a stochastic rolling solve that reaches no forecast is an error. Every roll needs a forecast of
stochastic_branchesat its start or where it fans out later in its horizon. A roll whose horizon reached none was solved without forecasts and without a message; the run now stops before solving, naming the roll, its start and its horizon. Add forecasts at the roll's start, or align the rolls with the forecast times (rolling_solve_jump). - A
realized: yesrow that switches the realization is an error. In a rolling solve, arealized: yesrow where no roll fans out, or one where a roll fans out that names a different branch than at the other rolls' fan points, silently switched the realization dispatch to that branch's series. The run now stops before solving. A row where no roll fans out is named with the roll (or solve), the period, the analysis time and the branch, as a single solve already did. Different branches at the fan points are named with the solve and each fan point's period, analysis time and branch. Mark the same realized branch where each roll fans out and change that branch's series to give the realization new data. - A stochastic node
inflowoutside everyinclude_stochasticsgroup needs a realized branch. In a fan without arealized: yesbranch such a node read an inflow of 0 in every branch period without a message; it is now a configuration error naming the node, as for the other stochastic-time parameters. Add the node to aninclude_stochasticsgroup, designate a realized branch, or give the inflow a non-stochastic value. - A stochastic inflow or reservation that gives a value to only some of a fan's forecast branches is an error, as for the other stochastic-time parameters (it read 0 in the branches left out). The check also covers values given at the fan's analysis time that later periods read.
- A fan's forecast must cover the whole horizon of its solve or roll. In a single or rolling stochastic solve, a period after the fan's period whose time step has no value in the forecast given at the fan's analysis time, but has one in a forecast given later (at the period's own first step: a later roll's or the next solve's analysis time), is an error naming the entity, the parameter, the solve or roll and the uncovered time steps, raised before it is solved. Give the forecasts at each fan's analysis time to the end of the horizon.
- A
co2_max_periodorco2_max_totalon a group whoseco2_methodhas no such cap is reported. The value is ignored (for exampleco2_max_periodwithco2_methodprice); the run now warns, naming the group, the alternative and its method. Before, such a group withco2_max_periodwas also listed as CO2-limited in the group CO2 results although no limit applied; the results now list only the groups whose method caps their emissions. - A limit given as a period map no longer drops the periods it leaves out without a word. A period
the solve uses (look-ahead periods included) but a period map does not cover is now an error naming the entity,
the parameter and the missing periods. Before, such a period got a CO2 cap of 0 (
co2_max_period), no inertia requirement, a non-synchronous limit of 0 (no non-synchronous infeed without a penalty), no limit or floor (cumulative_min_capacity,invest_min_period, cumulative and instant flow limits), an error about another entity (the investment maxima), or a capacity margin of 0. This coversco2_max_period,inertia_limit,non_synchronous_limit,capacity_margin, the per-period investment and retirement limits,cumulative_max_capacity/cumulative_min_capacityand the flow group limits; the investment limits and the capacity margin need a value in the investment periods of the solve only (invest_max_period/invest_min_periodwith a construction lead time: in the periods the investments start to operate). Only limits that are switched on are checked, and a constant still applies to every period. The error names the solve (and the roll of a rolling solve); each solve and roll checks its own periods, so the run stops at the first solve or roll that uses the missing period. Fill in the missing periods if a model now stops with this error. - An explicit 0 in a flow group limit is a limit of 0.
max_cumulative_flow,min_cumulative_flow,max_instant_flowandmin_instant_flowdropped a value of 0 (also one time step of a time map), so there was no limit at all. The net flow into a flow group can be negative, so a 0 limit now binds. - A CO2 cap method without its value is an error. A group whose
co2_methodhas period in it and noco2_max_periodgot a cap of 0 in every period (or the run stopped with an internal error); a total method withoutco2_max_totalhad no cap. Both now stop with an error naming the group, the method and the parameter. - A profile link without profile data held the flow at 0 without a word (#104).
profile_methoddefaults to upper_limit onunit__node__profile, and a profile without a value in the scenario is 0, so the linked unit did not run (typically when the profile's value is in an alternative the scenario leaves out). Now an upper_limit or fixed link (unit, node or connection) to a profile without a value stops the run with an error that names the link, the profile and the alternatives that hold its value; lower_limit stays allowed. Anode__profileorconnection__profilelink without aprofile_method(default none: not applied) gets a warning. - A unit flow with
conversion_flow_coeff0 and nothing tying it was free without a word (#213). Such a flow is outside the conversion and every capacity, ramp and minimum-load limit, so a unit whose only output had coefficient 0 produced free power. A warning, printed once per run, now names each zero-coefficient flow that no user constraint, flowGroup maximum or upper_limit / fixed profile ties (an input is a free dump load on its node, an output a free source), a unit with an input whose outputs all have coefficient 0 (it uses no input), andcapacity_max_coeff/capacity_min_coeffon such an output (no effect). The narrower "no minimum-load floor" warning for such units is replaced. The model is unchanged. - Typing mistakes in common values ran on silently (#170). The run now stops, before the data is read, with
one error that lists every value of (together with the period-key and profile-link errors): unit or connection
efficiencyat or below 0, unitmin_loadoutside [0, 1],availabilitybelow 0,capacity_max_coeffbelow 0, an invest / retire / cumulative minimum above its maximum (total, and per period) when theinvest_methoduses it,lifetimeat or below 0 on an entity that can invest or retire, andexistingas a time series, keyed by time steps or keyed by numbers such as 2020 (a period whose name is also a time step is fine). Before, these turned a unit off, paid it to run, ended "infeasible" without a hint, gave no capacity or crashed. Warnings:availabilityabove 1.5 and a profile above 1.5 on an upper_limit / fixed unit link (values above 1.5 look like percentages; capacity factors slightly above 1 are not reported), connectionefficiencyabove 1 andvirtual_unitsizeat or below 0. Model parameters → Parameter groups and conventions has a new section Input checks listing them. Valid data runs as before. - A negative
existingran without a word (#170). A unit, connection or node withexistingbelow 0 now gets a warning naming it, the alternative and the period. Such a unit is a supported "anti-energy" unit (its flows run the other way), so the run continues, but a negative value is more often a typing mistake, and with a profile,min_loador invest / retire limits it can make the problem infeasible. - An
invest_methodon a node that is not a storage node is reported. Only storage nodes invest or retire. Such a node got an investment variable that did nothing; it now gets none, and a warning names it. Remove theinvest_methodor make the node a storage node. - A
virtual_unitsizeat or below 0 was not ignored, although the warning said so. A negative value became the unit size and multipliedexisting(200 units of −5 MW: an anti-energy unit of −1000 MW). Now it is ignored as the warning says:existingis the capacity itself and the entity is one unit of it. Results of valid data do not change. - A node with nothing to balance it ran without a word (#276). A node without any unit or connection in the
scenario (because none was linked, or because they are not active in it) now gets a warning: with an
inflow, only the penalty slack can meet it; without one, the node does nothing. A balance node whose units and connections only take energy out of it (a fuel node that should be a commodity node) and that has no positiveinflow, or whose units and connections only bring energy into it and that has no negativeinflow(demand), gets a warning too. A connection's direction follows itstransfer_method, or thetransfer_methodof a group that holds both its nodes; a unit with a negativeexistingcounts both ways. Commodity nodes are not checked, storage nodes only for being empty. The run continues and the model is unchanged. - A mistyped period key was dropped without a word. A period map keyed y2020 in a
model whose period is p2020 gave
existing0, an extra key such as p2030x was ignored, andstartup_costkeyed by time steps or by 2020 vanished or crashed. Now a map key of a period parameter that is not a period of the database (any scenario's solve periods,years_represented,stochastic_branches, the stochastic branch periods such as p2035_high ormodel.periods_available) stops the run with an error that names the entity, the parameter, the alternative and the key, and suggests the closest period; data that covers more years than the solves name goes inmodel.periods_available. A period parameter that cannot vary over time keyed by time steps, and numeric keys, are errors too; maps keyed by constraint names are not checked. A map withoutindex_nameof a parameter that takes a period or a time map (self_discharge_loss,efficiency, ...) whose keys are neither periods nor time steps and do not look like time steps (p2050, p_none) gets this error too (before: the entity silently got the parameter's default); keys that look like time steps (t0099, T0001, a date-time stamp) get the time-series error below. - A spare timeline without
timestep_durationstopped the run. Only the timelines a solve uses (through the timesets of itsperiod_timeset, or the timeset a solve without one takes by default) need durations now; a used timeline without them, or with a step of 0 hours or less or no number, stops the run with an error naming the timeline, the timeset and the solve. - Time series values whose time step is not on any timeline were dropped without a word. Hourly data
on a 3-hour timeline used every third value, keys past the end of the timeline or in a different case (
T0001) were left out, and a series with no key on the timeline fell back to the parameter's default. Now a warning, printed once per run for each class and parameter, names the entities, the number of such time steps and the first five, and the timelines; a time series with none of its time steps on a timeline stops the run with an error that names the entity, the parameter and the alternative. The keys are compared with the steps of all the scenario's timelines; stochastic branch names and the periods of a period+time map (also the periods of another scenario, in a map withoutindex_name) are not checked. Date-time stamps are compared as dates. A series on a timeline of another scenario gets a warning, not an error. The model is unchanged. - A timeset block that starts off its timeline, or runs past its end, was skipped or cut without a word.
A
timeset_durationblock whose start is not a time step of the timeset's timeline (a typo, a different case such asT0001) was left out of the model, and when no block was left the error pointed at thetimelineparameter. Now the run stops with an error that names the timeset, the solve and the block (and the step it probably means, when only the case differs). A block longer than the rest of the timeline is still cut at the end of the timeline, but a warning, printed once per run, names the timeset, the block, the timeline and the number of time steps not used; on a representative-period timeset this is an error (before, the run crashed later with an internal error). A timeset whosetimelineis not a timeline of the scenario is an error. Only the timesets used by the solves of the model are checked. A block length that is not a whole number gets a warning. No change to the model otherwise. - A time map without
index_nameof a nodepenalty_up/penalty_down/availability/self_discharge_loss/storage_state_reference_valueor of another_operational_costadded its time steps to the model's periods. It now reads only as a time series, as the same map withindex_nametime does. - A run reloaded from edited
solve_datafiles left out the operating costs of a period or time step without a word when the inflation factor, the share of the year, the time step duration or the time step weight had no value for it; it now stops with an error that names the file and the missing periods or time steps. - A
profilemap withoutindex_namekeyed by periods was read as a time series and gave 0. A map such as p2020: 0.3, p2025: 0.35 now gives each period its value in every time step, as the same map withindex_nameperiod does (single, rolling, nested and stochastic solves). A map whose keys are both periods and time steps of the model (or a key that is both) stops the run with an error naming the profile and the alternative: set itsindex_nameto period or time. A profile period map (with or withoutindex_name) that leaves out periods of the solves the scenario runs gets one warning per run naming the profile and those periods; the profile is 0 there. - A two-level (period + time)
profilemap with an unnamed level gave 0. Two shapes were affected: a map withoutindex_nameon its outer level (its inner level with or withoutindex_nametime), which was taken for a stochastic map (those have three levels), and a map withindex_nameperiod on its outer level but none on its inner level, whose time steps were not read. Either way the profile was 0 in every time step without a word. Both now read as the same map withindex_nameperiod and time, when the outer keys are periods (the stochastic branch periods take their anchor period's curve), in single, rolling and stochastic solves. Unnamed outer keys that are time steps, both periods and time steps, or no period, and unnamed inner keys that are periods, stop the run with an error naming the profile and the alternative; a period missing from the map gets the warning above. Three-level (stochastic) maps are unchanged.
User constraints
- A user constraint without capacity terms lost its
constantwhen another constraint had one (since 4.0). When some constraint usedconstraint_cumulative_pre_built_capacity_coefforconstraint_capacity_coeff(whose existing capacity moves to the right-hand side) or, in a chain of solves with a construction lead time,constraint_invested_capacity_coefforconstraint_in_construction_capacity_coeff(capacity an earlier solve decided), andconstantwas a single value, every constraint without such a term got a right-hand side of 0 instead of itsconstant(e.g.flow ≤ 800becameflow ≤ 0), in per-step and once-per-period rows alike. Models without these terms, or with a period-mapconstant, are unchanged.
Outputs and plots
- Names in Excel workbooks stay text. A name that starts with
=(an entity, scenario or alternative, a description) is written to the result workbooks, the comparison and dispatch workbooks and the input export as text, never as a formula or a link; the input export now reads such a name back unchanged (before, it came back empty). The CSV files keep the names exactly as they are;docs/results.mdsays how to open a CSV file from someone else safely in a spreadsheet program. - Plot file names stay in the plot folder. A
/or\in a plot name from the plot settings, or in a node, group or scenario name of a dispatch plot, becomes_in the file name (on Windows also< > : " | ? *). - Control characters in names are shown, not acted on. A name from the input data with an escape character or
another control character is shown with that character as
\x1b(and so on), and with a character that reverses the direction of text as\u202e(and so on), instead of reaching the terminal as a command or reordering the line. This holds inflextool-run's console output and_run.log, in a model run's own console output, inflextool-write-outputs,flextool-scenario-results,flextool-execute-workflow, the calibration and representative-period command lines, in the Spine Toolbox console for the FlexTool, Process outputs and scenario comparison tools, and in the FlexTool window's logs. Other command-line tools (for exampleflextool-time-runsand the database tools) print such characters as they are. - VRE curtailment, VRE potential and flow-group energies per period are scaled to
a full year.
unit_curtailment__outputNode__d,unit_outputNode_VRE_potential__dand thecumulative_flowofgroup_flow__dwere sums over the sampled time steps (the flow group's also without the timestep weights); they are now MWh per year, weighted and annualised like the flows, so with a 2-day period they are 182.5 times larger. The curtailment share is unchanged;average_flowis unchanged with uniform timestep weights and is now the weighted mean with timeset weights (before, it ignored them). The "total" plots multiply each period by its years represented. New tablesgroup_node__horizonandgroup_node_VRE_share__horizon: the node-group indicators and the VRE share over the whole horizon as ratios of the total energies (each period scaled to a year and times its years represented), not averages of the period shares; the "total" plots use them. Plot labels say what the numbers are: "(annualized)", "(horizon total)", costs in "M CUR/a (annualized)", "M CUR (horizon total, undiscounted)" and "M CUR (discounted)", CO2 in "Mt CO2 (horizon total)". - The inertia result tables were always empty.
group_inertia__dtandgroup__unit__node_inertia__dthad no value column; they now hold the inertia of each group's units (flow ×inertia_constant). The model was right. - The unit of a storage node's annuity.
entity_annuity_d_pholds units, connections and storage nodes in one table. A storage node's capacity is an amount (how much it holds), so its annuity is per amount unit:CUR/MWh(wasCUR/MW), orCUR/m3for a node whoseunit_of_measureis m3. A unit's or connection's capacity is a rate, so its annuity stays per rate unit (CUR/MW, orCUR/(m3/h)). This changes the storage-node columns' unit in the parquet footer, the Excel unit row,Unitsand_output_metadatasheets,units.csvanddatapackage.json; values are unchanged. The combined scenario-comparison parquets now carry the units of each scenario's columns in their footer, as the scenarios' own parquets do. - The slack lines of
summary_solve.csvare weighted like the objective. The Created / Removed / Inertia / NonSync / Reserve lines summed the slack over the time steps × duration without the timestep weights, so withtimeset_weightsor representative periods they did not match the penalty the objective charges. They are now Σ w·Δt·slack per period (not annualised, as before). Uniform weights give the same numbers; in a representative-period solve the line is now on the scale of the represented sample. The CapMargin line (per period) is unchanged. - The per-period result tables of a rolling solve were labelled with one roll (#210). The
solvecolumn of the…__dtables (CSV, Excel, results database) named the last roll that realized a part of the period (dispatch_fullYear_roll_roll_17). It now names the complete solve (dispatch_fullYear_roll). The capacity tables (unit_capacity__d,connection_capacity__d,node_capacity__d) and the investment marginal values name the solve that realized the period's investments, for example the investment solve of an investment → dispatch chain, instead of the dispatch solve. The per-step tables are unchanged. Values do not change. - An Excel sheet name over 31 characters lost its time-resolution suffix. Excel cuts sheet
names at 31 characters, so
unit_curtailment__outputNode__dtbecame the sheetunit_curtailment__outputNode__dand the real per-period tableunit_curtailment__outputNode_1. A long name now keeps its__d/__dtsuffix and is shortened in front of it, with_1,_2, … before the suffix when two names would be the same (also without regard to case, as Excel compares them). The shipped long names get hand-made sheet names instead (process__reserve__node__dt,process__reserve__node__d_avg,reserve_price__group__dt,slack__reserve__group__d/__dt,unit_VRE_potential__d/__dt,unit_VRE_curtailment_share__d/__dt), and the curtailment tableunit_curtailment__outputNode__d/__dtgets the sheet nameunit_VRE_curtailment__d/__dtlike its neighbours; the CSV file names do not change. The scenario-comparison workbook and the dispatch-data workbook name their sheets the same way. - The comparison dispatch plots added together the time steps of the same name in different periods. In
a multi-period scenario each step (pv, loss of load, curtailment, demand, ...) was the sum over the periods, in
the order of the step names. The periods now follow one another in the realized order, with a gap where a
period does not continue the previous one, and the x axis names the time steps with the period below them. In
a comparison of scenarios with different steps, each scenario shows its own steps in its own order (not zero
rows for the other scenarios' steps), and its plot has gaps only where its own timeline breaks. The same fix applies to the dispatch plots in the result viewer. Output format: the
plotted-data CSV of a dispatch plot and
dispatch_data.xlsxnow have aperiodcolumn beforetime. - The period node-group indicators and VRE share were not shares of the period's energy. Curtailed VRE
of potential VRE was divided by the inflow instead of the potential VRE generation and, per period, summed over
the time steps (so it could exceed 1); Curtailed VRE of demand was an average of the time-step shares;
nodeGroup_VRE_share_d_gwas an average of the time-step shares and VRE share of demand lost the VRE of a time step without inflow. Every period share (groupindicator,nodeGroup_VRE_share_d_g) is now the ratio of the period sums weighted by the time-step weights, so the values of these results change. The VRE share takes the VRE generation into all member nodes, as the indicators do; the curtailed VRE is taken on the arcs that have a potential (VRE units with anupper_limitprofile), so the flow of a VRE arc without a profile no longer offsets it; the time-step VRE share is 0 (not infinite) at a time step without inflow; a realized time step without a time-step weight stops the output processing with an error. - The reported input of a unit with a profile (
unit__node__profileon a unit that also has an input) equalled its output: the efficiency was left out. Its fuel or electricity use, the node results at its input node, and the commodity cost, CO2 emissions and CO2 cost computed from that input (per unit, per group and the calculated total cost) were too low. The optimisation itself was right (the solver objective insummary_solve.csvwas correct). - Line and stacked-area plots leave a gap where there are no results. A time step whose plotted cells are all empty, and the gap at a timeline break, were drawn as 0 in a line that sums several columns and in every stacked-area plot (scenario, comparison and result-viewer plots); a time step with no result at all was dropped, so the line joined across it. They are now gaps. In a sum, an empty cell next to values adds nothing. Bar charts are unchanged (an empty value draws no bar). The scenario comparison's dispatch plots now leave a gap at a timeline break too (before, they drew it as 0).
Databases and the FlexTool window
- Fixed: deleting a scenario's results in the FlexTool window removed the wrong Excel workbooks. It removed
every workbook whose name contained the result folder's name (deleting
base_1also removedbase_10's) and kept the scenario's own one, and the window showed an Excel export where there was none. The workbook is now named after the result folder,output_<scenario>_<source number>.xlsx(before,output_<scenario>.xlsx, which every source of the scenario overwrote), and the window matches it by its exact name. A workbook with the old name is deleted with the last result folder of its scenario, since until then it may hold another folder's results. The command line and Spine Toolbox name itoutput_<scenario>.xlsxas before. - Fixed: Calibrate investments in the FlexTool window deleted the results of every scenario in the project.
Each calibration round emptied the project's whole
output_parquetfolder, so the result files of the other scenarios were lost (the run records too; their CSV, Excel and plot files stayed). A calibration now clears only the result folder of the scenario it calibrates. The same held forpython -m flextool.calibratewith--output-locationset to a folder that held other results; with--debugthe earlier rounds are now kept inout_iter_<k>/<scenario>/. Present since 4.0.0. In a project from before result folders got the source number,output_parquet/<scenario>/can be the results of one input source; calibrating a scenario of the same name from another source is now refused with a message (before, it deleted those results). - The model run refuses an input database that is not at the current FlexTool version.
Before, only databases below version 25 were refused, so an unmigrated database ran old data under the new
version's semantics without a word.
flextool-run(andrun_flextool.py,python -m flextool.cli.cmd_run_flextool) now stops with a message that namesflextool-migrate-database <file>; a database newer than FlexTool asks for a FlexTool update. The standard ways to run migrate first:execute_flextool_workflow.pymigrates the input database before the model run (new), the FlexTool window migrates its sources on refresh and now also checks the sources of the scenarios added to the execution list, and the Spine Toolbox workflow runs its Migrate database tool before FlexTool. - Checking the version of an input source no longer upgrades its SpineDB schema. The FlexTool window
read the version by opening each
.sqlitesource with a schema upgrade, so an external file's schema changed before the consent dialog was shown. The version and the SpineDB schema revision are now read read-only; a source at the current FlexTool version with an older SpineDB schema (e.g. after aspinedb_apiupdate) is migrated like an older one (an internal source quietly, an external one after asking, a frozen one through the frozen dialog), and the model run reports such a database with the migrate command instead of a SpineDB error. - A canonical example database left at an older version is rebuilt.
templates/examples.sqlite, the time-settings template and the how-to example databases are rebuilt from their JSON sources when the file in the FlexTool folder is older than this FlexTool (the old file is kept as<name>.v<version>.bak); before, an existing file was never refreshed, and the model run now refuses it. Calibrate investments migrates its scenarios' sources first, as a normal run does. - The Excel export of an input database put a flowGroup's
max_cumulative_flow/min_cumulative_flowon the time-series sheets; they are constant or per period and are now on the period sheets. - The Solver options dialog said the
.optfiles are in the project'ssolver_configfolder. They are in thesolver_configfolder of the FlexTool workspace (or$FLEXTOOL_SOLVER_CONFIG_DIR). - The Spine Toolbox tool specifications listed files that no longer exist (
bin/highs.optof the FlexTool tool, old modules of Process outputs and scenario comparison). Toolbox skipped them without a word; they are removed. - Databases in a folder whose name holds
%or#open with the next spinedb-api (0.36.8). That version decodes%xxin a database address once more, so a project folder such as100%25 REorG #2was not found (input sources, results, the AI assistant). FlexTool now hands spinedb-api the file path as it is; addresses in messages and run records are unchanged. - A database in a folder whose name holds
#, run from Spine Toolbox with a scenario filter, was looked for at a cut-off path. Toolbox puts its filter (?spinedbfilter=…) in front of the#, and the version check, the solver-option folder, the run record and the settings / output-info database checks cut the address at the?, so they readGfor the folderG #2. They now share one address → file reader that keeps the whole path.
FlexTool 2 import
- FlexTool 2
invested capacity (MW)/invested storage (MWh)are imported as existing capacity. They are capacity already built in FlexTool 2. The importer wrote them as a forced investment, which a dispatch-only workbook (mode_invest0) never made, nor any solve for an entity without an investment cost, so that capacity was lost; withcapacity (MW)empty it was also written asexisting, so counted twice; and amax investbeside it was dropped. Nowexistingis capacity + invested capacity (units, storage, connections, also in sensitivities) andmax investstays the investment limit. FlexTool 2 adds the annuity of that capacity to its total cost, FlexTool 4 does not: the import report lists the entities that have an investment cost (base data and sensitivities). A sensitivity that setsinvested storage (MWh)orstorage (MWh)to 0 keeps a storage that can still invest (max invest (MWh)) a storage node; before, it became a balance node and lost the investment. - A FlexTool 2 storage with only one
max investcan invest. FlexTool 2 derives the other limit through thefixed kW/kWh ratio(MW = ratio × MWh); the import left that side without an investment option, so the ratio link held both at 0. Now both limits are written (also when a sensitivity changes one). A sensitivity oninv.cost/kWhsets the storage node's cost (before: the discharger's cost per kW). A storage that can invest in MWh with neither a cost per kWh nor a ratio is listed in the import report: FlexTool 2 adds it at no cost, FlexTool 4 does not invest in it. - An annuity typed over the FlexTool 2 workbook's formula is listed in the import report. FlexTool 4 computes
the annuity from
invest_cost,discount_rateandlifetimeand ignores that column. - The FlexTool 2
fixed kW/kWh ratiowas inverted. FlexTool 2 sets invested MW = ratio × invested MWh; the import wrote ratio × MW = MWh (a ratio of 0.25 gave a 1/16 h storage instead of a 4 h one). - The FlexTool 2 importer (
flextool-read-old, GUI "Convert from FlexTool 2.0 input file") translates more and reports what it cannot. Sensitivities are now imported in the same run as the base data (--master MASTER.xlsm; in the GUI the master workbook is asked for before the conversion), before the database is migrated: before, the GUI wrote them into an already migrated database, where a renamed parameter such as the capacity margin was dropped with only a log line. A target that is a FlexTool database at another version than the import's (56) is refused, as isflextool-import-sensitivitieson a migrated database. The FlexTool 2 master workbook given as the input file stops with a message naming the right files (before: an empty database).fix unit generation,use min generationanduse max generationwith theirts_unitseries (shares of capacity) become profiles withprofile_methodfixed,lower_limitandupper_limiton the unit's primary output (before: ignored, so must-run units were free).storage start/storage finishsetstorage_start_end_methodexplicitly (fix_start,fix_end,fix_start_end, elsefix_nothing): before, astorage startof 0 fixed the first state at 0 (FlexTool 2 leaves it free), astorage finishwas not read, and with both set the storage still wrapped from the last step to the first (nowbind_forward_only, as in FlexTool 2). The import ends with a report of failed writes and of every FlexTool 2 input that was not carried over or works differently, with its entities and what to do, grouped by topic in plain sentences (flextool-read-old --strictmakes a failed write an error), and the imported database carries no behaviour-change notices. - FlexTool 2 CHP units (output2) are imported as FlexTool 2 defines them. The output2 (e.g. heat) used
fuel and shared the unit's capacity with the primary output, and
output2 max capacity ratiowas divided by the capacity and written asconversion_flow_coeff, which changed the fuel use. Theoutput2 eq / gt / ltrows had output1 and output2 swapped (heat >= 1.4 × elecbecameelec >= 1.4 × heat), their constants were not multiplied by the capacity, and a row with only a constant was dropped. Now the output2 edge hasconversion_flow_coeff0 (no fuel, outside the capacity, ramp and minimum-load limits, as in FlexTool 2), and each row, and the capacity ratio, becomes a user constraintoutput2 − coeff × output1 {=, ≥, ≤} constant × capacity in service. Headers written with underscores (output2_max_capacity_ratio) are read. - An invested FlexTool 2 CHP keeps its output2 in the dispatch solve. The output2 constraints counted only the capacity invested in the same solve, so in the dispatch solve after the investment solve the heat was limited to 0. They now use the capacity in service (pre-built + invested). The import report notes an output2 without any row (free heat).
- In older FlexTool 2 workbooks the output2 constants are MW. A workbook with the
output2 max capacity (MW)column (in place ofoutput2 max capacity ratio) gives theoutput2 eq / gt / lt constantin MW; the importer multiplied them by the unit's capacity (a constant of 100 on a 50 MW unit became 5000 MW). They are now written as MW, with no capacity term, and the import report says so under "How some inputs were read". Newer workbooks are unchanged (constants per unit of capacity). - FlexTool 2
availabilityno longer derates dispatch. FlexTool 2 uses it only in the capacity margin; the import wrote it as FlexTool 4availability, which reduces every flow of the unit. It is now left out and listed in the import report (FlexTool 4 has no per-unit share in the capacity margin). - A FlexTool 2 sensitivity that turns
use_rampsoff is imported (it wrote a ramp method the import's database version does not have, and the import stopped). - A FlexTool 2 workbook with no time step in use is refused with a message. When no
ts_timestep hasin_use(orin_use_investfor an investment run) 1, the import stopped with a SpineDB error after creating the database; now it says which column is empty, before writing anything. - The FlexTool 2 import report flags a
time_in_yearsthat does not match the time series. FlexTool 2 scales operating costs by the years the series represents, FlexTool 4 by the hours it covers; the report now comparestime_in_yearswith steps × duration / 8760 (before: with 1, which missed a two-week workbook withtime_in_years= 1 and flagged a consistent two-year one) and gives the factor by which the ratio of operating to investment costs differs, with the remedy. It also lists cf and inflow series that FlexTool 2 rescales over the steps in use. In the FlexTool window, a converted FlexTool 2 model is written outsideinput_sourcesand moved in only after the conversion, and the new source and its scenarios are ticked again. The CLI now prints its log, and a failed import leaves no database file behind.
Examples
- The demand how-to example (demand.sqlite) served its demand only from the penalty slack. Its coal plant was not active in any scenario and had no capacity, and the scale_to_peak_and_annual scenario asked for peaks of 500 / 600 / 700 MW that cannot reach the annual flows (the scaled demand went above the peak). The coal plant now has 3500 MW and is active in the coal alternative, and the peaks are 1500 / 2200 / 3000 MW. The example's results change.
- The battery of the non-synchronous how-to example was a node without anything connected in the wind scenario. The battery node is now active only in the battery alternative, like its connection. The wind scenario no longer has the idle node; its results do not change otherwise.
- Getting the corrected example databases. A
how to example databases/Demand.sqliteornon_sync_and_curtailment.sqliteleft by FlexTool 4.3 or older is rebuilt from the new data when the FlexTool window starts or FlexTool is updated (see "A canonical example database left at an older version is rebuilt" above; the old file is kept as<name>.v<version>.bak). A file already at this FlexTool's version is never replaced, so your edits are kept: to get the corrected copy, rename or delete the file and start the FlexTool window again (or runpython -m flextool.update_flextool.canonical_databases materializein the FlexTool folder).
AI assistant
- A tool call the AI service could not read is asked again at once, not waited for. With gpt-oss on Ollama, Ollama sometimes answered "error parsing tool call": the model had written its reasoning into the tool call. The assistant took that for a busy server, waited up to five minutes and said a model was likely being loaded. It now asks the model again at once, at most twice, and then says that the service could not read a tool call in the model's answer: ask again, or try another model or level. The assistant also sends the model's own reasoning back to Ollama between the tool calls of a question, as gpt-oss expects, which keeps the reasoning out of its tool calls; the reasoning is never shown and is kept with the saved conversation. When the model lists the runs of one state, it now picks from FlexTool's run states (it guessed names such as "success" before).
Docs
- Planning-practitioner pages. New pages Reading results (where to look first, units) and Model
limitations; a tutorial step that lets the model invest; how-tos for a minimum inertia (with a synchronous
condenser), a sensitivity as one alternative on top of a scenario, replacing a dataset through a partial Excel
import (
--no-purge), several milestones with representative-period investment and full-year dispatch, and a terminal batch loop (bash and PowerShell); Advanced concepts says how costs are discounted between milestones (setinflation_rateto the social discount rate in a real-terms model). Fix (docs): the fixed cost of new capacity counts only within the model horizon, not over its full lifetime as Advanced concepts said, and the solve-sequence how-to used old names forrolling_solve_jump,rolling_solve_horizon,contains_solvesandfix_storage_periods. Results and Reading results now giveinvest_marginalin CUR/kW (CUR/kWh for a storage), the unit ofinvest_costand of the result files; they said per MW. - How-to "How to make a unit run all the time (must-run)" (Thermal, CHP and reserves). Six ways to make
a unit or a fleet run (profile lower limit, a user constraint on a flow or on a fleet's output, a flowGroup
min_instant_flow, a demand that needs the unit's other product, a user constraint on the units online) with when to use each, an exact online count (constraint_online_coeffwithsenseequal), whymin_loadalone is not a must-run and why a negative cost is a poor must-run bonus. Eight example scenariosmust_run_*in examples.sqlite. - How-to "How to compare emission trajectories" (Emissions and system limits): one
co2_max_periodtrajectory per alternative on the same four-period system, without, with existing and with additional measures, and how to compare them. Three example scenariosco2_*_measuresin examples.sqlite. - How-to "How to model cogeneration with desalination" (Thermal, CHP and reserves): a water node in m³,
a thermal desalination unit fed by a backpressure CHP, the same with an extraction CHP whose operating region is
made of user constraints, a reverse-osmosis investment (an
invest_costper m³/h is entered ÷ 1000) and a water storage that lets the plant run flat. Four example scenariosdesal_*in examples.sqlite. - How-to "How to model CSP with thermal storage" (Investment and multi-year models): a solar field, a heat
storage and a turbine; the solar multiple and the storage hours as user constraints on the capacities (with the
design efficiency and
constraint_capacity_coeff), an optimised design, a gas backup, a plant summary (field potential and curtailment, capacity factor, tank cycles, levelised cost) from the existing outputs, and the limitations. Five example scenarioscsp_*in examples.sqlite. - The CHP how-to gave the coefficient 2 on the electricity output of coal_chp_fix; the example has 1 (with −0.5 on heat, so heat = 2 × electricity). The how-to also says how the coal_chp_extraction alternative builds its operating area.
- The ramp-limit text in Units no longer names the internal
p_fix_onlineas something a user sets; it names the online status a stochastic realization fixes and a user constraint on the units online. - A unit
efficiencymay be above 1 (no change in results). A heat pump with COP 3 hasefficiency3; a desalination unit with an electricity input and a water output in m³ uses the m³ per MWh ratio (e.g. 250). The database upgrade to version 79 rewrites theefficiencyandefficiency_at_min_loaddescriptions to say so. - The user-constraint capacity coefficients (
constraint_capacity_coeff,..._invested_capacity_coeff, and the otherconstraint_*capacity_coeffof unit, node and connection) say in their description that they are a map of coefficients indexed by constraint name (description only). - Solver pages. Solver selection gives the one option precedence for every solver, where the option files
come from (new section) and what happens at a time limit; the Gurobi, CPLEX, Xpress and COPT pages describe the
command-line solvers FlexTool runs, not the retired Python adapters, and give the native names the flags
reach. Fix (docs): the 4.3.0 notes and the Gurobi page said
--solver-time-limit(and--highs-threads) reached Gurobi; neither did before this release (--highs-threadsstill does not), and a misspelt Gurobi parameter does not raise an error (see above).
Release 4.3.0 (11.11.2026) — AI assistant
Behaviour changes — read before upgrading
- Rolling solves of a unit, connection or storage node whose
existingis a Map by period (withoutvirtual_unitsize) now use the right unit size, so their rolling results can change; see "Solvers and rolling solves". - Gurobi now gets FlexTool's solver options (it solved at its own defaults before), so Gurobi results can
change, for example a MIP stops at
solver_mip_gap; see "Solvers and rolling solves".
AI assistant
- Fixed: a chat in the AI assistant window changed model after the settings changed. An open or reopened chat took the model saved later for new chats at its next question (and that model's level). A chat now keeps the service and model that answered in it, and its level unless you change the level of that same model; new chats use the saved settings. The top of the window shows the selected chat's service, model and level and where its data goes. The token line is now at the bottom of each chat, always in view, for the last question and the whole chat in thousands ("26k"); the terminal rounds its token line the same way. One empty row now separates "Png settings…" from "AI assistant…" in the side menu.
- New: assistant levels, safety caps and the free plan. The AI assistant now places each model at a level
(basic, standard or advanced) from its name and its context size; the window's settings and
--tieroffer only that level or a lower one, and say why (a model FlexTool does not recognise may go higher at your own risk). Each level has safety caps on the steps and tool calls for one question, and the context size sets how much a tool result may hold and how often earlier results are shortened; a request that would not fit the model's context is shortened first and never sent over it. The new free plan setting ("Free plan" in the settings,--free-tier on|off|autoin the terminal) makes each question use fewer steps, to stay within a free plan's request limits; it is on by itself only for OpenRouter models whose name ends:free, and the chat suggests it after a service first limits how fast questions can be sent. In the terminal,-qwith a--tierabove the model's levels exits with code 2 and sends nothing; the start of the chat shows the level and why, and/contextthe context size and the result size.get_entityandlist_entitiesalso return the entity's description. See "Assistant levels" in "The AI assistant". - New: the AI assistant in the FlexTool window. "AI assistant…" in the side menu opens a chat window for the
open project; ⚙ beside it opens the assistant settings (service, address, key, model, level, privacy settings
sent with each request, how many chats may wait for the service at once, how long conversations are kept). The
first use explains how the assistant works; nothing is sent until you send a question, and before the first
question goes to a service on the internet (or an Ollama cloud model) the data notice asks you to accept. The
window says where the data goes. Each question also tells the assistant what is selected in the FlexTool window
and the result viewer. Chats are numbered tabs: several can answer at once, they can be renamed, closed,
reopened and deleted from the Chats menu, and the open ones come back when the window opens again. Closing
FlexTool stops the answers and keeps what was received. Renaming a project moves its saved chats, deleting it
deletes them. The window and the terminal chat share settings, keys, accepted notices and conversations. An
agent connected with
flextool-assistant --mcpsees the window's selections throughget_gui_contextwhile the project is open in the window. See "The AI assistant" in the documentation. - New: chat with an AI model about a FlexTool project in the terminal.
flextool-assistant --project-path <project folder>asks your questions to the AI service you choose — OpenAI, Anthropic, Mistral, OpenRouter, Google Gemini, a model on this computer with Ollama or LM Studio, or any service with the OpenAI request format — and gives the model the same read-only tools as the agent connection. The first start explains how it works and sets up the service, key and model; keys come from the service's environment variable, the system keyring (--set-key) or, by choice, a key file. Before the first question goes to a service on the internet (or to an Ollama cloud model) the chat shows what is sent and that FlexTool cannot guarantee how it is used, and asks you to accept; the acceptance is asked again when the service, its address or the proxy changes. A model on this computer needs no acceptance.-q "question"answers one question for scripts (only the answer on stdout;--accept-data-noticeaccepts the notice for good). Conversations are kept per project for 30 days (--continue), the tokens used are counted as each service reports them (--usage), and Ctrl-C stops an answer at once. New requirements:anthropic,openai,keyring; run "Update FlexTool" (orpip install -e .). See "The AI assistant" in the documentation. - New: connect an AI agent to a FlexTool project.
flextool-assistant --mcp --project-path <project folder>starts read-only tools that an AI agent such as Claude Code uses to answer questions about the project: input data by scenario, results (filtered, summed or averaged where that is meaningful, and compared between scenarios), run logs and slack, and the FlexTool documentation with the model's equations. For Claude Code:claude mcp add flextool -- flextool-assistant --mcp --project-path /path/to/project. The tools cannot change anything, read results only from a scenario whose latest run succeeded, and run on one thread by default (--threads N). Everything they return is sent to the AI service the agent uses; check its policies yourself. Three prompts set the assistant level (basic, standard, advanced);--tier basicoffers only the basic tools.flextool-assistant --call TOOL '{...}'runs one tool in the terminal. See "The AI assistant" in the documentation. New requirements:mcp(2.3 or later 2.x),platformdirs,jsonschema; run "Update FlexTool" (orpip install -e .) to get them and the new command. - How the assistant answers. At every level the assistant gives the answer first. A question about one value gets the value, its unit and where it is stored, and nothing more. A why-question gets the cause in plain words, the few numbers that show it, the other causes it checked in one line, and what you could change, each option with its effect and drawback. It names entities, parameters, result tables and documents, never its tools, and adds a caveat only when it changes the conclusion.
Parameter descriptions and result labels
- Changed: every parameter description starts with its unit, in the default convention (MW, MWh, CUR,
tCO2):
[MW],[CUR/MWh],[hours];[-]for a share or ratio, followed by an example ([-] e.g. 0.9 means 90%); words where the unit depends on the use ([unit of the constraint per MW]);[choice],[yes/no],[name],[text],[count]for the other parameters. Units that were wrong or incomplete are corrected:penalty_inertiais[CUR/MWs per hour]andpenalty_reserve[CUR/MW per hour](both are paid for each hour of the shortfall);fixed_costandpenalty_capacity_marginare per year ([CUR/kW/a], storage[CUR/kWh/a]) andannual_flowis[MWh/a]. One spelling per unit ([tCO2/MWh],[CUR/tCO2],[hours]).inflow,peak_inflowandenergy_margin_addersay they are MWh per time step, when you must rescale them for another step length and when FlexTool does it;max_flow_for_unconstrained_variablesno longer mentions commodity tiers (it does not bound them). Upgrading a database adds the units to its descriptions. Values and results are unchanged. - Fixed: result labels.
node_inflow__dt,nodeGroup_flows_dt_gand the "Timestep inflow" ofnodeGroup_gdt_pare MWh per time step (labelled MW before);entity_annuity_d_pis the discounted investment cost per MW (labelled as a yearly annuity before); the capacity factor tablesunit_outputs_cf_d_ee,unit_inputs_cf_d_eeandconnection_cf_d_enow carry their metadata (a ratio).
Runs: records, logs and the GUI
- New: every run records itself in its output folder.
output_parquet/<run>/_run_status.jsonsays whether the run is running, succeeded, failed, was interrupted, killed or crashed, with each solve's outcome (solver status, accepted / rejected and why, objective), the error of a failed run, the input database and scenario, the options and threads used, and whether the parquet files in the folder belong to this run or to an earlier one (a failed run keeps the earlier run's files)._run.lognext to it holds the run's console output; a long log keeps its first 64 KiB and its last 4 MiB (FLEXTOOL_RUN_LOG_MAX_BYTES). Writing outputs again keeps both files. A run started from Spine Toolbox or withpython -m flextool.cli.cmd_run_flextoolrecords the Python console output; native solver lines and crash dumps are not in that log. - New flags
--input-source-file PATHand--run-launcher NAMEforrun_flextool: the file the input database was made from, and who started the run, both recorded in_run_status.json. - Changed:
flextool-runruns the engine as a child process and writes the run's_run.log, so the log also holds the solver's native output, a--save-memorysolve's output and a crash dump. Arguments and exit code are the engine's, and Ctrl-C stops the run as before (a second Ctrl-C kills it). The process that stays for the whole run is small (about 20 MB, no polars). On a processor that needs the compatible polars build,flextool-runasks before it starts the engine. The GUI, the workflow runner and the calibrator (also the representative-period iteration) write the log the same way; a run that crashed or was killed is recorded as such. Output with characters outside the Windows console code page no longer crashes these runs. Update FlexTool (flextool-update, orpip install -e .in a source checkout) to get the newflextool-run; until then it runs the engine in the same process as before. The documentation and the command the GUI shows for a run useflextool-run. - Changed: the GUI lists failed and running runs with their state, and offers only results of a run that succeeded. A scenario whose latest run is running, failed, was interrupted, killed or crashed shows ✗ or … in place of its checkbox (– when the run wrote no parquet), says why on hover, and cannot be checked for plots or comparison, even when the folder still holds an earlier run's parquet; ▶ on it opens the run's log. The result viewer and the comparison leave such folders out. The list updates when a run starts. Results from before this version show as before.
- Fixed: two GUI runs of the same scenario could run at once into the same output folder, the second deleting the first one's work folder. A run now waits until the earlier run of its folder has finished.
- Fixed: stopping a GUI run left its
--save-memorysolver process running; it now stops the run's whole process tree. - Fixed:
timings.csvand the output-info database ignored anoutput-locationfrom the settings database while the outputs went there. All three now use the same location, as an absolute path. - Fixed: a run whose last check found a solve without an accepted solution exited 0 and wrote the output-info
database (for example a Benders solve that found no feasible incumbent, or a run with no solve at all). It
wrote no outputs, yet Spine Toolbox and the comparison steps read its
finish_timeas a finished run. Such a run now exits 1 and leaves the output-info database unchanged. A calibration no longer takes the outputs of an earlier run for those of a Benders run without an incumbent.
Solvers and rolling solves
- Rolling solves: unit size of a period-Map
existing. A unit, connection or storage node withoutvirtual_unitsizewhoseexistingis a Map by period got a unit size of 1000 MW in the rolls of a rolling solve (single solves were right), so a 17 MW unit counted as 0.017 units. Withstartup_method = binarysuch a unit could never be online, and amin_instant_flowfloor that needed it made roll 1 infeasible. The unit size is now the largestexistingover the periods in every solve, as for a constantexisting. Rolling results of such models can change. - Warning for a fractional unit count with binary start-ups. A unit or connection with integer online variables
whose existing capacity is not a whole number of units in a period (for example
existingdropping from 17 to 8.5 MW withoutvirtual_unitsize: 0.5 units) now gets a warning naming it, the count and the unit size. - Gurobi options are applied.
gurobi_cltakes parameters only asName=valuearguments; it ignored theReadParams=<file>argument FlexTool passed ("Unknown parameter: 'ReadParams' (ignored)") and solved at its defaults. Each parameter of the merged options (solver_config/gurobi.opt,solver_mip_gap,--solver-time-limit,solver_arguments) is now passed as its ownName=valueargument, so Gurobi results can change (for example a MIP now stops at the requestedsolver_mip_gapinstead of Gurobi's 1e-4). - Licence message only for licence errors. A commercial solver that returned no solution was reported as a
licensing problem whenever its output mentioned a licence, which every Gurobi run does (
Using license file ...), so an infeasible model got a misleading message. The message now needs an actual licence failure in the output (no licence found, expired or not valid, failed to obtain a licence, size-limited licence, token server errors).
Release 4.2.2 (8.11.2026) — time steps other than 1 h: new_stepduration and inflow scaling fixes
These bugs affect models whose time steps are not 1 h, solves with new_stepduration, nested solves where a solve
with new_stepduration runs before one without it, and scale_to_annual_and_peak_flow. Other models give the same
results as in 4.2.1.
new_stepdurationon time steps other than 1 h. A solve'snew_stepdurationnow combines the right time steps for any step length (0.5 h, 2 h, mixed):inflowis summed, other time series are averaged (weighted by duration when the combined steps differ in length), and a constantinflowcounts once per combined time step. Before, it read one row per hour of the new step, so on 2 h steps a constant inflow doubled and time series were misplaced, and on 0.5 h steps half of the energy was lost.new_stepdurationmust fit the time steps. Anew_stepdurationthat is not a whole multiple of the time steps (also 1.5 on 1 h steps, which silently made 2 h steps before), or a new step that would end inside a time step, now stops the run with a message naming the solve and the timeline. Only solves the model runs are checked.scale_to_annual_and_peak_flownow meetsannual_flowon time steps other than 1 h, with periods on different timesets and with representative periods of unequal length (it assumed 8760 steps a year; on 2 h steps the annual energy was 36 % short). Each period now reaches its ownpeak_inflow(the peak was taken over the whole timeline, so periods missed their peak). Withnew_stepduration,peak_inflowis matched per original time step. The input check's reach warning counts the time steps in a year.- Stochastic solves:
scale_to_annual_flowandscale_to_annual_and_peak_flowwork in branch periods (they stopped with an error). scale_in_proportionwithnew_stepduration: a constantinflowis scaled as it is withoutnew_stepduration(it came out about 2/3 of that on 2 h new steps).- Nested solves: a solve without
new_stepdurationthat runs after one with it scalesscale_to_annual_flowover its own time steps (it used the earlier solve's total). Results of such nested models can change. max_flow_for_unconstrained_variablesbelow 1,000,000 is now used as given (it was raised to 1,000,000).
Release 4.2.1 (8.11.2026) — polars 2 supported again
- polars 2 is supported again, with the memory of polars 1. polars 2.x reorders chains of joins by its own size estimate, and on the LP hand-off to HiGHS it picked an order that built a far larger intermediate table: peak memory about tripled on large models (the same LP), so 4.2.0 held polars below 2. polar-high 3.12.3 runs these joins in the order they are written, so FlexTool now requires polar-high 3.12.3 and accepts polars 1.40 or newer below 3.
Release 4.2.0 (7.11.2026) — coarse time steps for units and connections, stochastic realization redesign, multi-solve cost & retirement fixes, run thread budget (schema v77)
A large release: units and connections can decide on coarse time steps, stochastic solves are rebuilt around forecasts and a separate realization dispatch, and many result fixes change what existing models report. Highlights (the detailed entries follow):
- Upgrade: opening a database migrates it to database version 77 automatically (value types match what FlexTool
reads, group
invest_methodnot_allowedbecomesnot_active, dropped parameters are listed). Runs on an upgraded database print a behaviour-change notice naming the affected entities; delete its metadata marker to silence it. - Coarse time steps for processes: units and connections whose nodes are all in
new_stepdurationgroups decide once per coarse step: flows, online variables, reserves,exactconnections and DC power flow. Coarse nodes now balance the sum of their flows (hourly-to-daily conversion no longer gets free energy). - Run thread budget:
--threads N/FLEXTOOL_THREADSfor FlexTool's processing, HiGHS and Benders; the GUI's "Cores per job" is now "Threads per run"; terminal and Toolbox runs process data on at most 8 threads. - Stochastics: a fan holds forecasts only; a
realized: yesbranch adds a realization dispatch; normal outputs hold realized results only (forecast rows in theoutput_horizonraw dump); per-path CO2, ladder and flow caps. - Construction lead time:
investedis the capacity coming online; newin_constructioncolumn andconstraint_in_construction_capacity_coeff. - Costs and retirement: a run of several solves reports costs from the start of the run, each once; annualized
investment and fixed costs are yearly amounts; retirement counts until the end of life; new user-constraint
constraint_capacity_coeff/constraint_retired_capacity_coeff. - Storage: default
storage_start_end_methodisfix_nothing;fix_end/fix_start_endpin the end; self-discharge is exact for any step length;bind_within_periodandbind_intraperiod_blocksfixed. - Other important fixes: node and reserve prices in currency per MWh; reserve provision in MW; one N-1 constraint per failing unit; ramp limits scale with the step length; exact start-ups; unit commitment no longer links across gaps; CO2 limits in rolling and chained solves; inflow scaling stops with an error instead of dropping demand.
- Removed / changed options:
--precision-digitsand--matrix-file-format(and their environment variables) removed; the GUI's MIP-gap setting removed and GUI solver settings now match terminal runs; new--benders-workersand--basis-cache-dir. - Dependencies: polar-high ≥ 3.12.2, highspy ≥ 1.15.1, polars held below 2 (2.x triples peak memory); Python 3.11+, CI on 3.13 and 3.14.
-
Platform: the Windows Toolbox bundle runs Python 3.14 with FlexTool installed, needs no Visual C++ runtime and is smaller; processors without AVX2 are supported through
flextool[compat]. -
New:
--threads N(FLEXTOOL_THREADS), one thread count per FlexTool run, for FlexTool's data processing and the solver. FlexTool's own processing (polars) uses at most N threads and at most 8 (POLARS_MAX_THREADScan set a lower limit), HiGHS getsthreads=N(its parallel simplex stays off) and a Benders solve runs at most N region solves at once. Default: the available cores, with HiGHS and Benders as before. Give each run about cores / runs when several run at once (a script or an AI agent driving the terminal, Spine Toolbox running scenarios in parallel: add--threads Nto the FlexTool Tool's arguments). The run header prints the threads in use. The workflow runner and the calibrator take--threadstoo and pass it on. Requires polar-high 3.12.2. - Changed: the GUI's "Cores per job" is now "Threads per run" and also sets FlexTool's own data processing.
GUI runs used one thread for FlexTool's processing by accident, about 30-45 % slower before the solver; a GUI
run now sends its "Threads per run" as
--threads(default 1, as before). It no longer turns on HiGHS' parallel simplex when set above 1. -
Changed: terminal and Spine Toolbox runs use at most 8 threads for FlexTool's data processing (before: every core). No measured slowdown, and less contention when several runs execute at once.
-
Changed: a solve HiGHS could not certify as optimal (status "Unknown") is judged against its own feasibility tolerance. It is accepted as near-optimal when its largest relative primal infeasibility is within max(1e-6, 10 ×
primal_feasibility_tolerance) (before: 1e-6 whatever the tolerance) and its primal-dual objective error is within the accepted gap. A solve run with a looser tolerance (e.g. 1e-3) that stopped the run before now writes results; with the HiGHS default 1e-7 nothing changes. A setFLEXTOOL_ACCEPT_PRIMAL_RELstill replaces the limit. Upgraded databases print a behaviour-change notice for each solve accepted this way. - Fixed: a solve that HiGHS could not certify (status "Unknown") failed with "returned no model status" when autoscale rescaled the LP. Writing the unscaled solution back into HiGHS cleared its status and counters before the accept/reject check read them, so the near-optimal check never ran and the real status was not reported. The check now uses the status and counters of the solve itself.
- Changed: polars is held below 2. polars 2.x roughly triples FlexTool's peak memory on large models (the same LP); installs keep polars 1.x until that is resolved.
- Fixed: after "Update FlexTool" in the Windows Toolbox bundle, moving the bundle folder broke FlexTool in
processes started outside its
flextoolfolder (python -m ..., a Jupyter console): the reinstall wrote the folder's absolute path into the bundled Python. "Update FlexTool" now points the install to the clone by a path relative to the bundled Python again, so the bundle can be moved or unzipped anywhere. - Fixed: on Windows, a database could not be deleted or replaced after a run in the same Python session
(Spine Toolbox console, a script calling FlexTool): the run kept the input database, the output-info database
and
results.sqliteopen after it finished. Every database FlexTool opens is now closed when it is done. - Fixed: on Windows, creating or migrating a database wrote garbled parameter descriptions (
→for→,—for—). The schema and template JSON files were read with the Windows code page instead of UTF-8; they are now always read as UTF-8. - Fixed:
FLEXTOOL_HIGHS_THREADSwas ignored byrun_flextool. The--highs-threadsflag always sent 1, which replaced the environment variable, so HiGHS ran on one thread. An unset flag now lets the environment variable through; a given flag still wins. The flags of the other options with an environment variable twin work the same way (flag > environment variable > database > default). - Fixed: a solve with Benders decomposition ignored its
scalingsetting. Its region subproblems were scaled withfull, or with thescalingof the solve that ran before it in the chain. Each solve now uses its ownscaling;--scaling/FLEXTOOL_SCALINGstill override it. - Fixed: a second run in the same Python session (Spine Toolbox console, a script) took its solver option files
(
highs.opt,gurobi.opt, ...) from the previous run's work folder instead of fromsolver_config/orFLEXTOOL_SOLVER_CONFIG_DIR. A run no longer setsFLEXTOOL_SOLVER_CONFIG_DIR, andrun_flextoolno longer writes its flags into environment variables. - Changed: an out-of-range numeric flag is ignored with a warning, like its environment variable.
--solver-time-limit 0(or a negative value) used to reach HiGHS as a zero time limit, which stops the solve at once, whileFLEXTOOL_HIGHS_TIME_LIMIT=0was ignored. Both forms now warn and fall through to the next setting (no limit by default). The same holds for a negative--solver-mip-gapand for--highs-threads 0. - Docs:
--warm-startalso works without--save-memory. It reuses the HiGHS basis in-process too; the help and the documentation said it worked only with--save-memory. - New flags
--benders-workers Nand--basis-cache-dir PATHforrun_flextool, the command-line twins ofFLEXTOOL_BENDERS_WORKERSandFLEXTOOL_BASIS_CACHE_DIR. - GUI runs now use the same solver settings as terminal and Spine Toolbox runs (behaviour change, notice). The
GUI sends presolve and scaling only when you pick them in the Solver options dialog, whose new "Default" entries
send nothing: presolve is then on (before: the GUI always sent
choose), and scaling is each solve'sscaling(choosing "Full" now forcesfullover the solve's setting). The GUI no longer has a MIP-gap setting: each solve uses itssolver_mip_gap, elsemip_rel_gapinsolver_config/highs.opt(before: 0.001 for every solve). Results of MIP solves can change within the gap. A project saved by an earlier GUI is updated once when opened: a stored presolvechooseand scalingfullbecome "Default" (a stored presolveon/offis kept), and the GUI shows the change once.--solver-mip-gapandFLEXTOOL_HIGHS_MIP_GAPremain for terminal and Toolbox runs. - Removed
--matrix-file-formatandFLEXTOOL_MATRIX_FILE_FORMAT(and the GUI's "Matrix file format" setting). They had no effect: HiGHS is called in-process, or with an MPS file under--save-memory, and commercial solvers are always given an MPS file. A GUI project that stored the setting opens as before. - Removed
--precision-digits,FLEXTOOL_PRECISION_DIGITSandFLEXTOOL_REPORT_NEAR_DUPS. They never affected a normal run (only--regionpassed the rounding on), so results do not change. FlexTool does not round input values; the developer documentation said it rounded them to 10 significant figures by default, which was wrong. - The run header lists the options set by a flag or an environment variable, with where each came from, and the number of polars threads.
- New page "Environment variables" in the user guide: every variable users set, with its
run_flextoolflag twin and the order flag > environment variable > database > default. The developer guide keeps the diagnostic, profiling and experimental variables, now marked developer-only, and the help of everyrun_flextoolflag names its environment variable. - Processors with AVX2 keep polars' fast build when the compatible build is installed too. polars loads its
compatible build (
polars[rtcompat]) first whenever it is installed, so aflextool[compat]install ran about 10-30% slower on every processor.import flextoolnow selects polars' default build on a processor that has its features (POLARS_PREFER_PKG; a value you set is kept). On Linux a processor feature now counts only when both/proc/cpuinfoand the processor itself (cpuid) report it, so emulators are read correctly. - Fixed:
python -m flextool.env_checkcrashed on a processor without AVX2 when the compatible polars build was missing, before printing anything. It now prints the diagnostics and the pip command. - Windows Toolbox bundle: runs on processors without AVX2 and on Windows without the Visual C++ runtime. The bundle's Python now has both polars builds (FlexTool picks the right one at start). The libraries it loads carry their own C++ runtime, so no Visual C++ Redistributable is needed.
- Windows Toolbox bundle is smaller: no debugger (debugpy), no test suites of FlexTool or of the bundled packages, no C headers or import libraries, and no unused pyarrow, pywin32 and Qt parts. The Jupyter Console still works, without debugging.
-
highspy 1.15.1 or newer is required (was
>=1.14.0,<1.15; now>=1.15.1,<1.16). From 1.15 the Windows highspy carries its own C++ runtime; highspy 1.14.0 crashed on import on Windows 10 1809 / Server 2019. The automatic fix for a crashing highspy now installs this requirement instead of downgrading to 1.13.1. Two test scenarios with several equally cheap solutions now report another one of them (same total cost). If a library reports "DLL load failed" on Windows,python -m flextool.env_checkpoints to the Visual C++ Redistributable. -
Fixed: FlexTool on processors without AVX2 (for example Celeron/Pentium laptops up to about 2021, Intel Core from before 2013, AMD FX). polars' default build crashes on these. The automatic fix installed
polars-lts-cpu, whose last release (1.33.1) is older than the polars FlexTool needs, so the fixed install was still broken. FlexTool now installs the compatible build of the same polars version (polars[rtcompat], about 10-30% slower), which polars loads in preference to the default build. It reads the processor's features when FlexTool is imported, before polars can crash. On such a processor without the compatible build, it prints why (the missing CPU features) and the exact pip command; in a terminal it asks "Run it now? [y/N]" and installs only on yes, and without a terminal (a Spine Toolbox tool, a GUI-started run, CI) it stops with that message instead of installing anything. The GUI (flextool,flextool-gui) asks the same before it loads polars, so the default build cannot crash first: in its terminal, or, started from a shortcut, in a small yes/no dialog with the reason and the command and a progress window while it installs; on no it closes with the message. "Update FlexTool" (flextool-update) installs it; an install that haspolars-lts-cpuis moved topolars[rtcompat]. A processor without even SSE4.2/POPCNT (x86-64-v2, for example Core 2 or Phenom II), which no current build of FlexTool's libraries supports, now gets a message that says so. New install optionpip install "flextool[compat]";python -m flextool.env_checkreports the processor's features and the installed polars builds; setFLEXTOOL_NO_ENV_CHECK=1to skip the checks. The install pages now list the system requirements (64-bit Windows 10 or 11, Linux or macOS; an x86-64-v2 processor, AVX2 recommended). - Windows Toolbox bundle: Python 3.14, with FlexTool installed in the bundled Python. Before, the bundle's
Python (3.12) had only pandas and spinedb-api, so FlexTool 4 tools could not run in it (polars, highspy and
polar-high were installed into the build machine's Python instead). The bundle build now also runs an import
check, a solve of the
baseexample and a headless start of Spine Toolbox. - Python 3.14 is supported and tested (CI matrix now 3.13 and 3.14 on Linux, Windows and macOS; 3.11 is still checked by the nightly CI as the minimum version).
- Fixed: results changed slightly from run to run with polars 2.x (row order of output files, last digits of
discount factors, costs and duals). polars 2.x runs some queries on its streaming engine, which does not keep
row order and adds partial sums in a varying order. FlexTool now selects polars' in-memory engine (the default
of polars 1.x) when it is imported; a
POLARS_ENGINE_AFFINITYset in the environment is respected. - Fixed: inflow scaling silently dropped demand or turned it into supply (models that now stop with an error
ran with a wrong demand before).
inflow_methodscale_to_annual_flow,scale_in_proportionandscale_to_annual_and_peak_flownow stop with an error naming the node, method and period when the period has noannual_flow(or a negative one), whenscale_to_annual_and_peak_flowhas no (or a zero)peak_inflow, when the node has noinflowprofile (or one that sums to 0) to scale, and whenpeak_inflowhas the opposite sign of the inflow. Before, the node's inflow silently became 0 in such a period, andscale_to_annual_and_peak_flowwithout aninflowprofile gave a constant supply ofannual_flow / 8760(for example +1027 MW for 9 TWh of demand). An explicitannual_flowof 0 still gives no inflow in that period. A warning (once per node) says whenannual_flowis above|peak_inflow| x 8760, or when the peak scaling makes the inflow change direction at some time steps (annual_flowtoo small for the peak and the profile). The example database'sevandscale_to_peak_flowalternatives and the demand how-to'sscale_to_proportionscenario lackedannual_flow: the EV battery now uses its inflow as given (use_original; before, its demand was 0),scale_to_peak_flowgetsannual_flow9 TWh (before, the demand ofwestwas 0) andscale_to_proportionincludes thescale_to_annualalternative. - Fixed: reserve provision results in MW (
process__reserve__upDown__node__dt,..._d_average, SpineDBreservation_t/reservation_average). The reserve a unit or connection holds is now reported in MW in each time step. Before, it was in units of the process's unit size (for example 0.02 instead of 20 MW for a 1000 MW unit) and, per time step, also multiplied by the step duration, so it was wrong for any unit size other than 1 MW and for steps other than one hour. - Fixed: the summary's total cost for realized periods (
summary_solve.csv). The line "Total cost (calculated) realized periods" now sums only the periods a solve realizes (each once, discounted from the start of the run); before, it repeated the full-horizon total, which also holds the fixed cost of existing capacity in the look-ahead periods of the last solve. - Fixed: the per-period discounted costs were not written to CSV (output file names change). The per-period
table of discounted costs is now
costs_discounted__d.csv(Excel sheetcosts_discounted__d); before, it and the horizon total shared the namecosts_discounted, so the CSV writer kept only the horizon total and the Excel sheets werecosts_discounted(per period) andcosts_discounted_1(total).costs_discounted.csv/ the sheetcosts_discountedis the horizon total by cost category, as before in CSV. The VRE share of a node group isgroup_node_VRE_share__dper period andgroup_node_VRE_share__dtper time step (before, the per-period table carried the__dtname and the per-step table none). The CSV writer now stops with an error naming both outputs when two outputs would be written to the same file (also when the names differ only by case). - Fixed: investment costs inside a stochastic fan (results change for stochastic solves that invest with a
construction_lead_timeor withstochastic_invest_method = recourse). The investment cost and the fixed cost of the new capacity that operates in a fan's branch periods are reported, weighted by each branch's probability weight, in the fan's period where the capacity starts to operate, also without a realization solve. A shared investment is reported at its full amount (its branch weights sum to 1); underrecourseeach forecast's own investment is reported times its branch weight, so the reported investment costs equal the investment part of the objective. Before, an investment whose capacity starts to operate in a later period of a fan without a realization solve (a construction lead time) was reported nowhere, and underrecourseno investment cost was reported at all. - A warning when reported costs are rebuilt without the run-level cost data (a CSV-dump reload): such costs
are discounted from the start of each solve, not from the start of the run, and the annualized investment,
retirement and fixed costs of new and retired capacity are 0. The documentation now also says that the
investment duals (
invest_marginal) stay discounted from the start of each solve, like the objective. - Fixed: retiring existing capacity before its end of life (results change for models that retire
capacity). Only capacity that existed before the model run (
existing) can be retired, and a retirement now counts until that capacity's end of life: forreinvest_choice/no_investmentthe first year of the run pluslifetime, forreinvest_automaticthe end of the model horizon. Before, a retirement counted in every later period, so past the end of life the retired capacity was subtracted a second time: the capacity went negative and a required retirement made the model infeasible (an optional one never happened); later solves of a chain subtracted earlier retirements from every period, and could be infeasible too. The capacity retired cannot exceed the existing capacity of any period it counts in. The retirement saves exactly the fixed cost the retired capacity would have paid in those periods (each period's fixed cost, discounted as the fixed cost of existing capacity; before, the retirement period's fixed cost with the investment discount, and over the lifetime from the retirement). Its salvage value is unchanged: an annuity overlifetimefrom the retirement period, counted inside the horizon as an investment's annuity. An entity with a retire option but no existing capacity has no retirement variable (a warning names it, once per run); a required retirement (retire_min_period,retire_min_total) that the existing capacity cannot supply is an error naming the entity and the period. An upgraded database prints a notice naming the affected entities. - Fixed: the end of life of existing capacity in a chain of solves and with stochastic branches in the first
period (results change for
reinvest_choice/no_investmentexisting capacity). It is the first year of the model run pluslifetimein every solve. Before, a later solve of a chain counted the lifetime from its own first period, so the existing capacity lived longer there and its reported fixed cost continued, and stochastic branches in the first period multiplied the lifetime by the number of branches (covered by the same notice). - Fixed: in a stochastic solve without recourse, capacity invested in a later period reaches the stochastic
branches of that period (results change for stochastic solves whose
stochastic_invest_methodis notrecourseand that invest or have areinvest_choice/no_investmentlifetime). The branch periods of the stochastic branches now count from the year of their period. Before, every branch period counted as the first year of the solve, so an investment decided in a later period, or one that starts to operate in a later period after aconstruction_lead_time, was not available in that period's branches (the model ran on penalties instead), and lifetimes ended late in the branches. An upgraded database prints a notice naming the entities. - Fixed: the annualized investment, retirement and fixed costs of new and retired capacity are yearly amounts in
every period the capacity operates (
cost_annualizedof the model, units, connections and nodes; results change for models that invest or retire). The annualized investment & retirement, fixed cost invested and fixed cost reduction of divestments of a period are now the yearly annuity and fixed cost of every vintage operating in the period (and of every retirement that applies in it). Before, they were the discounted cost of the whole lifetime divided by the discount factor of the period where it was reported, so they appeared in that one period only and were many times too large there. - A run of several solves reports its costs from the start of the run, each once (results change for models
with several solves in
model.solves: chains of investment solves, investment and dispatch solves, stochastic and realization solves). The discounted costs of every solve now count their years from the first period of the run (before: from the first period of the solve, so a later solve's costs were too large by the inflation of the years before it). The investment and fixed cost of new capacity cover the years it operates in the whole run (before: only the deciding solve's periods) and are reported once, in the period the capacity starts to operate, by the solve that realizes that period; capacity carried from an earlier solve and retired capacity are no longer charged again as existing capacity. The fixed cost of existing capacity is also reported in the last solve's look-ahead periods, which the fixed cost of new capacity covers. A single solve reports as before, except that a lump of new capacity with aconstruction_lead_timeis now in the period it starts operating and the "Investment discount factor" of the solve summary is now the investment factor (before: the operations factor). The printed objectives and the CO2 prices stay those of each solve. An upgraded database prints a notice listing the solves of such a run. - Fixed: the ramp of a unit with online variables at a stochastic branch point (results change for stochastic solves with mid-horizon branches and units with online variables and a downward ramp limit). Each branch's first step now reads the shut-down share of the trunk's last step once; before, it was multiplied by the number of branches squared (1.04 instead of 0.26 with two branches), which let units shut down at the branch point drop their flow faster than the ramp limit allows. An upgraded database prints a notice naming the units.
- Units and connections between coarse nodes use the coarse step (results can change for models with a
groupnew_stepduration; before, every flow, unit commitment and reserve stayed at the solve's step). A unit or connection whose balance nodes are all in coarse groups now decides once per coarse step, so the model gets smaller (the three-region hydrogen test model: 7 instead of 168 flow variables per liquefier and pipe arc in a week). Requires polar-high 3.12.1. - Flows: one flow per coarse step, a rate that stays the same over the step. Its capacity, availability, profile, efficiency and input-share limits apply to their average over the coarse step, and ramp limits apply between coarse steps. The result is the same as with flows at the solve's step when each flow has at most one limit that varies within the step and its cost, efficiency and prices do not vary within the step; otherwise the result can differ (hourly flows could use the cheapest or most efficient hours, and several varying limits are looser when averaged).
- Units with online variables (
startup_methodlinear or integer): units online, start-ups and shut-downs are decided once per coarse step (a unit starts or stops at the start of the step); minimum load and online capacity apply to the average over the step; a minimum up or down time counts whole coarse steps (a start-up counts while the steps since its start last less thanmin_uptime, so a 30-h minimum on daily steps holds the unit for two days, and one of at most a day adds nothing). With ramp limits, the start-up and shut-down capacity reduction uses the coarse step: a unit started at the start of a step is limited to its mean output over the whole step while ramping up from minimum load (at full load for the rest of the step once the ramp reaches it), likewise before a shut-down, and its units online ramp between coarse steps. - Reserves: nodes of a coarse group may now be in reserve groups (before, such a model stopped with
"participates in reserves but is a member of resolution-group"). A provider holds one reserve per coarse step;
the reserve requirement is still checked at every time step of the solve. Its reserve limits (the share of its
capacity, a connection's net position, the floor of its flow, the storage energy for
reserve_durationwhen the storage node and every provider are coarse) apply to the average over the step, with the average availability or profile (a wind unit offers reserve against its mean capacity factor over the step), and the room its ramp limit leaves for reserve is the smallest of the step's time steps (each with its own capacity factor). - Exact connections and DC power flow: an
exactconnection chooses its direction once per coarse step, its direction limits applying to the average over the step. A DC line has one flow per coarse step; the nodes of a DC network whose every line uses the coarse step (and whose nodes all have the same coarse step) have one voltage angle per coarse step. In a DC network with a line at the solve's step every angle stays at the solve's step, so that line keeps its per-step physics. - Rows that combine several units and connections: a capacity-margin, inertia or non-synchronous row of a group, a flow group's instant-flow limit, a user constraint with flow or storage terms, the DC power-flow row of a line and the stochastic storage-use tie of a node are one row per coarse step, the average of the row over the step, when every unit and connection in the row uses the coarse step; the inertia and non-synchronous shortfalls are then one value per coarse step. Such a row uses the step mean of the demand, inflow, profile, limit or constant in it: an averaged capacity margin covers the mean net demand of the step, not its peak hour. A row whose limit is given on only part of a coarse step stays at the time steps it covers. A row that mixes units or connections on the coarse step with others on the solve's step stays at every time step and sees the coarse flows constant through their step (a coarse DC line in a network with a line at the solve's step holds the angle difference across it constant through the step; the angles themselves stay per time step).
- What keeps the solve's step: units and connections with a node outside the coarse groups, and those with a delay; flows that cross Benders regions or live in the Benders master; in a stochastic solve, a unit or connection at a stochastic node, with online variables or providing reserves, whose coarse step is tied between the forecasts over only part of it; in a realization solve, one whose committed units online or reserve change within a coarse step or are pinned on only part of it. An info line lists the units and connections that use a coarse step and those kept at the solve's step with the reason.
- Outputs: flows, units online, reserves, voltage angles and the shortfalls of averaged rows are written at every time step of the solve, the same value through each coarse step; each start-up and shut-down is written whole at the first time step of its coarse step (0 at the others), so start-up totals and costs are the model's.
- An upgraded database prints a notice naming each coarse unit or connection whose results can differ from flows at the solve's step, and why (a cost, price, efficiency or minimum load varying within the step, two or more varying limits, ramp limits, online variables, minimum times, the coarse start-up reduction, reserves and their step-mean headroom, a mixed row, or an averaged row whose demand, inflow, profile or limit varies within the step).
- A Benders master that HiGHS left with an undetermined status after its cold retry is solved once more from its reloaded LP (polar-high 3.12.1), instead of stopping with "Benders master problem did not solve to optimality". This happened on badly scaled masters (an unscaled objective with cut slopes near 1e9).
- The shipped how-to and example databases now carry the documented parameter defaults and value lists (for
example
lifetime_method,startup_methodandstorage_binding_methoddefaults, and thenonemembers of the profile and ramp method lists), the same as a new database. Their data and results are unchanged. - Construction lead time: "invested" is the capacity that comes online (results change for models with a
construction_lead_time). The capacity results (unit_capacity__d,connection_capacity__d,node_capacity__d) report asinvestedthe capacity that comes online in the period, and a new columnin_constructionthe capacity decided but not yet operating; the column is always written and is empty without a lead time, and capacity plots give it its own bar colour. Over combined solves each realized investment is counted once, in the period it comes online. The per-period investment limits (invest_max_period/invest_min_period, also of groups) andconstraint_invested_capacity_coeffcount capacity coming online in the period; the newconstraint_in_construction_capacity_coeffputs the capacity in construction in a user constraint, and capacity in service = pre-built + invested − retired now holds with a lead time too. The annuity and fixed cost of new capacity are paid only while it operates, at theinvest_cost(an overnight cost), lifetime and interest rate of the period it comes online; the lifetime starts then. The start of operation is rounded on the periods of the whole model, so a chain of solves and a single solve agree. Investments that would start to operate after the end of the solve or of the model horizon are not offered, with a warning naming the entity, the lead time and the solve; a chain whose solves are not longer than the lead time therefore does not invest in that technology (use a longer solve horizon orconstruction_lead_time_methodimmediate). A minimum per-period limit that nothing can meet (nothing can come online in the period) is an error naming the entity, the period and the lead time. An upgraded database with a lead time prints a notice naming the entities. Without a lead time, results are unchanged apart from the new empty column. A lead time in a solve whoseinvest_periodsis a map (one investment sub-solve per period) stops with an error that explains why (capacity with a lead time could never come online inside a one-period sub-solve) and names the solve, the entities and their lead times; use one investment solve over several periods instead. - Dispatch and other time-series plots again leave a gap at timeline breaks: after a gap in the timeline, at
the start of every representative period (also between two adjacent representative periods) and at the first
step of a period that does not continue the previous one (also a later year that reuses the same timeset).
Since version 4 no breaks were found, so every plot drew a straight line across them. Rolling windows that
follow each other on the timeline stay one line. A break now applies to its own period only (before, a break
time cut every period with a step of that name). The breaks are written to
output_parquet/<scenario>/timeline_breaks.parquet(columnsperiod,time); results saved with an earlier form of this file are drawn without gaps until the scenario is run again. - Output files come out in the same row and column order on every run. The rows of some set outputs (for
example
edd_invest,period_in_useandd_realize_invest) and the entity columns of some per-entity outputs could change order from one run to the next; a rerun of the same model now writes identical files. The values are unchanged, apart from the last digit of some sums whose terms were added in a varying order. - A map whose index depth or level names do not fit its parameter is now an error naming the entity, the
parameter and what the parameter takes (for example a 2-level
commodity.price, or a ladder tier level namedperiod); before, such values were dropped without a message. Maps without index names at different depths within one parameter are read per depth: a one-level map keyed by time steps next to a period x time map is now read over time (before, its values were lost; results change, and an upgraded database prints a notice naming the entities). A one-line note says when a map's shape was read from its depth or keys; name the index levels to make it explicit. - Price-ladder tier labels are kept as written. Tiers such as
01oraare now their own tiers. Before, a label was read as a whole number: tiers01,02, ... were bought at no cost,1and01were merged, and tiers likeawere dropped (results change; an upgraded database whose ladder has such labels prints a notice naming the commodities). A tier with aquantitybut nopriceis now an error naming the commodity, period and tier (before: price 0). A tier without aquantitystays unlimited. - A unit whose
min_loadmap has no value above 0 in a solve's periods no longer stops the solve ("feature 'min_load' is active but ...p_min_load"), and it gets no minimum-load constraint rows in that solve. A reserve whosereservationadds up to 0 over a solve's time steps (for example a period map with values only for other periods) gets no reserve variables in that solve, as on the CSV path. Results of solves that ran before are unchanged. - A solve without
period_timesetthat uses the model's only timeset now gets the same periods everywhere. Aprice_ladder_annualgiven without periods now applies in every period of such a solve when the model has noperiods_availableeither; before, it was skipped with a warning (results change; an upgraded database prints a notice naming the commodities). A multi-period stochastic solve withoutperiod_timesetnow has the forecast-branch period names of its later periods too. - A model with no timeset now runs on the whole timeline instead of stopping with "More than one timeset
available". With one timeline, FlexTool creates the timeset
full_timelinecovering it and uses it for every period of the solve, as documented. With several timelines and no timeset the error now says that no timeset is available. --csv-dumpshows period + time maps of nodeavailability,penalty_up/penalty_downandself_discharge_loss. The dumpedsolve_data/pdtNode.csvlisted the default value for such a map instead of the authored one. Only the debug dump was wrong: the model already used the authored values, so results are unchanged.- Lower peak memory in large in-process HiGHS solves. The LP's column and row names are no longer copied into
HiGHS (a few GB on large models); they are passed only when a HiGHS option writes a file that carries them
(
write_solution_to_file,write_model_to_file,write_basis_file, …), so such files keep the real names. Autoscale's coefficient bucketing keeps its per-column tables as arrays. Results and outputs are unchanged. Requires polar-high 3.12.0. - The default
storage_start_end_methodis nowfix_nothing. Storage nodes without their own method start free. Before, where some node had astorage_state_start, the old defaultfix_startfixed them at the start: at their ownstorage_state_start, or at 0 when only other nodes had one. The database upgrade writesfix_starton the nodes that have their ownstorage_state_start, so their results are unchanged (a notice names them). The other nodes now start free (results change; a notice names them). Where no node had astorage_state_start, the old default fixed nothing and results are unchanged. fix_start/fix_end/fix_start_endwithoutstorage_state_start/storage_state_endis now an error naming the nodes (before: the start or end was silently not fixed, or fixed at 0). The database upgrade rewrites such nodes where it can tell what they did (fix_nothing, or a start / end value of 0) without changing results, and lists them; the error remains only where scenarios used the node differently.- Behaviour-change notices. When a release changes what an existing model does, the database upgrade marks the
database (metadata
flextool_behaviour_change:<change>, in the database editor's Metadata tab) and runs print a notice naming the version, its publication date, what changed and the affected entities, once per run. Deleting the marker stops the notice; new databases have none. -
dispatch_realization_method = separate_dispatchwithout arealized: yesbranch is now an error naming the solve (before: it was accepted although there was no branch to realize). -
Database version 77: the value types of the database match what FlexTool reads. The investment and retirement limits per period of units, nodes, connections and groups,
co2_max_period,startup_costand the user-constraintconstantcan now be a constant or a period map;efficiency,min_loadandefficiency_at_min_loadof a unit,efficiencyof a connection,profile,self_discharge_lossandenergy_margin_adderalso take period + time maps. Ten parameters that had no allowed value type (for examplepenalty_method,scaling,shared_operation_periods) now have one, so the database editor offers them. The upgrade only adds types; stored values are unchanged. Parameter descriptions were rewritten as short statements of what each parameter does. - A user-constraint
constantcan vary by period. A period map gives each period its own right-hand side; it must cover every period of the solve (a missing period is an error naming the constraint). Constraints with a constant value are unchanged. - Groups have their own
invest_methodvalues:not_allowedis nownot_active(the default: the group sets no investment or retirement limits). The upgrade renames stored group values; a group valuenot_allowedin a database that was not upgraded stops the run with an error. Unit, node and connection values are unchanged. Results unchanged. - Group retirement limits in the database.
retire_max_period,retire_min_period,retire_max_totalandretire_min_totalof a group (with a retire option in itsinvest_method) were read by FlexTool but missing from the database; they are now there. - New user-constraint parameters
constraint_capacity_coeffandconstraint_retired_capacity_coeffon units, nodes and connections.constraint_capacity_coeffputs the capacity in service in each period (existing plus investments still in service minus retirements, the capacity that limits the flows) on the left side of a user constraint;constraint_retired_capacity_coeffputs the capacity retired in each period there. Shares of the fleet, ratios between technologies and replacement rules can now account for retirements. - The pre-built capacity term of user constraints subtracts earlier retirements (results change).
constraint_cumulative_pre_built_capacity_coeffis now the capacity in service at the start of each period: existing capacity plus earlier investments still in service minus the retirements of earlier periods. Before, retirements decided earlier in the same solve were ignored while those of earlier solves of a chain were subtracted, so one solve and a chain of solves of the same problem gave different results. Results change only for models that retire capacity of an entity with this coefficient; upgraded databases print a notice naming the constraints. Capacity in service = pre-built + invested − retired, also with a construction lead time (see the construction lead time entry above). - User constraints with only capacity terms are written once per period instead of once per time step (the
rows were identical). In the
output_horizonraw dump these rows areprocess_constraint_{equal,less_than,greater_than}_periodwith dimensions (constraint, period). - A user constraint with no term left in a solve gets no row. This happens when its coefficients only refer
to flows, storage states, investments or retirements that the solve does not have. When the remaining row cannot
hold (for example 0 >= 5, or one period of a period-map
constant), the run stops with an error naming the constraint, the solve and the period. Before, the solve was infeasible without naming the constraint. When the row always holds (for example 0 >= 0), a warning names the constraint once per run. - Fix: a user-constraint pre-built coefficient now gives a zero-cost entity its investment variable (results
change only for such entities). An entity without investment cost that a user constraint linked only through
constraint_cumulative_pre_built_capacity_coeffgot no investment variable, because the link was looked up under a wrong parameter name. The new capacity and retired-capacity coefficients link the investment and retirement variables the same way. - The upgrade drops parameters FlexTool no longer has (
discount_years,max_relative_capacity,storage_include_solve_fix_method, unit inputcapacity_max_coeff/capacity_min_coeff) and lists any values they held; an inputcapacity_max_coeffvalue moves toinput_share_max(its name since database version 70) where that input has none of its own.solve_modesingle_shotbecomessingle_solve. Results unchanged. - Fix: DC power-flow lines are always the connections inside
dc_power_flow_with_anglesgroups (results change for DC power-flow models that also have anis_DCconnection; a notice names the groups). Such a model used itsis_DCconnections as the DC power-flow lines: the group's lines got no angle constraints and the reference node could change. Anis_DCconnection inside such a group is not a line; it keeps its owntransfer_method. Astartup_methodon anis_DCconnection no longer raises the DC power-flow error, and the "DC power flow per connection" output of a model without DC power flow is now empty (it listed theis_DCconnections; solved values unchanged). - Fix: a node retirement or an investment without its period map stops with an error naming the missing data (before: the retirement or investment was left out of the capacity without a word). Results unchanged.
- Warning when
existingis not a whole number of units for a unit or connection with integer online variables (startup_method = binary) andvirtual_unitsize. Withvirtual_unitsize,existingis the number of units; integer online variables commit whole units, so a count such as 2.5 cannot all be online. The warning names the entity, the count and the unit size. Results unchanged. - Docs: with
virtual_unitsize,existingis the number of units (as in FlexTool 3 and every 4.x release); the 4.x reference example said MW. Results unchanged. - Fixed: net-load representative-period clustering reads
existingas the number of units withvirtual_unitsize(results change: the selected representative periods can differ for a VRE unit withvirtual_unitsizeother than 1 and a non-zeroexisting, when the representative periods are selected again). The existing VRE capacity is nowexisting×virtual_unitsize, as in the model; before, the clustering usedexistingas MW. The tool prints a note naming such units. Stored selections are not changed. - Fixed: the
--regionexport gave a cross-region connection withvirtual_unitsizeonlyexistingMW. The virtual half-flow connection of the region's input now carriesexisting×virtual_unitsize. Solve results (including the Benders decomposition) are unchanged. - Fixed: the automatic DC power-flow reference node reads a node's
existingas the number of units withvirtual_unitsize. The reference is the node with the largest existing capacity (existing×virtual_unitsize); before,existingwas compared as is, so a node withvirtual_unitsizecould lose to a smaller one. Results change only where that picks another reference node. - Docs: a lossy connection's capacity limits the flow it delivers. The how-to example on the regular transfer method said both nodes can send 500 but receive 400 (capacity 500, efficiency 0.8); as the model has always done, they receive 500 and send 625. Results unchanged.
- Docs:
constraint_invested_capacity_coeffis a coefficient per MW (MWh for storage). Its description said "Not multiplied by unitsize"; FlexTool has always applied it to the invested capacity in MW (MWh). - Docs:
increase_reserve_ratioapplies to upward and downward reserve alike. The ratio stands for the variability of a flow, which can go either way, so each participating flow raises both requirements (unlikelarge_failure_ratio, which is directional). The reference and the parameter descriptions now say so; the model is unchanged. The reference also lists the database version 77 parameter shapes, the group retirement limits and the groupinvest_methodvaluenot_active. -
Fix: Excel export keeps period + time maps of parameters without stochastic values. Such a value (for example a period + time
energy_margin_adder) was dropped when a model was exported to xlsx; it is now written on its own multi-index sheet and read back on import. Behaviour-change markers of a database (its metadata) are now also written to the workbook and restored on import. -
Fix: memory is returned to the system when polars was imported before flextool. FlexTool sets the memory allocator of polars to give freed memory back to the operating system about a second after it goes idle, but the setting only took effect when flextool was imported first. In a Spine Toolbox console where an earlier tool had imported polars, in a notebook or in a script that imported polars first, freed memory stayed with the process and peak memory on a large model could be about twice as high. The setting is now also applied when polars is already loaded, and memory freed before flextool was imported is returned as well. Where it cannot be applied (for example on macOS), one warning names the remedy: import flextool before polars, or start Python with
_RJEM_MALLOC_CONF=dirty_decay_ms:1000,muzzy_decay_ms:0. -
Lower peak memory when building the non-synchronous limit of a group. The constraint no longer combines the unit size of every process with every time step before picking the processes that feed the group; on a large model this removes several GB from the peak of the scaling step. The model is unchanged.
-
Fix: the online commitment no longer links across a gap in the timeline. The units online at the first step after a gap were compared with the units online at the step before the gap, so a start-up (or a free shut-down) was counted across the gap. The first step of a solve or of a period that does not continue the previous one wrapped to the period's last step when that step name existed in the solve, and otherwise started from zero units online (a start-up charged at the first step of later periods). Now every step without an adjacent predecessor — the first step of a solve or such a period, the step after a gap, the start of a representative period — takes the units online at the last step of its own stretch of the period (the steps that follow each other up to the next gap), as a solve with one block always did. The commitment thus cycles within each block and never reaches across a gap, a representative-period start or into another period, the same as ramp limits and the minimum up / down time look-back. In a stochastic solve that branches mid-horizon, the steps before the reveal cycle among themselves.
- Fix: the units online carried into the next roll stop at a gap, a new year and a representative-period start. A rolling or nested solve took over the units online realized at the end of the previous roll at its first step whatever came between them: across a gap in the timeline, into a new year that reuses the same timeset, at a representative-period start, and, when no step matched, on the first step of the solve anyway. The commitment is now carried only to the steps from which ramp limits and the minimum up / down time continue (the steps that continue the previous roll's last realized step on the timeline, including a continuing period, every forecast branch starting there and a solve with longer time steps). Elsewhere the first step cycles within its own stretch of the period, as within a solve. A carried commitment without the step it was realized at now stops with an error instead of being placed on the first step.
- Fix: a solve with longer time steps continues the finer dispatch realized before it. In nested and rolling
solves, the ramp limit and the minimum up / down time carry of a solve's first step were taken over from the
previous solve only when the step before it on its own timeline was the very step realized last. A solve with
longer steps than the dispatch realized before it (for example a 6-hour storage roll after an hourly dispatch)
never matched, so its first step had no ramp limit from the realized flow, the start-ups realized just before
it did not count towards
min_uptime/min_downtime, and a warning said the look-back was cut short. The two steps are now matched by where they end in time on the underlying timeline: when the step before the first step ends exactly where the realized step ends, the solve continues it (the ramp uses the realized step's own duration). A hole or an overlap between the two still gives no carry and the warning. - Fix: the minimum up / down time obligation carried into the next roll follows the timeline. The start-ups and shut-downs realized near the end of a roll were carried into the next roll only within a period of the same name, matched by sorting step names. The forecast branches of a stochastic roll therefore never took the obligation (the history had the period's name, not the branch's), and neither did a period that continues the previous one on the same timeline. The carry now starts at the first steps that continue the previous roll's last realized step on the timeline — the same steps from which ramp limits continue — and counts the hours from each realized start-up or shut-down to them, so every forecast branch and a continuing period take it; a period that does not continue the previous one, a step after a gap and a representative-period start still take none.
-
Fix: minimum up and down times only look back along the timeline. The look-back of
min_uptime/min_downtimewalked the time steps in the order of their names, so it reached from a forecast branch into the last hours of a sibling branch, from the first hours of an investment year into the end of an earlier year that reuses the same timeset, and across gaps in the timeline and the starts of representative periods. It now follows the same adjacent-time links as ramp limits: it continues into a period whose time steps continue the previous period's on the same timeline and into the steps of a forecast branch before the reveal, and stops at a gap, at the start of a representative period, at a period that does not continue the previous one and at the first step of a solve (no cyclic wrap). A rolling or nested solve whose first step continues the timeline of an earlier solve, but not the last step realized before it (so no realized on-line history reaches it), now prints a warning naming the process. -
Fix:
co2_max_totalin rolling solves and chains of solves no longer allows more than the limit. Each roll or solve subtracted the emissions earlier rolls and solves had already realized as plain tonnes, while the limit counts emissions annualized and timesyears_represented. The history was therefore under-counted byyears_represented/ period share (52 times for a one-week period that represents a year), and a rolling or chained run could emit several times its limit. The realized emissions are now counted the same way as in the limit, so the realized emissions of the whole run stay within it. The per-forecast-pathco2_max_totalconstraints of rolling stochastic solves (below) use the same corrected budget. -
A recourse stochastic fan nested inside another solve now stops with an error. A solve with
stochastic_invest_method = recoursethat is nested (contains_solves) in a solve sharing exactly one of its periods ran without the ties that keep its forecast investments non-anticipative. It now stops with a configuration error naming the sub-solve; make it a top-level solve. -
A committed window in a forecasts-only fan no longer stops a run over a cap on another period. The check that stops a solve committing steps inside a fan nobody realizes named the annual commodity-ladder tiers and
co2_max_periodcaps of every period; it now only counts caps on the fan's own period. -
Fix: in a rolling stochastic solve each forecast keeps its own cumulative limits. The rolls of a rolling solve were built without their scenario paths, so the CO2 total cap (
co2_max_total), the cumulative commodity-ladder tier cap and the flow-group cumulative flow limits (max_cumulative_flow/min_cumulative_flow) pooled all forecasts of a roll into one constraint: the forecasts shared one budget (the ladder tier allowed the forecasts' hours added together, the CO2 cap counted every forecast's emissions against one budget). Each forecast now gets its own constraint, as in a single stochastic solve. The cumulative tier cap of a scenario path also counts the periods that earlier solves or rolls already finished, so a fan in a later period no longer gets a too-tight (or negative) budget. Deterministic rolls and realization rolls are unchanged. -
Fix: in a stochastic fan the capacity-margin penalty is weighted by the forecast's probability. Each forecast branch period of an investment period keeps its own capacity-margin shortfall, but its penalty was counted in full, so a fan of forecasts paid the shortfall as many times as it had branches regardless of their weights. Each forecast's penalty is now multiplied by its branch weight, as the operational costs already were. Deterministic solves are unchanged.
-
Fix: in a rolling stochastic solve, a forecast's period budgets count the hours already realized. The annual commodity-ladder tiers and
co2_max_periodallot each roll the share of the period reached by the end of its horizon. A forecast branch counted only its own window, not the hours the earlier realization rolls had already realized, so its tier budget shrank as the week advanced. After the realized purchases were subtracted it could turn negative and make the roll infeasible. Each forecast now counts its period's realized hours plus its own window. Theco2_max_totalshare of a fan counts those hours once, and so does the fallback weight of the cumulative ladder. A forecast'sco2_max_periodcap also subtracts the emissions already realized in its period; before, every forecast could emit them again. -
Normal outputs hold realized results only, with or without
output_horizon.output_horizon = yesno longer changes the parquet / CSV / plot / summary outputs: forecast-branch rows, rolling look-ahead rows and per-forecast (recourse) investment rows are gone from them, and the "realized periods" / "full horizon" totals no longer include forecast-branch costs. Those rows are only in the raw dump underhorizon/. A stochastic fan with no realization solve (forecasts only) reports its investment decisions only: without the option it also used to report the forecast branches' costs, prices and capacities. CO2 in the summary is one total for the realized periods. -
Fix: node and reserve prices of a solve that realizes no time step are no longer reported. In a nested run, the investment and storage solves above the dispatch solve (and a forecasts-only stochastic fan) realize no dispatch step, yet their node prices reached
node_prices_dt_enext to the dispatch prices, and the per-step price plots could show the parent's price for a step. Prices now come only from the solves that realize the step. -
Fix:
output_horizon = yesno longer changes the next solve. In a solve followed by another solve (rolling, realization dispatch, multi-solve), the CO2 emitted in unrealized steps, and the unrealized hours and commodity use, were carried into the next solve's CO2 limits (co2_max_period,co2_max_total), cumulative-hour totals and commodity ladders. Only realized steps are carried now, as without the option. -
New: raw LP dump with
output_horizon = yes. The debug option now also writesoutput_parquet/<scenario>/horizon/: one raw parquet per LP variable and constraint holding all rows of every solve (forecast branches and rolling look-ahead steps included), appended over the solves and rolls, withsolve,rollandrealizedcolumns, raw primal values, reduced costs and duals. Not available for solves decomposed with Benders. -
timeset_weightsnow reach the forecast branches of a stochastic solve, and a map indexed bytimeis no longer ignored. The forecast branch copies of a period (single, recourse and rolling stochastic solves) used weight 1 on every time step instead of the weights of their period's timeset; they now take that timeset's weights, so the objective, the energy and cost outputs and the prices weigh the branch steps like the deterministic period. Atimeset_weightsmap whoseindex_nameistime(ort) was read as no weights at all in a single solve; it now gives the same weights as a map without an index name. Atimeset_weightsvalue that is not a timestep map (a scalar, or a map indexed byperiod) is now a configuration error instead of being ignored. Objectives of affected solves change. -
Units online are no longer tied across the forecast branches when no step is committed. With
shared_operation_periods = [], or in a rolling solve whose fan lies only in the look-ahead, the units online were still held equal in every forecast over every time step of the fan, although the other operations were free. They are now tied only over the committed (tied) steps, like storage use and reserves, so each forecast can commit units on its own there. Objectives of such solves can be lower. -
Under
stochastic_invest_method = none, investment cost in a stochastic fan is no longer counted several times. The investment annuity, the salvage value of retirements and the lifetime fixed cost of capacity built before or at the reveal were summed over the period's original name and again over each forecast branch copy of the same calendar years, so with two forecasts the fan periods were charged three times. Each calendar period the capacity serves is now counted once, as underrecourse. Objectives and reported investment costs of such solves are lower; the chosen investment can change (it was biased low). -
A
realized: yesbranch in a stochastic solve'sstochastic_branchesnow makes FlexTool run a realization dispatch right after the stochastic solve, without settingdispatch_realization_method = separate_dispatch. It applies when the designated branch opens a fan that reaches the periods the solve realizes (a single or rolling solve withstochastic_invest_method = none). The realization dispatch is deterministic, fixes the stochastic solve's investments, reads the designated branch's data, and inherits the units online and the reserves the stochastic solve committed before the forecast is known (the value of the first forecast branch in sorted order over the tied steps; reserves were not inherited before). The stochastic solve keeps those ties over the window it hands to the realization dispatch. A deterministic solve placed right after the stochastic solve inmodel.solves, with the same solve mode and rolling settings and realizing the same fan periods, is used as the realization dispatch instead. A designated solve that contains other solves or is nested in another solve is a configuration error; understochastic_invest_method = recoursea warning says that no realization dispatch is added. When the inherited units online cannot cover the realized net load of a node withpenalty_method = off, the run stops before that solve with an error naming the node and the time step. -
Under
stochastic_invest_method = recourse,cumulative_max_capacity/cumulative_min_capacitynow apply to every forecast branch. The cumulative-capacity limit of acumulative_limitsentity sat on the period's original name, which has no investment under recourse: acumulative_min_capacitymade the solve infeasible, and neither limit constrained the forecast branches' investment. -
A stochastic fan now has no dispatch of its own under the period's original name. The period that opens a fan (and every later period of it) keeps its name only for period-level bookkeeping: its investment (under
stochastic_invest_method = none, seen by every forecast), years and discounting. Its operational results (flows, storage, units online, reserves, prices, costs per time step) are empty, not 0: those decisions exist only per forecast branch, and the realized operation comes from a realization dispatch (see therealized: yesentry above). Before, the original period name carried a weight-0 copy of the dispatch: an arbitrary feasible point that was reported, carried to the next roll and, without a designated branch, filled with a guessed scenario. Forecast branches stay visible throughmodel.output_horizon. Understochastic_invest_method = recoursethe original period name has no investment either: every forecast branch invests on its own, and the shared-investment ties (shared_invest_periods) now join the forecasts to each other. A forecast branch's retirement now reduces that branch's capacity (before, a branch retirement was paid but changed no capacity), and every forecast of a fan in a solve's first period starts from the solve's start state (or the previous roll's carry), not from its own last step. Per-path caps and outputs (investment and cumulative-flow totals,co2_max_total, the cumulative price ladder) have no__anchorpath any more: each forecast path holds the shared periods before the reveal and the fan's here-and-now investment. - A committed window that reaches into a forecasts-only fan now stops the run before the LP when a
later solve continues from it and no solve realizes it: the error names the solve and the
families decided per forecast there (storage state, ramp-limited flows, the cumulative commodity
ladder, the CO2 total cap, finite annual commodity-ladder tiers, the CO2 period cap, recourse
investment, or units online / reserves outside the tied steps)
and suggests designating a
realized: yesbranch, configuring a realization solve, or ending the committed window before the reveal. Units online and reserves on the tied steps are the same in every forecast and need no realization: the next solve (e.g. the next roll) continues the units online, and the start-up / shut-down history of minimum up and down times, from them (before, it continued from nothing at all, as if no unit had been online). - A forecasts-only fan (no
realized: yesbranch) that authors a stochastic-time value (availability, efficiency, min_load, operational cost, reservation, penalty, profile) is valid again: every forecast branch period takes its own branch's value. A designatedrealized: yesbranch's value feeds the realization dispatch. An entity in noinclude_stochasticsgroup reads the realized branch's value on every branch period, so in a forecasts-only fan such a value is a configuration error naming the entity and the parameter (it would otherwise silently become the class default); add the entity to the group, designate a realized branch, or give a non-stochastic value. The same holds for thereservationof a reserve group withoutinclude_stochastics(before, it silently became 0). A stochastic-time profile is read per profile: when any of its users is in aninclude_stochasticsgroup, all its users read the forecast branches' own series. The how-to stochastics example2_day_stochastic_dispatchis a forecasts-only fan of four forecasts again, so it reports no realized dispatch (the forecast branches are visible throughmodel.output_horizon);1_week_rolling_windkeeps its realized branch. -
A stochastic-time value that gives only some of its forecast branches a number, leaving others out, is now a configuration error naming the entity, the parameter and the missing branches, instead of silently treating the branches left out as the class default. A deterministic solve that authors a stochastic-time value takes the
realized: yesbranch's value and raises a configuration error when no branch is designated, instead of silently using the class default (a realized branch projected from elsewhere — e.g. for a rolling solve's realization dispatch — is used, not an error). -
A rolling stochastic solve's commodity price-ladder tier cap now gives a forecast branch period the same already-bought carry-over as its anchor, instead of re-opening the tier's budget in full for every branch. The running "already bought this tier" total carried from roll to roll is only ever recorded under the anchor's real period name; a branch period of the same calendar year had no row of its own there, so its remaining budget silently fell back to the un-reduced full-tier amount — too loose — while the anchor's own row correctly subtracted what earlier rolls had bought. Branch periods now read their anchor's carried-over amount the same way they already read its tier price and quantity. A deterministic solve, a single (non-rolling) stochastic solve, or a rolling solve without a price ladder is unaffected.
-
A rolling solve that auto-spawns a separate realized-dispatch solve now carries the commodity price-ladder's rolling "already bought" total forward from that dispatch solve alone, instead of also adding the same hours' purchases from the solve that spawned it. The spawning solve's own dispatch for those hours is a placeholder with no weight in its objective (the dispatch solve re-solves the same hours for real right afterwards), so its own purchase split across tiers was arbitrary; folding it into the running total on top of the dispatch solve's own, correct contribution double-counted those hours and could leave an arbitrary residual in the carried total besides. The spawning solve now carries the running total forward unchanged, leaving the addition to the dispatch solve that actually realizes the hours. A rolling solve that does not auto-spawn a separate dispatch solve, or that has no price ladder, is unaffected.
-
The same auto-spawned realized-dispatch solve fix applies to the rolling CO2 cap's "already emitted" total, for the same reason: the spawning solve's own emissions for the hours its dispatch solve re-solves were a placeholder, and adding them to the running total on top of the dispatch solve's own, correct contribution double-counted those hours. A rolling solve that does not auto-spawn a separate dispatch solve, or that has no CO2 cap, is unaffected.
-
A stochastic branch period now inherits its anchor's existing capacity and fixed cost, instead of only the anchor seeing them. A period map keyed by the anchor period (e.g.
existingorfixed_costset for just that one period, the normal way to author a value that applies to the whole forecast fan) used to reach only the anchor: every branch period fell back to zero existing capacity, and the fixed cost of existing capacity — when that optional objective term is turned on — silently dropped out of the objective because the only period left to weight was the anchor's own zero-probability placeholder. Branch periods now read their anchor's value the same way every other period-keyed input already does. A deterministic solve, or a value authored as a plain constant, is unaffected. -
A group's per-period investment or divestment cap now applies to a stochastic branch period the same way the entity's own cap already did. Under the recourse investment feature, a branch period is itself an investment period, and the entity-level cap already applied there — but the group-level cap's row was missing for branch periods, so a group member could invest or divest past the group's limit in every branch, constrained only by its own (looser) individual cap. A deterministic solve, or a solve without the recourse feature, is unaffected.
-
Fixed a follow-on bug from the group per-period cap fix above: a branch period where no group member actually invests or divests (for example because its own authored value of zero wins over the anchor's) no longer makes the solve infeasible. The cap's row now only applies where a group member actually has the investment or divestment there; previously the row still carried the group's minimum-per-period floor at a branch with nothing to satisfy it, forcing an infeasible zero-against-floor comparison instead of leaving the branch unconstrained. A deterministic solve, a solve without the recourse feature, or a group cap without a minimum is unaffected.
-
A non-recourse stochastic solve's operating costs now get the same
years_representedweight as the investment side, instead of being weighted one year regardless of how many years the period represents. Every forecast branch period (the ones carrying the solve's probability weight) used to fall back to one year per period forp_inflation_opeven when its anchor period represented several years, while the investment side was still annualised over the full span — under-weighting every operating cost relative to investment by the same factor. Branch periods now read their anchor's years the same way the investment annuity side already does. A deterministic solve, a rolling solve, or a stochastic solve withyears_represented = 1(every stochastic fixture in the regression suite) is unaffected. -
A flow into a CO2-priced or CO2-capped node now nets against the group's emissions the way the solve does, instead of only when the same process both emits and removes at the same node. The removal credit's CO2 cost and emission outputs used to require the crediting flow to belong to the very process (and node) that also emits, so a removal process (e.g. direct air capture) distinct from whatever emits in the group went uncredited in the reports, even though the solve itself already gave it the credit. The credit is now attributed to the process that delivers it, and nets against the group's total the same way the LP objective and caps do. Models with no removal flow, or where removal and emission happen to be the same process and node, are unaffected.
-
The per-period CO2 price for a rolling solve now averages the rolls that realize a period, weighted by the hours each roll keeps of it, instead of only reporting the last roll's price. When a period's hours are split across more than one rolling window, each window's own shadow price covered only its own slice of the period; reporting just the last window's price silently dropped every earlier window's realized hours. A period realized by a single roll is unaffected.
-
The per-period CO2 price in a stochastic solve no longer comes out multiplied by the branch's probability. The per-period CO2 cap isn't weighted by the branch probability the way the objective's CO2 price term is, so the raw shadow price was the path's true price times its own branch weight — each path now reports its own price, as node prices now do (see "Prices are in currency per MWh" below). A deterministic solve is unaffected.
-
The model-wide and per-group CO2 totals in a stochastic solve now report each scenario path separately, instead of adding every forecast branch's emissions into one pooled number. The anchor's shared history and every branch's own periods used to be summed together as if they were one timeline, counting several alternative futures' emissions against a single total. A deterministic solve, or a stochastic solve with only one scenario path, is unaffected.
-
A group's whole-horizon CO2 cap and a commodity price ladder's cumulative tier cap now apply separately to each path of a stochastic solve, instead of being shared across every forecast branch. Both caps used to sum every forecast branch's emissions or purchases into a single row, so a solve with several alternative futures let them collectively spend several times the intended budget between them (one shared row instead of one per branch). Each cap now gets one row per scenario path: the whole-horizon CO2 cap keeps the same remaining budget on each path (the forecast branches are alternative futures, not consecutive years to divide the budget across); the commodity ladder's cumulative cap gives each path its own share of the budget, weighted by the periods that path actually covers. A deterministic solve, or a stochastic solve with no CO2 total cap or cumulative ladder tier, is unaffected.
-
A group's per-period and whole-horizon CO2 caps now carry a remaining budget across the rolls of a rolling solve and across the solves of a chain, instead of re-granting the full cap every time. Both caps used to recompute the allowed emissions from the fixed input cap on every roll / solve, with no memory of what had already happened, so a period touched by several rolling windows could be allowed its full per-window share on each of them — over-allowing roughly in proportion to the number of rolls or chained solves touching it. Each roll's / solve's cap is now "the budget allocated up to the end of this roll's horizon, minus what earlier rolls or solves already emitted" (the same mechanism the commodity price ladder's rolling budget already used), so the remaining room only ever shrinks by what was actually realized. A single, non-rolling, non-chained solve is unaffected. The internal CO2 accumulator that tracks realized emissions across rolls also now counts a min-load-efficiency unit's part-load fuel, which it previously missed.
-
A flow into a CO2-priced or CO2-capped node now earns a removal credit, instead of being ignored by the solve while the results already reported it as a credit. Modelling CO2 removal (e.g. direct air capture or bio-energy with carbon capture) as a flow into a CO2-content commodity's node, where that node belongs to a group with an active CO2 price or cap, used to cost and count nothing in the solve itself — even though the emission reports already netted the removal off the group's total, so the reported numbers and the solve's incentives disagreed. The flow now earns
co2_content × co2_priceas a negative cost (reducing the objective) and reduces the same group's emission caps, matching the emission reports and the documented "CO2 price is added to the price of the commodity" behaviour. Models that never route a flow into a CO2-priced or capped node are unaffected. -
CO2 emission outputs now report physical emissions even when no group prices or caps CO2. The model-wide and per-unit/connection CO2 outputs used to come from the same flow set the LP builds for a priced or capped CO2 group — so a commodity with a
co2_contentrow, but no group with an activeco2_priceorco2_max_period/co2_max_totalon its node, reported zero emissions even while units were burning that fuel. Emission totals are now derived from the commodity's own flows whenever it has a nonzeroco2_content, independent of whether a group happens to price or cap it; the per-group breakdown is unchanged and still requires a group that actually tracks CO2. -
A commodity price ladder in a rolling solve no longer crashes the solve, and its annual tier quantity is now scaled to how much of the year the solve actually covers. The annual tier quantity is a per-year amount, but a short or rolling solve used to compare it against the un-annualised MWh bought in that solve, so a solve covering only part of a year could spend a whole year's cheap-tier allowance in that short window. The per-period cap is now scaled by the solve's own share of the modelled year and of the roll's progress through it, minus what earlier rolls already bought against that same allowance — so each roll's budget is exactly what the model has allocated so far, never more, never less. The cumulative price ladder's rolling carry used to be able to drive a later roll's remaining budget negative and crash the solve once a chained run moved from one modelled period into the next; the real cause was that the per-roll progress measure feeding the cap's running total silently dropped a period's contribution the moment the chain finished it, instead of keeping it. Both ladders' running totals now account for every period the chain has ever simulated, so the remaining budget can never go negative.
-
The cumulative price ladder's running budget now accounts for how much of a modelled year each period's own dispatch covers, and how many real years that period stands for. Like the annual tier's quantity, the cumulative tier's quantity is a per-modelled-year amount, but its running budget used to credit a period's full allowance for every period the chain touched, regardless of how much of a year that period's own simulated hours cover or how many real calendar years the period represents — so a multi-year or representative-sample chain could let the cumulative cap run far more permissive than intended, never actually binding. The budget is now scaled the same way as the annual tier's (by the period's own share of a modelled year) and further by the number of real years that period represents, so the two ladders' running budgets are on a comparable footing.
-
A commodity bought on a price ladder by a unit with a minimum-load efficiency curve now counts the extra fuel that unit burns at part load. The ladder's purchase balance used to count only the full-load fuel use, so such a unit's actual fuel draw could exceed what the ladder tiers had accounted for.
-
The cost outputs now show what a commodity price ladder actually cost. A commodity priced on a ladder used to be excluded entirely from the reported commodity cost (or, if it also had a flat price authored, priced at that flat price instead of what the ladder tiers actually cost) — so the reported operating cost could disagree with the solved objective. The commodity cost output is now priced from the ladder purchase at the tier price actually paid.
-
A multi-flow unit with availability below 1 can now reach its derated full output. The fuel (input) arc of such a unit used to cap its flow at availability times the full-load fuel need, which is tighter than the fuel the unit actually draws at derated full output whenever it has a minimum-load fuel section or outputs with different per-output coefficients — so the unit's output could fall short of availability times capacity even though nothing else limited it. The fuel arc's limit now matches the fuel the unit actually needs at that output level, so a unit with availability below 1 reaches its full derated output.
-
Period x time maps of unit/connection efficiency, min_load, efficiency_at_min_load and availability are now honoured in rolling, nested and stochastic-rolling solves, not only a single solve. A genuine two-dimensional map (period, then time within each period) of one of these parameters used to be read correctly only by a solve with no rolls or nested sub-solves; every roll or nested sub-solve fell back to the parameter's constant / fold default instead, changing the unit's or connection's efficiency, minimum load and availability (and so its fuel use, cost and feasibility) for the rest of the model run. A period x time map now gives the same result in every roll and sub-period as the equivalent single solve.
-
Fixed a related case where a period sharing a name with one of the model's own timesteps could lose an explicitly period-labelled map value on rolling / nested sub-solves.
-
A unit with a single input and a single output now honours its per-arc
conversion_flow_coeffin its fuel use, cost and CO2, not only a multi-flow unit. The input/output coefficient onunit--inputNode/unit--outputNodeused to be applied only on a unit with several input or output flows; on the common one-input-one-output unit it was silently ignored (the model solved to a byte-identical LP whatever the coefficient was set to). The coefficient is now folded into the unit's fuel-use curve, the same way a multi-flow unit already applies it, so a non-default coefficient now changes the reported input flow, fuel cost and CO2 of such a unit. A unit with a minimum-load efficiency curve and a non-default output coefficient should note that its authoredefficiency/efficiency_at_min_loadare then no longer exactly its energy efficiency at full or minimum load (see the updatedconversion_flow_coeffdocumentation). A coefficient of 0 keeps its existing "uncapped edge" meaning rather than being folded in, now with a warning naming the affected unit. -
Storage capacity retired in a period now stays retired in later periods. The storage state limits, the start/end and cyclic start bindings, the reserve headroom and the cumulative capacity limits now subtract every earlier retirement of a storage node, and the storage capacity outputs show it. Before, a storage node's retirement earned its salvage value without ever reducing the storage capacity bound, so the node could still be dispatched and cycled as if nothing had been retired, and in some configurations could be "retired" again and again for repeated salvage.
-
A flow group's net flow now counts the fuel a min-load-efficient unit draws while online, not only the fuel its output flow implies.
flowGroup-based flow limits (cumulative-over- the-solve, cumulative-per-period and instantaneous) used to measure a flow group's net flow as output flow times slope only; a unit with a minimum-load efficiency curve also draws a fixed "section" fuel proportional to the capacity online, which the node balance, the CO2 price and the CO2 caps already count but the flow-group limits did not. The limits now use the same fuel measure throughout. -
Flow-group cumulative flow limits now weight each time step the same way every operational cost does, instead of by its duration alone.
max_cumulative_flow/min_cumulative_flow(per period and over the whole solve) used to multiply a step's net flow by its duration only; a representative-period step's own weight (and, for the whole-solve limit, how many years a period represents) used to drop out, so an under- or over-represented step counted as a full, unweighted hour. Under stochastic recourse, the whole-solve limit now applies separately to each scenario path instead of pooling every branch's flow into one row, so one branch's surplus can no longer cover another branch's deficit; this matches how every other per-scenario limit in the model already works. -
An explicit
invest_max_period/retire_max_period/invest_max_total/retire_max_totalof 0 on a group now binds, instead of being misread as "no limit" once another group sets the same parameter. This was the same "an authored zero is a given value" gap already fixed for the entity-level versions, carried over to the group readers. -
A
cumulative_limitsentity (unit, connection or storage node) with only acumulative_min_capacityfloor and nocumulative_max_capacityceiling can invest again. Such an entity used to be pinned at zero investable capacity — forbidding the very investment needed to reach its own floor — because the internal ceiling used to default to 0 when nobody authored a maximum. It is now unconstrained above (the actual limit, if any, is still enforced by the cumulative-capacity constraint itself); an entity that also authors acumulative_max_capacityis unaffected. The same gap also existed on the second (later) solve of a continuation/rolling chain and is fixed there too. -
A group with a
cumulative_limitsinvestment method now gets the same "give your own maximum once a sibling sets one" check as an entity does. Once onecumulative_limitsgroup setscumulative_max_capacityfor a period, another such group that leaves that period out of its own map used to be silently read as "no limit" there instead of being asked for an explicit value (or an explicit 0 to forbid investment); it now raises naming the group that needs a value, matching the entity-level behaviour. -
Group cumulative capacity limits now subtract a group's process retirements, same as the entity-level limit (#29). A group of units/connections with a cumulative capacity limit (
invest_method = cumulative_limits) ignored its own retirements entirely; the group's capacity in service only ever grew. Storage-node members are unaffected (the engine has no retirement history to draw on for them, same pre-existing gap as the entity-level limit). -
Group cumulative capacity limits now read the documented
cumulative_max_capacity/cumulative_min_capacityparameters. Groups previously needed undocumentedinvest_max_cumulative/invest_min_cumulativevalues (not part of the schema) to get a cumulative-capacity constraint; the documented, schema parameters were silently ignored. Also, which groups get a total/cumulative investment or retirement limit is now determined by each group'sinvest_method, matching the entity-level behaviour, instead of by "any group with a positive value of the matching limit parameter": a group with a limit value but a different (or no) method no longer gets rows it never asked for, and a group whose method does call for a total limit no longer loses its own row just because another group happens to set the same parameter. -
Cumulative capacity limits now subtract every earlier retirement, not just a retirement of the row's own period.
cumulative_max_capacity/cumulative_min_capacityon a unit, connection or storage node compare existing plus invested plus retired capacity against a limit for every period; a retirement from an earlier period used to drop out of that comparison again at later periods (processes/units only — storage nodes already counted every retirement before this fix and are unchanged). An explicitcumulative_max_capacity = 0(a phase-out year) now binds as given, instead of being read as "no limit"; a period an entity's own map leaves out, once that entity authors the parameter at all, is now a configuration error instead of silently capping investment at zero there. -
Investment and retirement totals over a chain of solves now count every earlier solve, not just the capacity still alive.
invest_max_total/retire_max_total(and the group-level analogues) used to ignore everything a previous solve in the same chain had already built or retired, so a chain of solves could invest past its own cap.invest_min_total/retire_min_totalused to re-impose the full minimum in every solve instead of crediting what earlier solves had already met, and a minimum on a storage node could be silently skipped whenever the matching per-process check had nothing to check first. Both now count the capacity the whole chain has built or retired so far and only require what is left; the minimum is now one requirement per entity (or group) over the whole chain — per scenario branch under stochastic recourse — instead of a repeated per-period check, so it can no longer be satisfied by pooling alternative branches together. An entity or group whose investment/retirement method still needs a per-period or total limit, but is never given one explicitly, now raises a clear configuration error instead of silently capping it at zero; a limit nobody sets at all is unaffected and stays unlimited, as before. The investment/retirement total's shadow-price output is now a single value per entity/group for the whole chain instead of one per period; a group's shadow price under stochastic recourse is no longer available in this output (it never was for the matching per-entity value either). -
Startups and shutdowns are exact, and the ramp limit of a unit with online variables applies to its online capacity. Startups and shutdowns are now exactly the change in units online: declaring a startup or a shutdown that does not match a real change in committed units is no longer possible, and declaring one no longer buys ramp room or reserve room by itself. A unit with online variables now ramps on the units online in both steps, reaching at most its startup output in the step it starts and dropping from at least its minimum load; a start/stop pair with online unchanged can still gain a little room on an input or on one output of a multi-output unit, which its startup cost pays for. Ramp-limited unit-commitment models can cost more than before (the free ramp room is gone); objectives of unit-commitment models without ramp limits are unchanged.
-
Online units continue across consecutive periods of one timeline and across rolling windows from the right step. A unit with online variables used to be charged a spurious startup where a period's first step continued the previous period on the same timeline (two periods on consecutive parts of one timeline, or a forecast branch continuing from the step before its fan), and a continuation roll's carried commitment used to land on the timeline's alphabetically first period instead of the roll's real first step. Both are fixed; a unit that stays committed across either boundary is no longer re-charged a startup there.
-
A period or time Map with an unset/default Map index name (
index_name) no longer drops other entities' values of the same parameter. When one entity's Map had no index name set (Spine stores this asx) and its keys could not be matched to either a period or a timestep of the active solve, FlexTool used to drop the whole parameter for every entity of that class, including other entities whose own values were set normally. Now only the one entity with no matching key is affected (it gets the parameter's usual default, exactly as if it had authored nothing); every other entity's value is unaffected. The most visible case was a storage'sself_discharge_loss: one node's unrelated, unresolvable Map could silently zero out every other node's self-discharge loss. Setting the Map'sindex_nameexplicitly toperiodortime(see "How to define the temporal properties of the model" in the user docs) avoids the ambiguity entirely and is still recommended. -
A commodity's price ladder (
price_ladder_annual,price_ladder_cumulative) is read correctly regardless of how its Map levels are indexed. A depth-2 annual ladder (the same tier prices and quantities applied in every model period) was silently ignored, as was any ladder whose Map levels were left with Toolbox's default index name instead of being namedtier/periodexplicitly. Two commodities authoring the ladder at different depths (one with a fixed annual price, one varying by period) could also have their tier numbers and period names mixed up with each other. Every one of these forms is now read correctly; naming a ladder's Map levels explicitly is no longer necessary. -
A model with a single timeline and no explicit
timeset_durationnow gets its usual "the timeset covers the whole timeline" default reliably. FlexTool has always defaulted a timeset with no authored duration to the whole (sole) timeline, and a timeset with no authored timeline to that sole timeline, when the model otherwise leaves this unspecified. A model built without these defaults in mind (for examplemodel_builder.build_model's generated models, which never writetimeset_durationat all) could end up with several of its per-period weighting Params (period-share-of-year, inflation, timestep weighting, branch weighting, profile values) silently missing. Both defaults now apply consistently wherever the model's time structure is read, not only on the legacy CSV-based path. -
A reused work folder no longer mixes old outputs in. Running FlexTool twice into the same work folder (for example, re-running in the same directory after a failed run or a
--csv-dumprun) could mix an earlier run's per-roll output files into the new run's results, producing wrong values or, in some cases, a crash. Every run now starts by clearing its own previous output stash in the work folder (the raw per-roll files and the--csv-dumpdebug mirrors); nothing else there is touched. The GUI and Spine Toolbox already gave each run its own folder, so this only changes behaviour for a command-line run reused in the same directory. -
invest_marginalnow shows the dual of an unbuilt entity, and group investment caps/floors reach their members. For aunit,connectionornodeentity that is eligible to invest but not built,invest_marginal(and theunit_invest_marginal__d/connection_invest_marginal__d/node_invest_marginal__doutputs) always showed0(or a near-zero-0.0), because the output writer looked up the entity's class (unit/connection/node) from a file a run never writes. That value is now read from the run's own data, so an unbuilt entity shows its true (negative) marginal value — negative there means it would be unprofitable to build, which is expected, not a numerical artefact (see the updated description indocs/results.md). A binding investment cap or floor on a group of entities is also now correctly spread to its member entities'invest_marginal, instead of only ever applying to the group itself. -
Five debug-only
solve_data/files no longer leak into a normal run's work folder.p_entity_divested.csv,fix_storage_quantity.csv,fix_storage_usage.csv,p_roll_continue_state.csvandp_entity_period_existing_capacity.csvare solve-to-solve handoff mirrors that nothing in FlexTool reads back; they are debug output for--csv-dumponly and are no longer written on an ordinary run.--csv-dumpstill writes all five, unchanged. -
--csv-dumpwrites the per-solve input dump again. With--csv-dump(the GUI's "Full" debug level) each sub-solve's inputs were supposed to be dumped toinput/andsolve_data/in the work folder, but the setting never reached the solves, so only the end-of-run snapshot was written. Each sub-solve now dumps into the work folder (a later sub-solve overwrites the files of an earlier one); adecomposition=benderssolve dumps its whole, undecomposed input. The dump also keeps the solve's objective scale insolve_data/scale_the_objective.csv, which the end-of-run snapshot used to replace with an empty file, so the offline output writer (python -m flextool.engine._csv_reload.offline_outputs) no longer needs--scale. A dump that fails is reported as an error with the solve's name; the run continues. -
A database file left in the work folder no longer changes or crashes the run. When the work folder held a copy of a database named
tests.sqlite(for example the run's own input database), the run read its entity names and switched the model's internal data types. The solve then built the same model in a different order, or stopped with aSchemaError(seen with rolling separate dispatch and nested fix_quantity runs). The run now takes everything from its input database and ignores such files.load_flextoolnow needs the run's data provider and database reader; loading a model from a CSV dump is done only by the unsupportedflextool.engine._csv_reloadpackage. -
Leftover CSV files in a reused work folder no longer change the model. When a run found no value for an input in memory, it read a CSV file of that name from the work folder instead, if one was there (for example from an earlier
--csv-dumpof another model). A stale dump of a model with an exact connection made a regular connection of the next model exact as well. The model is now built only from the database of the run; CSV files in the work folder are ignored. -
A run that cannot read its database stops with an error. When the input database could not be opened for the scenario (for example locked by another program or not migrated to the current version), the run went on without the database's settings and finished normally with a wrong model. It now stops with an error that names the database and the scenario. A run without a scenario also stops: the command line asks for
--scenario-name(or, in Spine Toolbox, a scenario filter on the tool) instead of failing later with an unrelated message. -
A nested fix_usage caps windows with no storage use. With
storage_nested_fix_methodfix_usage, a dispatch period whose matching storage-solve window had zero storage usage got no usage cap at all (and, with use_reference_value, the reference end value instead). It is now capped at zero usage like any other window. -
Only the last period of a solve ends its horizon. In a solve with several periods that share a timeset, the end of every period was treated as the end of the solve:
storage_solve_horizon_methoduse_reference_value pinned and use_reference_price valued the storage at the end of each period, and fix_end / fix_start_end pinned each period end of the last solve. These now apply only at the end of the solve's last period (and of each stochastic branch of it), as documented. In a rolling solve with bind_intraperiod_blocks, the end of a period that a roll holds only in part, followed by a period the roll holds whole, also gets the node'sstorage_solve_horizon_method, since nothing else continues from it. -
The csv dump shows what each solve was built from. With
csv_dump,handoff/*.csvnow holds the carriers the last solve consumed (its starting storage state, running totals, …), so the dump and a model rebuilt from the run match the solved model; the solve's own end state and final totals are inproduced_handoff/*.csv. -
fix_start_endignores the storage binding method. As documented, a storage node withstorage_start_end_methodfix_start_end now chains forward like bind_forward_only whatever itsstorage_binding_method. Before, a cyclic method (bind_within_timeblock, bind_within_period, bind_within_solve and their _blended_weights forms) kept cycling, so the start and the end were tied together, and with differentstorage_state_startandstorage_state_endthe solve needed slack or ended away fromstorage_state_end. A bind_intraperiod_blocks node keeps its blocks, which now chain forward from period to period instead of looping within each period. -
fix_end/fix_start_endfix the end of the storage. Thestorage_state_endof a fix_end or fix_start_end storage was never applied: the storage ended wherever the model chose. The end state is now pinned tostorage_state_end(a share of the storage capacity, invested storage included) at the last timestep of the entire model, in every solve whose horizon contains it: e.g. both an investment solve and the last dispatch roll when both reach the end of the model, each nested level's solve that does, each terminal branch of a stochastic solve. A solve or roll that ends earlier is not pinned. In a nested lower level, an upper-level fix_quantity or fix_usage of the same step takes precedence.storage_start_end_methodis meant for simple models;storage_state_reference_valuewith use_reference_value is usually the better choice. -
The horizon-end reference value yields to a nested storage fix. A nested dispatch solve with
storage_solve_horizon_methoduse_reference_value andstorage_nested_fix_methodfix_quantity (the recommended nested setup) pinned the end of each roll both to the reference value and to the storage solve's state, and the solve was infeasible. Where the upper-level solve fixes, caps or prices the storage at the step, its value now overrides the reference value, as documented. -
Nested dispatch rolls keep their unit commitment, ramps and running totals. In a nested solve every dispatch roll after the first took the roll-forward carriers from the storage or investment solve above it, which realises no dispatch: each roll restarted its unit commitment from its own cyclic wrap (charging startups again), ramped from nothing, forgot the min up / down time history and, with a cumulative price ladder, the tier energy already bought. They now continue from the dispatch realised before them, also across a later roll of the storage solve; a storage roll continues the commitment of the dispatch solved below it.
-
Later solves continue from the realised storage state. In a solve sequence (e.g. one solve per investment period) every solve was pinned again to
storage_state_start, discarding the state the previous solve realised, and the first dispatch roll under a later roll of a rolling storage solve started with a free storage level (free energy). Nowstorage_start_end_methodfix_start applies to the first step of the model and to the first appearance of each nested level; every later solve or roll starts from the latest storage state realised before it at its level or below (a storage or investment solve from the dispatch below it). Cyclic binding methods keep cycling within each later solve without the start value, and representative-period (_blended_weights) nodes start each block free. -
fix_startof a bind_within_timeblock storage. The start state was pinned on the state at the end of the solve's first step, after that step's flows, so the timeblock's cycle could start at any level and spend the difference in the first step. It is now pinned at the step the first step starts from, the last step of the first timeblock (in a rolling solve, of the timeblock's part in the first roll), as for the other cyclic methods. Where an upper-level nested solve fixes the storage state at that step (a nested roll whose horizon is one timeblock), the fixed value wins. -
Non-synchronous limit: internal flows, DC connections and reverse flows. The non-synchronous limit of a group counted the flows of a connection between two nodes of the group (both directions, as supply) in single solves, though not in rolling solves; the optimiser could cycle energy through such a connection, or through a storage connection inside the group, to loosen the limit. Those flows are now excluded on every solve path, as documented. An
is_DCconnection's flow into the group is counted as non-synchronous also when a unit is flaggedis_non_synchronous(it was dropped then), and the reverse flow of ano_losses_no_variable_costor DC connection is counted (infeed into the group, supply out of it). Models with a non-synchronous limit change (thenon_sync_and_curtailmenthow-to: higher cost, the battery no longer cycles). A warning names ano_losses_no_variable_costconnection that crosses the boundary of a group with a non-synchronous limit (flowing both ways at once is free and loosens the limit; useexact). -
The energy margin no longer enters the non-synchronous limit and the capacity margin. An
energy_margin_multiplierorenergy_margin_adderadded its demand to the group rows of the investment solve too: the non-synchronous limit allowed more non-synchronous infeed (its supply side grew with the added demand) and the capacity margin asked for capacity above the margined peak. Both now use the real demand, as the reserve requirement already did; the margin enters only the node balance (setcapacity_marginfor a capacity surplus). Models with both an energy margin and one of these group limits change (in the test model the tighter non-synchronous limit moves generation from wind to coal). A--csv-dumpof a solve with an energy margin now also writespdtNodeInflow_unmargined.csv, so a reload keeps the real demand. -
Reserve price of groups with several requirements.
reserve_price_tis now the sum of the duals of all of a group's requirement constraints at the step (timeseries, dynamic, and the large-failure constraint of every failing unit or connection): the cost of one more MW of every requirement of the group at once. Groups with a large-failure requirement were not reported at all (large_failure_only,dynamic_and_large_failure) or reported only the timeseries dual (timeseries_and_large_failure,all), andtimeseries_and_dynamicgroups reported the timeseries dual alone, which is 0 when the dynamic requirement is the binding one. When a requirement is short the price is nowpenalty_reserve. -
Time-varying
self_discharge_loss. Aself_discharge_lossgiven as a time (or period) map was read as one value per map entry and every coefficient of the storage's balance was summed over the entries (about 72× for a 72-step map): the storage behaved as if it were many times larger, with no error. The map is now read per period and time step: each step decays at its own rate, and a coarse (new_stepduration) step or abind_intraperiod_blocksblock decays by the sum of its hours' losses. -
use_reference_valuecounts invested storage. The end state of a storage withstorage_solve_horizon_methoduse_reference_value was pinned tostorage_state_reference_value× the existing capacity only, so newly built storage had to end (nearly) empty. It is now that share of the built capacity (existing + invested − divested), as for the start state and the storage capacity limit. -
fix_start_endfixes the start state. A storage node withstorage_start_end_methodfix_start_end got no start condition at all (it could start with free energy); its start is now fixed like fix_start. Its end is fixed too (see "fix_end/fix_start_endfix the end of the storage" above). -
fix_startof a bind_within_solve storage in a solve with several periods. The start state was pinned at the last step of the first period, i.e. in the middle of the solve. It is now pinned at the step the solve's first step starts from: the last step of the solve (in a stochastic solve, the end of each forecast branch the first step wraps to). -
Storage self-discharge is exact for any time-step length (for a loss that is constant within each step). The storage content now decays continuously at ln(1 +
self_discharge_loss) per hour, whatever the step length, with the energy charged or discharged within a step spread evenly over the step. Before, a step lostself_discharge_lossonce whatever its length (a 24 h step lost one hour's worth, so storages with self-discharge on steps longer than one hour kept far too much energy), coarse (new_stepduration) groups andbind_intraperiod_blocksblocks used a linearloss × hoursthat under-decays stored energy, and energy charged within a step was decayed for the whole step. Results with self-discharge change, slightly also at 1 h steps (the test battery's states by up to 0.0025 MWh). The reported self-discharge (balance_tSelf discharge) now equals the loss the optimisation used; before it wasstate × lossper hour, which differed for steps other than 1 h, coarse nodes and blocks. -
bind_intraperiod_blocksstorage balance. The balance of such a node over each timeset block is now exactly its per-step energy balance summed over the block. Before, it left out the reverse flow of two-way connections that use one flow variable (no_losses_no_variable_cost) and of DC power-flow lines, and the part-load (minimum load) input ofmin_load_efficiencyunits drawing from the node. The reported storage state of a block is now the state at the end of the block (before: at its start), like the state of every other storage node, and withfix_startthe start state is pinned at the end of the first period (the state the first block starts from). The node's price keeps its sign. -
Cyclic storage in rolling solves. In the second and later rolls of a rolling solve, the first step of a bind_within_timeblock, bind_within_period or bind_within_solve storage node mixed the cycle with the storage level carried over from the previous roll (the start state was forced halfway between the two). Each roll now cycles on its own, as documented, and the carried level is not used for these methods. Results of rolling solves with these methods change (the
fullYear_rolltest model: total cost −1.1 %). -
Blended-weights storage in solves without representative periods. A solve without representative periods treats a
*_blended_weightsnode as the plain method (e.g. bind_within_solve_blended_weights as bind_within_solve). When another node used the plain method in the database, the converted nodes lost their storage chain (their energy could appear or vanish freely). They now keep it. -
bind_within_periodstorage works. A storage node withstorage_binding_methodbind_within_period had no state-change term at all (its energy could appear or vanish freely) and no error was raised; the same happened to bind_within_period_blended_weights nodes in solves without representative periods (e.g. a chronological dispatch after a representative-period investment solve). The state now cycles within each period: blocks chain inside the period and the period's first step continues from its own last step, also in rolling, stochastic and coarse (new_stepduration) solves. Withfix_startthe start state is pinned at the last step of the first period (the step the first period wraps to).use_reference_valuedoes not pin the end state of a bind_within_period node (nor of a blended-weights node a solve without representative periods turns into bind_within_period / bind_within_solve), as already for bind_within_solve / bind_within_timeblock: their end is tied to their start. -
Representative periods in solves with several periods. Every period of a solve whose timeset has
representative_period_weightsnow gets its own weights, representative blocks and base-period storage chain. Before, only the first such period did (unless a node usedbind_within_period_blended_weights): the other periods' representative days were weighted 1.0 instead of their weights, so their operating costs were under- or over-counted, and thebind_within_solve_blended_weights/bind_forward_only_blended_weightsstorage of later periods lost its base-period chain. Periods sharing a timeset, or base-period names on one timeline, shared one chain and weight set. Objectives of multi-period representative-period models change (the test models by −45 % to +74 %; compared with the hourly model of the same days the error drops from 3–100 % to under 0.3 %). The across-solve variants now chain the periods in solve order; different blended-weights methods may now share a solve. A solve that mixes representative and other periods with a blended-weights node, a stochastic solve on a representative-period timeset, and a rolling solve with blended-weights nodes now stop with an error (their representative data was not defined). -
Benders with in-out stabilisation reports a valid upper bound. With
benders_in_out_weight> 0 the decomposition could stop on an upper bound below the true optimum (by up to 3e-4 on the three-region hydrogen test model): the two regions at the ends of a pipe could be solved with different flows on it. Both ends now always get the same flow, and the upper bound is the cost of that one point, and the investments handed to a following dispatch solve are the plan that bound priced. This costs one extra master solve per iteration (iteration counts are unchanged on the test models). -
Solver options reach every solve.
solver_mip_gapwas ignored on the default (warm) HiGHS path; it is now honoured. A solve withsolver_argumentsorsolver_mip_gapthat runs cold (warm=False,--save-memory) no longer losessolver_config/highs.opt, the CLI flags (--highs-threads,--solver-time-limit, …), the determinism defaults and the automatic scaling options; every HiGHS solve now gets the same layered set. Benders decomposition now uses these options too (master and region subproblems; see the solver docs for the details), including a thread count (--highs-threads, orthreadsinhighs.opt) with parallel regions. -
Stochastic storage tie counts every flow into the node. The tie that keeps the net flow into a stochastic storage node equal in all forecasts before the reveal now also counts the reverse flow of two-way connections that use one flow variable (
no_losses_no_variable_cost) and of DC power-flow lines, and the part-load (minimum load) input ofmin_load_efficiencyunits. Before, forecasts could move energy in or out of the node through those flows differently, i.e. use knowledge of the future. -
Prices are in currency per MWh.
price_tis now the cost of one more MWh of demand at the node and step (positive when more demand costs more). Before, node prices were negated and multiplied by the period's share of the year, by the timestep weight and, in stochastic solves, by the branch probability (e.g. −164 250 instead of 900 for an unserved-energy step). Reserve prices (reserve_price_t) were a million times too small and are now per MW of reserve held for one hour, also with the step length and weights removed. In a stochastic solve each forecast branch now reports its own price, and a step the objective does not weigh (e.g. a step thattimeset_weightsleaves out) has no price. The nestedfix_pricestorage handoff passed the dispatch solve a price a million times too small (and weighted); it now passes the storage solve'sprice_tto the dispatch solve, also to the rolls of a rolling dispatch solve (before, a rolling dispatch solve always received 0) and also forbind_intraperiod_blocksstorage nodes. -
bind_intraperiod_blocksstorage in rolling and nested solves. Before, such a node had no energy balance at all in a roll and in the per-period sub-solves of a nested solve (energy could appear from nothing). The nested sub-solves now get their timeset blocks. In a rolling solve the blocks of each roll also end at the end of the roll's realized part: a period that the roll holds whole loops its blocks (as in a solve that is not rolling), and in a period that the roll holds only in part the blocks chain forward and the storage level is carried from roll to roll, as for bind_forward_only (fix_startstarts the first roll, andstorage_solve_horizon_methodsets the end of a roll that does not loop). Arolling_solve_jumpthat is not a whole number of periods gives a warning. -
Nodes in a
new_stepdurationgroup sum their flows over each coarse step. A node of a group whosenew_stepdurationis longer than the solve's time step is now balanced once per coarse step by summing all its flows of the solve's time steps; flows, online status and reserves of a unit or connection with a node at the solve's step stay at the solve's time step (those whose nodes are all coarse now use the coarse step, see "Units and connections between coarse nodes use the coarse step" above). Before, a flow between an hourly and a daily node (e.g. an electrolyser from hourly electricity to daily hydrogen) entered the daily balance only in the first hour of each day, multiplied by 24, so the other hours produced hydrogen from free electricity; flows between two daily nodes were similar, a unit listed in a daily group was missing from its hourly node's balance, DC lines' reverse flow was missing from a coarse bus's balance, and a node's balance could pick up the output arcs of another node. Models with coarse groups now pay for the energy they convert and get higher costs (the three-region hydrogen test model: +14 %). Coarse groups now also work without storage nodes, in rolling solves (a coarse step ends at each roll's realized end), with a solve-levelnew_stepduration(coarse means longer than the solve's step; a shorter group step is ignored with a warning) and with two groups of different steps; coarse storage follows itsstorage_binding_method,fix_start,storage_solve_horizon_method(reference value) and nestedfix_price. Online (unit commitment) variables of units next to coarse nodes now exist at every time step. A group step that is not a multiple of the solve's step is now an error (before the coarse handling was silently dropped). Coarse nodes now report a price (the dual of their coarse-step balance) with the same sign convention as every other node, andbind_intraperiod_blocksstorage nodes now also use that convention (before, their sign was the opposite of the other nodes'). Listing a unit or connection in a coarse group has no effect on time resolution (a warning names those that touch a node outside the group). Benders decomposition now allows a boundary between nodes of different time resolutions. -
A representative base period that does not start at a time step of the timeline is an error. Before, it was ordered after all other base periods without a message, so the storage chain between base periods linked it out of time order.
-
Solve-level
new_stepdurationkeeps the gaps between timeset blocks and every period's time steps. With a timeset made of separate blocks (e.g. sample weeks), the aggregated steps of a solve withnew_stepdurationnow keep the gaps between the blocks: storage bound within a timeblock or with intraperiod blocks cycles within each block again (before, every aggregated step looked like its own block, so storage could not shift energy), ramp limits and rolling windows no longer link steps across a gap, and representative blocks keep their order. When several timesets of the solve share a timeline (one per period, or a period before a stochastic fan and the fan's branches), every period keeps its time steps; before, only the last timeset's period survived.scale_in_proportioninflow scaling now also works withnew_stepdurationand scales as the unaggregated solve does. A timeset shared by a solve withnew_stepdurationand a solve without it is now an error. -
Benders decomposition checks connections between regions. A connection between regions with a
startup_method, theexacttransfer method or reserve now stops the run with an error (its online variables, direction switch or reserve would be left without flows in the region subproblems); integer variables in a region subproblem print a warning. -
Connection reserve limited by the connection's flow. A connection's reserve at a node is now limited by its net position there: upward reserve by its free capacity towards the node plus the flow it sends away (which it can stop), downward reserve by the flow it brings plus its free capacity away from the node. Before, only upward reserve at the receiving node shared the capacity; a line could offer reserve at its sending node it could not deliver. Models with connection reserve can get less reserve from connections and higher costs.
-
Reserve requirements count the reverse flow of lossless connections. A
no_losses_no_variable_costor DC power-flow connection flowing towards a node in its reverse direction now raises the dynamic requirement there, sets a large-failure requirement when it can fail, and a connection to a storage node backs its reserve with stored energy also in that direction. -
exactconnections flow in one direction at a time. Theexacttransfer method now has a direction switch (one binary variable per connection and time step), as documented. Before it only added an online integer that did not stop simultaneous flow in both directions, so a lossyexactconnection could still burn a surplus through its losses, and an investedexactconnection could lose capacity to the unit count. -
Online connections. A connection's
startup_methodworks again, for one-way and two-way connections: the connection gets online capacity that limits its flow in both directions,startup_costis charged, and new outputs report the online status and startups of connections. Before, a two-way connection with a startup method silently became one-way and lossless, and a one-way connection lost its online variables (and every connection its startup cost) as soon as a unit had linear online variables. A DC power-flow line with a startup method is now an error. Units with binary online variables next to units with linear ones, and units online only throughminimum_time_method, keep their online variables (they lost them, or had their inertia counted twice). -
Group
transfer_methodapplies to the network topology. A group'stransfer_methodwas applied to some of the model's inputs only: DC power-flow lines kept an unconstrained reverse flow next to the one set by the voltage angles, and a groupunidirectionaloverride left its connections two-way. The group's method now decides the connections' flows everywhere. A connection in several groups with different overrides is now an error (the result depended on the database order). -
Startup and shutdown capacity reduction. As documented in the how-to, a unit with online variables and ramp limits can no longer run at full output in the step it starts in or in the step before it shuts down: it reaches at most its minimum load plus half a step's ramp on average (one step's ramp from minimum load, reached at the end of the step). A unit online for one step gets the smaller of the two limits. This was in the old model and had not been ported.
-
Reserve uses ramp room. A unit with ramp limits could hold reserve its ramp limit would not let it deliver. Its upward reserve now uses the upward ramp room of the time step (after the change of its flow from the previous step), downward reserve the downward room, and "consume more" reserve at a unit's input the input's ramp room. Models with ramp-limited reserve providers can get reserve shortfalls or a different dispatch.
-
Ramp limits scale with the time step. A ramp speed is a share of capacity per minute; the change allowed between two time steps is now that speed × the average length of the two steps, so 2-hour steps allow twice the change of 1-hour steps (it was one hour's change whatever the step length). The first step of a solve, of a period that does not continue the previous one, after a gap in the timeline or of a representative period is no longer ramp-limited against an unrelated step (it was tied to the last step of the period, or of the look-ahead in a rolling window); a period that continues the timeline, and a stochastic branch, ramps from the step before it; a rolling window ramps from the flow realized by the previous window. Models with different time steps per period lose a hidden cap on the first step's output. The reported ramp (
unit_ramp__*Node__dt) is empty where there is no previous step. -
Dynamic and large-failure reserves without a timeseries reservation. A reserve with only a
dynamicorlarge_failurerequirement did nothing unless some group of the same reserve also had areservation. It now applies whenever a unit or connection at a node of the group has anincrease_reserve_ratioor alarge_failure_ratio. Models with such reserves get the reserve they asked for and higher costs. The reserve price (reserve_price_t) is now also reported for groups whose only requirement is dynamic. -
Reserve requirements count what a unit consumes. The dynamic requirement and the downward large-failure requirement of a unit at a node it consumes from (electrolyser, heat pump, electric boiler) now use the power it draws (output / efficiency, plus the minimum-load input of a
min_load_efficiencyunit), as the node balance does, not its output. -
Group
increase_reserve_ratio. It was ignored; it now raises a dynamic reserve requirement by the ratio times the demand of the group's nodes in each time step (in MW; a node with positive inflow does not count). The demand added by an energy margin in investment solves does not count. On a group without a dynamicreserve_methodthe ratio is ignored with a warning. -
Reserve at a unit's input. A unit consuming at full capacity, or offline, can no longer offer "consume more" downward reserve at its input node (electric boiler, heat pump, fuel draw of a CHP unit);
max_shareat an input node is a share of the unit's input capacity there. -
co2_max_periodapplies in rolling solves. Each roll of arolling_windowsolve compared its window's emissions, annualised as if they covered the whole solve, with the annual limit, so the limit was too loose by the solve length over the roll length (for example ×52 for a year rolled in weekly windows) and usually did not bind. Each roll now annualises its emissions over its own hours. Rolling models with a CO2 period limit get a tighter limit and higher costs. A roll limits its whole window, look-ahead included, so the realized emissions of a period can still differ somewhat from the limit. In a rolling solve the reported CO2 price of a period averages the rolls that realize it (see the per-period CO2 price entry above). -
Warm-started rolling solves across period boundaries. When two consecutive rolls had the same number of timesteps and periods but split them differently between the periods (a window moving across a period boundary), or a timeblock started at a different step inside the window, the warm start reused the previous roll's model unchanged: per-period terms such as a per-period CO2 price, CO2 limit or inflation were applied to the wrong timesteps. Such rolls are now rebuilt. Results of rolling solves over several periods can change in the rolls that cross a period boundary; for the rolls inside one period see the next item (every roll is now rebuilt).
-
Rolling solves: results of reused rolls. When consecutive rolls differed only in demand (no changing profiles), the solver reused the previous roll's model. The reported results of such rolls came from other hours of the window, or were zero, and the storage state was not carried to the next roll. With a large spread of input magnitudes the reused roll could also use a wrongly scaled demand, and a rolling solve with timestep durations that vary between steps could stop as infeasible. A second solve with other solver settings (for example
scaling) could also run with the previous solve's settings. Every roll is now built for its own window; a built model is reused only for an identical next solve with the same solver settings. Rolling models without changing profiles get correct results; they can run up to about 5 % slower. Models with time-varying profiles are unaffected (their rolls were already rebuilt). A rebuilt roll starts the solver without a basis, as every roll of a model with profiles already did; the warm-start basis cache (FLEXTOOL_WARM_START) still starts a roll from the basis of the same roll in an earlier run of the same model, and never transferred a basis from the previous roll. -
Units with an online variable no longer need a
min_load, andmin_load_efficiencyunits get defaults instead of losing their fuel use. An online unit stopped the model when no unit in the model had amin_load. Amin_load_efficiencyunit withoutmin_load(or withoutefficiency) could stop the model, or, next to othermin_load_efficiencyunits, use no fuel at all; it now usesmin_load0 (andefficiency1, the default), and no longer gets a minimum-load floor. Amin_load_efficiencyunit with amin_loadabove 0 but noefficiency_at_min_loadused no fuel on single and stochastic solves and a wrong curve in rolling solves; it now uses itsefficiencyat minimum load too (a flat curve, as withconstant_efficiency) while the minimum load still applies, and a warning names it. Periods or timesteps that anefficiency_at_min_loadmap leaves out useefficiencyin the same way. -
The capacity margin applies only in investment periods. A manual capacity margin was enforced in every period of every solve, so dispatch solves, rolling dispatch, stochastic dispatch and look-ahead periods without investment paid capacity margin penalties. It now applies in the periods listed in the solve's
invest_periods(and their stochastic branch periods), as the documentation says. -
A capacity margin, inertia or non-synchronous limit applies only with its method or flag. A
capacity_margin,inertia_limitornon_synchronous_limiton a group withoutcapacity_margin_method = manual/has_inertia = yes/has_non_synchronous = yescreated the constraint anyway when no group had the method or flag (on every solve type for constant values, and for period maps of the inertia and non-synchronous limits). Such a limit is now ignored, and a warning names the group. A group with the method or flag but no limit is treated as limit 0 whether or not another group sets a limit (capacity covers the peak net load; no non-synchronous infeed; no inertia requirement), and a warning says so when no value is given. -
Stochastic-time values of availability, efficiency, min_load, other operational cost, reservation and penalties no longer stop the model. A value given as a three-level map (branch, analysis_time, time) with these index names stopped the model with an "index_name 'branch'" error. Without index names a stochastic efficiency set every unit's efficiency to 1, a stochastic node availability or min_load was lost or stopped the model, and a period-and-time map of the same parameter on another unit, node or reserve was ignored. For the units, nodes and reserves in an
include_stochasticsgroup, each entity's stochastic series now applies to its forecast branches, and the other entities keep their own values. -
A period-and-time
other_operational_costis charged in the objective. Another_operational_costof a unit input, a unit output or a connection given as a map keyed by period and time was left out of the objective on every solve type (the cost results still showed it). It is now charged like the other shapes. -
A map of
penalty_up/penalty_downon one node no longer makes the other nodes' balance free. When one node gave its penalty as a map (by period, time or period and time), nodes with a constant penalty or the default lost their penalty, so their unserved or excess energy cost nothing. This happened on every solve type. Every node now keeps its own value or the default, and periods or timesteps that a map leaves out get the default. A time or stochasticpenalty_up, and a period-and-timepenalty_uporpenalty_down, now also apply in rolling solves, and a stochastic penalty no longer stops the model. -
A group with an inertia, non-synchronous or capacity margin limit but no penalty of its own now gets the default penalty (5000) when another group sets one. Before, such a group had no penalty at all, so violating its limit cost nothing and the limit had no effect. A penalty of 0 still makes the violation free.
-
Rolling solves and the sub-solves of a nested investment use period-map values of efficiency, minimum load and profiles. A unit or connection
efficiencyor a unitmin_loadgiven as a map keyed by period, and aprofilekeyed by period, now apply in every roll of arolling_windowsolve and in the per-period sub-solves of a nested investment, as they already did in a single solve. Before, these solves used efficiency 1 (wrong fuel use, fuel cost, CO2 and input-flow limits), dropped the minimum-load part of the conversion curve and read the profile as 0. Stochastic rolling solves are fixed too. -
A
profilekeyed by period is no longer read as 0 when the model also has time-series profiles. A profile given as a map keyed by period, next to profiles given as time series (and no profile keyed by period and time), could be read as 0 on every solve type, depending on the order of the profiles in the database. -
A period-keyed
inflowis now an error.inflowis a constant or a time series. A map keyed by period (or by period and time) was silently read as no inflow on every solve type, so the demand disappeared. The model now stops with a message that names the node and points toannual_flowfor demand that varies by period. -
A constant
cumulative_max_capacityorinvest_max_periodno longer blocks investment. A unit, connection or node withinvest_methodcumulative_limits, or aninvest_methodwith per-period limits (invest_period,invest_period_total,invest_retire_period,invest_retire_period_total), and a single value forcumulative_max_capacity(orinvest_max_period) now gets that limit in every period, the same as the equivalent period map. Before, the single value was ignored when the model computed the maximum capacity, so the entity could not use new capacity (withcumulative_limitsnot even its existing capacity), and the model fell back to slack or other technologies. This also holds for the sub-solves of a nested multi-period investment and for rolling solves. Since database version 77 the parameter definitions accept a constant for these limits too. -
Forecast branches use their period's per-period input values. In a stochastic solve, a value given as a map keyed by period (for example
efficiency,startup_cost,min_load, commodityprice,availability,other_operational_cost,penalty_up/penalty_down, profiles, CO2 price and per-period limits such asco2_max_periodorcapacity_margin) now also applies to the forecast branches of that period, the same way a single value does. Before, the branches got the default instead (efficiency 1, no startup or variable cost, free fuel, free slack), so a model with period maps could report a far too low cost. A value authored for a branch period itself still takes precedence. With recourse investment (stochastic_invest_method: recourse), the per-period investment limits of units, connections and nodes given as period maps (e.g.invest_max_period) now also apply in the branch periods, which are investment periods there. -
Rolling stochastic solves rebuild the LP when branch weights change. When the forecast branch weights differ from one roll to the next and nothing else forces a rebuild, the next roll now gets its own weights in the objective. Before, the reused LP could keep the previous roll's weights.
-
Forecast branches use their period's annual commodity ladder. In a stochastic solve, the forecast branches of a period now use that period's
price_ladder_annualprices and tier quantities. Before, purchases in the branches had no price and no tier limit, so a ladder commodity was free and unlimited in every branch. A branch period gets each tier curve whole from its period. -
Commodity price ladders in stochastic solves are weighted by branch probability. The purchase cost of a
price_ladder_annualorprice_ladder_cumulativecommodity is now weighted by the probability of each forecast branch, like the other operating costs. Before, every branch counted in full, which overstated ladder costs by one over the branch probability and pushed the dispatch away from ladder commodities. -
co2_max_totallimits the emissions of all represented years. The total CO2 limit now applies to each period's annual emissions times itsyears_represented, summed over the periods: the same measure as the model-wideCO2output. Before, each period counted as one year, so with multi-year periods the limit wasyears_representedtimes too loose. Models that use aco2_methodwithtotalandyears_representedabove 1 get a tighter limit. Chains of solves and rolling solves now also subtract the emissions realized before them (see theco2_max_totaland remaining-budget entries above). -
Years represented in rolling solves and stochastic branches. The outputs that scale annual values by
years_represented(the model-wideCO2 [Mt]andyears_represented__d) now use each period's years also in rolling-window solves, where every period counted as one year before, and on the forecast branch periods of a stochastic solve, which counted as zero years before (they now get their period's years). -
CO2 limits count the part-load fuel of min-load units. The CO2 limits (
co2_max_period,co2_max_total) now count the extra fuel thatmin_load_efficiencyunits burn at part load, as the CO2 price and the reported emissions already did. Before, the limits left that fuel out, so the reported emissions of such units could exceed the limit. Models that combine a CO2 limit with such units get a tighter limit and less output from those units. -
Combined reserve methods keep all their requirements. A reserve group with a combined
reserve_method(timeseries_and_dynamic,timeseries_and_large_failure,all) lost its timeseries requirement when another group in the model usedtimeseries_only. Every group now gets the requirements its method names. -
N-1 reserve requirement per failing unit or connection. A
large_failurereserve requirement now has one constraint per failing unit or connection, so the largest single failure sets the requirement, and the failing unit's own reserve no longer counts towards covering its own failure. Before, the failures of all large-failure units in the group were added up into one requirement (too much reserve when two or more can fail), and a single failing unit could cover its own loss (too little). The largest possible reserve shortfall of such a group is now the largest single failure instead of the sum. Models with n-1 reserves will get different reserve procurement and costs. Each failing unit or connection adds one small constraint per reserve group and time step. -
Upward reserve of a unit with online variables comes out of its online capacity. A unit with online variables (a
startup_methodlinearorbinary, or aminimum_time_methodother thannone) can hold upward reserve only from the units it has online: its output plus the upward reserve it holds at that output cannot exceed online capacity × availability, and for a unit with several outputs the same applies to their sum. Before, only the built capacity limited the reserve, so an offline unit could offer spinning reserve for free. Models with upward reserve on such units will commit more units (more startup and min-load cost) or show a reserve shortfall where they did not before. -
Input-side operational cost counted as a cost.
other_operational_coston a unit--inputNode of a unit with an efficiency now raises the objective, as documented and as the cost outputs already reported. Before, the model subtracted it, so a higher input-side cost made the unit run more and the solver objective did not match the reported total cost. Units without an efficiency, output-side costs and connections are unchanged. Models that set this parameter on such units will get different dispatch and costs. -
Mixed constant / period
invest_costandsalvage_value. A model with a period map ofinvest_costorsalvage_valueon some entities and constants on others (in the same or in different entity classes) no longer crashes. Period maps are now applied per period also when the map has no index name, so a zero cost in one period is respected. Multi-solve models whose cost is a period map no longer get investment or retirement variables for periods outside each solve's invest periods (before, every map period got one, including past periods). These two parameters must now be a constant or a map indexed by period: a time-indexed value, or a map with an explicit index name other thanperiod(e.g.year), raises a configuration error instead of being accepted. -
Internal: the engine package is renamed
flextool.engine_polars→flextool.engineand split into subpackages (input,preprocess,model,solve,output,autoscale);flextool/engine/README.mddescribes the layout.flextool.engine_polarsstays as an alias for the public API. The model build (model/core.py, formerlymodel.py) is restructured for readability; the LP it builds is unchanged.
Release 4.1.1 (21.10.2026) — scale_to_annual_flow over the whole timeline, multi-output indirect-unit capacity, node__dt balance under coarsening
No schema change (stays v76); no migration needed.
-
scale_to_annual_flowuses the whole original timeline. The annual inflow target is now matched against the raw inflow summed over the entire original (unmodified) timeline and annualised by its length, the same wayscale_in_proportiondoes, instead of against the representative weeks or a "first 8760 hours" window. Results are unchanged for timelines of one year or less; timelines longer than one year now average over all their years instead of using only the first. Two test results that usescale_to_annual_flowwith timesets differing from 8760 hours were updated accordingly. -
Indirect-unit capacity for multi-output units. The capacity bound on the fuel (source) arc of an indirect unit now covers the throughput of every output arc at its own capacity, instead of limiting the sum of outputs to a single unit capacity. This keeps a dispatch-only solve consistent with the investment solve. Single-output units are unchanged.
-
node__dtbalances under timeline coarsening. Withnew_stepduration(step duration ≠ 1) the Inflow column ofnode__dtwas energy per step while the flow and slack columns were MW rates, so the rows did not sum to zero. Inflow is now reported as a MW rate too. Output-only: the solve was already correct, andnode__dis unchanged. -
Internal: the hours-per-year unit (8760) is centralised as a single
HOURS_PER_YEARconstant used by every annualisation in the engine. -
Docs: new section in the advanced guide on using periods that are not one year long (what stays correct automatically, what the annualised result columns mean, and when not to rely on the annual inflow-scaling methods).
-
Release automation: pushing a version tag now also creates the GitHub Release (notes taken from this changelog), after the PyPI publish succeeds.
Release 4.1.0 (20.10.2026) — two-stage & recourse stochastic investment, realized-dispatch (UC→dispatch) solves, rolling unit-commitment continuity; construction lead time; indirect-unit capacity, per-unit min-load & reserve fixes (schema v76)
Schema change (adds solve.stochastic_invest_method,
solve.shared_invest_periods, solve.shared_operation_periods,
solve.dispatch_realization_method,
reserve__upDown__group.reserve_duration, and per-entity
construction_lead_time / construction_lead_time_method on
unit/connection/node; migrated automatically). The stochastic
shared-decision window parameters use positive names:
shared_invest_periods / shared_operation_periods name the periods
whose decisions are shared (identical) across stochastic branches.
-
Mixed period/time Map shapes within one parameter. A parameter can now be authored with different Map shapes for different entities (for example a
flowGroup.min_instant_flowthat is aMap(period)on one group and aMap(time)on another). Each row keeps its own index semantics regardless of the order of rows in the database, instead of being silently misplaced or dropped. As a consequence of classifying every row, a Map shape that a parameter does not accept — for example a time Map (or any other non-accepted shape) on a period-only parameter — now raises an error that names the offending entity, instead of being silently ignored. Databases that relied on such input being dropped will stop with a clear message identifying the entity and parameter to fix. -
Construction lead time. New per-entity parameters
construction_lead_time([years]) andconstruction_lead_time_method(immediate/closest_seam/previous_seam/next_seam) model physical build time onunit,connectionandnodeinvestments. Capacity ordered in a period becomes available only after the lead time, snapped to a period boundary — the investment decision and its overnight annuity stay in the order period, while balances, flows and reserves see the plant (and its full technical lifetime) only from its commissioning year. It applies to deterministic and stochastic models alike, and composes with the invest non-anticipativity window to stage long- versus short-lead technologies across a stochastic reveal automatically (a long-lead asset serving a post-reveal period must be ordered in a pre-reveal, tied period). Left at the default 0 (orimmediate) every existing result is byte-identical. -
Standard two-stage stochastic investment (the main mode). The new
solve.shared_invest_periodsparameter names the periods over which investment is a single shared here-and-now decision:v_investandv_divestare tied across the stochastic branches over those periods, while later periods invest per branch (recourse). Compose it withstochastic_invest_method = recourseandshared_operation_periods = []to get the classic capacity-expansion structure — shared first-period investment, per-scenario recourse investment thereafter, operations free from the first timestep. Both invest and divest are tied so the net first-stage capacity is genuinely shared. Left unset (or empty) it adds no tie and every existing result is byte-identical; on a non-recoursesolve it is a harmless no-op. -
Per-scenario stochastic investment (opt-in
solve.stochastic_invest_method = recourse): each stochastic branch can now make its own investment decisions at and after the branching period, with probability-weighted investment costs and per-scenario investment limits — a wait-and-see analysis giving per-scenario optimal plans and their expected cost. Committed results report the realized scenario; non-realized branch investments are available under the horizon-output debug flag (model.output_horizon). Default behaviour (none) is unchanged, and the option is rejected in combination with Benders decomposition. - Mid-horizon reveal / hedged two-stage stochastics. A stochastic branch
may now start at any period boundary, not only the solve's first step, so the
pre-reveal periods stay a single shared here-and-now investment (a genuine
first stage) while the branches fan out into per-scenario recourse from the
reveal onward. Under
recoursethis makes the objective a real two-stage hedge — strictly between the wait-and-see bound and the deterministic mean-value plan (positive EVPI/VSS). Storage state is carried across the reveal for stochastic-group storage nodes. Mid-horizon reveal is a general stochastics capability; the previous multi-period restriction is lifted. A malformed reveal (analysis time off a period boundary, or a branching period without exactly one realized branch) is now rejected with a clear error. - Separate realized-dispatch (unit-commitment → dispatch) solve
(
solve.dispatch_realization_method). A stochastic solve can now hand its committed decisions to a separate deterministic dispatch that re-solves the realized operating conditions with the plan's unit commitment, investments and storage held fixed — the classic commit-under-uncertainty then dispatch-against-what-happened structure. Setdispatch_realization_method = separate_dispatchand the engine automatically runs a<solve>__realized_dispatchsibling after the commitment solve: it pins the committed online units (v_online) and fixes the committed capacity while the flows re-optimize against the realized data, and the realized dispatch's results are what the outputs report. Under a rolling-window solve it runs per roll — each roll's commitment solve is followed by the realized dispatch of that roll's realized steps, whose end-state carries into the next roll. The defaultwithin_solvekeeps today's behaviour (operations solved inside the stochastic solve) and is byte-identical. Supported for a single top-level solve and for rolling-window solves; not yet for contained/nested solves. - Rolling unit commitment is now continuous across roll boundaries. In a rolling-window solve a unit's commitment is carried from one roll to the next instead of being re-decided cold at each boundary: a unit still committed at a roll's end is not charged a fresh start-up at the start of the next roll, and a minimum up-time / down-time obligation opened near a boundary constrains the following roll's early steps (within the same period). Previously each roll started its unit commitment from scratch, over-counting start-up costs and letting committable units cycle or break their minimum up/down times across boundaries. Results of rolling solves with committable (online) units change — typically lower start-up cost and fewer boundary start-ups; non-rolling solves and models without unit commitment are unchanged.
- Minimum load now applies once per unit, on the sum of its outputs.
min_load× online capacity is a floor on the sum of the outputs that define the unit's capacity (outputs withconversion_flow_coeff ≠ 0; the single flow of a one-input/one-output unit; the input of a unit without outputs). Inputs no longer get a floor of their own. Behaviour changes for every unit withmin_loadand online variables that has several inputs or outputs (or a delay): - a unit with two inputs no longer has to burn
min_load× online of each input — an expensive second input is no longer forced, and a second input that cannot supply no longer keeps the unit offline; - a unit with an efficiency above 1 (heat-pump-like) can now run with
min_load(before, its input floor pushed the outputs above capacity); - an extraction CHP can run in pure-heat mode while online (before, the input floor forced electricity out of it);
capacity_min_coeffon an output no longer lowers or switches off the unit's minimum load (setting it below 1, e.g. 0 on every output, used to do that): the fullmin_loadapplies. Lowermin_loadinstead (min_load = 0switches it off). The model warns about such units;- one-input/one-output units behave as before, except that an authored
capacity_min_coeffbelow 1 no longer softens their floor. capacity_min_coeffon an output is an optional per-output floor (default 0, schema v70). While the unit is online, an output of a unit with several outputs must be at leastcapacity_min_coeff × min_load × online capacity. 0 (the new default) means no separate floor; values outside 0..1 are an error. The schema migration deletes stored output values outside 0..1 (they were copied from old flow coefficients by an old migration, e.g. 2 or −0.025) and values equal to the same output'scapacity_max_coeff(≠ 1, also left by that migration), and lists them; it also lists the units whose kept values now act differently (all below 1: no longer lower the minimum load; > 0 on a unit with several outputs: now a floor on that output). The listing is printed on the console only.- New:
input_share_min(unit--inputNode, default 0). While the unit is running, the input must supply at least this share of the unit's current input energy (flow ×conversion_flow_coeff): e.g. 0.3 makes the input supply 30 % of whatever the unit uses. It applies with or without online variables. Shares on all inputs summing to 1 fix the blend. Errors: a share outside 0..1, shares of a unit summing above 1, a share above the same input'sinput_share_max, and a share on a unit with a negative inputconversion_flow_coeff; a share on an input withconversion_flow_coeff = 0or on a unit with one input has no effect (a warning). The schema migration deletes all values of the oldunit__inputNode.capacity_min_coeff(the model never applied them, and their old meaning is unrelated) and prints the deleted values; re-author them asinput_share_minwhere a mixing minimum is meant. - Downward reserve can no longer take a unit below its floor. Downward
reserve at a node a unit feeds, and upward reserve at a node a unit
consumes from (a consumption cut), cannot exceed the unit's flow, and for
an online unit cannot take the sum of its outputs below
min_load× online capacity (nor an output withcapacity_min_coeffbelow its floor). Before, a unit could offer its full capacity as downward reserve even while off. Themax_sharelimit (× capacity) still applies on top. Connections are not limited this way. - Reserve shortfall can cover the whole requirement and is always
penalised. The reserve shortfall is now sized by the largest
requirement of the group —
reservationfor timeseries requirements (unchanged), Σ capacity ×increase_reserve_ratiofor dynamic and Σ capacity ×large_failure_ratiofor large-failure requirements, summed when a group combines methods. Before it was sized byreservationonly, so a dynamic or large-failure group with a small or noreservationof its own could leave its requirement unmet only up to that amount (or not at all) and, without areservation, without penalty — a requirement that could not be met made the model infeasible. The reserve shortfall outputs use the same sizing. - Fix:
penalty_reservedefaults to 5000 again (schema v70). A reserve group without its ownpenalty_reservehad no shortfall penalty at all, so its reserve requirement was in effect optional. The default of 5000 [CUR/MW per hour of shortfall] — the value of the 3.x model and of the schema until an earlier migration cleared it — now applies to every reserve group without a value; authored values are unchanged. Models with such groups now procure the reserve (or pay for the shortfall), which can raise the objective. - Fix: connection
delayis ignored, with a warning. The reference said it works withtransfer_method = no_losses_no_variable_cost; in fact no connection delays its flow (neither did the 3.x model). Setting it still changed the connection: its efficiency losses were dropped and aregularconnection became one-way. Until connection delay is implemented, a connectiondelayis ignored — the connection is modelled exactly as without it — and the model warns once per such connection. Use a unit for a delayed flow. The reference, the how-to and the parameter description (schema v70 step) say so. - Known limitations (follow-ups): upward reserve is still not limited by
the online capacity (an online unit that is off can offer upward reserve);
units consuming more to offer downward reserve are not limited by their
input capacity; connections are not limited by their flow when offering
reserve; the reserve balance does not yet convert input-side reserve of a
one-input/one-output unit by its efficiency; dynamic and large-failure
(n-1) reserves are only active when some group of the same reserve and
direction has a non-zero
reservation; the large-failure requirement sums all failing processes into one requirement instead of one requirement per failing process (over-procures reserve when several processes can fail); connectiondelayis not applied (it is ignored with a warning). - Fix: multi-input/multi-output (indirect) units with
conversion_method = min_load_efficiencynow burn the no-load fuel. Their input wasslope × outputonly; thesection × online capacitypart of the efficiency curve was missing, so fuel use, commodity cost and CO2 were understated (by 31 % at full load for a unit withmin_load0.2,efficiency_at_min_load0.4 andefficiency0.9). The input is nowslope × output + section × online capacity, as for one-input/one-output units. - Fix: a unit with a constant
delayruns again. Only a map of delays was read into the delay durations; with a constant the unit's inputs were dropped from its conversion and its output was forced to zero without a message. Rolling-window solves with a delayed unit (constant or map) no longer stop with an error. The delay tables of a delayed unit are now checked, and an inconsistency stops the run with a clear error instead of zeroing the unit. Note:delayon connections is not applied; it is ignored with a warning (see above). -
Fix:
input_share_maxon a delayed unit's input divides by the input'sconversion_flow_coeff. For delayed units the input limit usedmin(conversion_flow_coeff, 1), so an input with a coefficient above 1 was allowed too much (e.g. coefficient 2 withinput_share_max0.6 let the unit run at full output instead of 60 %). Delayed and undelayed inputs now get the same limit. -
Input (fuel) limits of units follow
input_share_max. An input of a multi-input/multi-output (indirect) unit may now supply at mostinput_share_max× the input that runs the unit at full output ((slope × W + section) / conversion_flow_coeffper unit of capacity,Wbeing the most fuel the outputs can demand within the capacity and theircapacity_max_coeff), times availability and capacity. The default 1 is no effective limit; e.g. two fuels with 0.6 each can run the unit at full output only together. Units without investment in the solve get the limit as a bound on the flow variable (fewer constraint rows); investing units keep a constraint whose invested-capacity term now carries the same multiplier (it used 1, so the fuel limit did not follow invested capacity correctly). Direct units honour an authoredinput_share_maxas a cap on their flow; source-side ramp limits of indirect inputs scale with the input limit. The previous fuel bound summedconversion_flow_coeff × capacity_max_coeffover all outputs, ignoring that their sum is capped by capacity (looser than needed). - Rolling-window sub-solves now apply the same capacity coefficients as
single solves. Output
capacity_max_coeff, the uncappedconversion_flow_coeff = 0edges and the indirect input limits were not carried into the roll sub-solves. - Input-side capacity coefficients renamed to
input_share_max/input_share_min(schema v70). Onunit__inputNode,capacity_max_coeffis nowinput_share_max(values move unchanged) andcapacity_min_coeffisinput_share_min(see below: its old values are deleted);unit__outputNodekeepscapacity_max_coeff/capacity_min_coeff. Existing databases are migrated automatically. - Multi-output indirect units (e.g. CHP) are no longer capped below their
capacity by their fuel input. The input flow of an indirect unit carries
only a loose solver bound; it was set to
slope × capacity, i.e. enough fuel for one output at full capacity. With several outputs, or an outputconversion_flow_coeffabove 1, that bound became binding and silently limited the unit (e.g. an extraction CHP's electricity pinned well below its cap). The bound now follows the conversion equation:capacity × slope × Σ_outputs(conversion_flow_coeff × capacity_max_coeff) / input conversion_flow_coeff, scaled up for delayed units. capacity_max_coeffon the outputs of indirect units is enforced again. Indirect output flows are bounded by existing capacity (plus investment), but the per-outputcapacity_max_coefffactor was only folded in for direct units, so an indirect output could run up to the full unit capacity.- The capacity of an indirect unit now caps the sum of its outputs, as
documented ("the maximum sum of output flows"). Previously only each output
was capped individually, so a two-output unit (e.g. a CHP) could deliver up
to twice its capacity, and investment solves sized such units by their
largest single output. The new
maxOutputSumconstraint boundsΣ outputs + upward reservesby existing plus invested capacity (times availability); online units additionally boundΣ outputsby the online capacity. Outputs withconversion_flow_coeff = 0stay excluded;capacity_max_coeffremains a per-output cap. - Results of models with multi-output indirect units can change (lower or equal cost where the fuel bound was binding; respected output caps; higher cost or more investment where the sum of outputs exceeded capacity).
- Capacity multipliers now scale invested capacity too.
capacity_max_coeffandavailabilitymultiplied only the existing capacity; invested (and retired) capacity entered the capacity constraints unscaled. An output withcapacity_max_coeff = 0.2on a unit that invested 900 MW could deliver 900 MW instead of 180 MW, and a unit withavailability = 0.5could use all of its new capacity. The flow caps of direct units, indirect-unit outputs, the output-sum cap and the reverse direction of two-way units now applycapacity_max_coeff × availabilitytoexisting + invested − retired. Investment models with such multipliers will build more capacity. - Ramp limits now follow invested capacity and
capacity_max_coeff. The ramp constraints used the existing capacity only, so a ramp-limited unit built by the model could not change its output at all, and the edge'scapacity_max_coeffwas not applied (the documented behaviour). - The reverse direction of a two-way connection can use invested capacity.
Reverse flow of
no_losses_no_variable_costconnections was capped at the existing capacity only. capacity_max_coeff = 0is a zero cap, as documented. The engine treated an edge withcapacity_max_coeff = 0as uncapped (bounded only bymax_flow_for_unconstrained_variables) in its structural flow bound — a leftover from the parameter rename, when the old coefficient was the flow coefficient. The uncapped (pass-through) edges are theconversion_flow_coeff = 0ones; those are now also kept out of the online flow cap, ramp limits and min-load constraints, and are no longer scaled by availability. Themax_flow_for_unconstrained_variablesandcapacity_max_coeffdescriptions are corrected (schema v70).capacity_max_coeffnow applies to online (unit-commitment) units' flow cap, and that cap no longer limits the fuel input of online multi-output units (it is tied to the outputs by the conversion equation).invest_retire_no_limitlifts the investment limits likeinvest_no_limit. It was not recognised as a no-limit method: the unit's maximum capacity stayed at its existing capacity, so a unit with no existing capacity could not invest at all.- Solves no longer write an unused
flextool.solfile, and HiGHS no longer warns "The cost row for the Glpsol solution file is reported last". Thehighs.opttemplate carried GLPK-era solution-file options (write_solution_to_file,solution_file,write_solution_style=2); nothing has read that file since glpsol was retired. They are removed from the template, and the same options in an existing project'ssolver_config/highs.optare ignored (silently for the old template lines, with a one-time warning for hand-edited values). To have HiGHS write a solution file anyway, set these options in the solve'ssolver_arguments. - Dependency versions are capped at the tested series:
polars<3,pandas>=2.2.3,<4,spinedb-api>=0.36.5,<0.37, andhighspy>=1.14.0,<1.15(was exactly 1.14.0 — HiGHS patch releases are now allowed). A weekly CI canary tests the newest releases and reports when a cap can be raised.
Release 4.0.5 (11.10.2026) — polars 2.0 rolling warm-start fix
Patch release. No schema changes.
- Rolling solves no longer receive misaligned time-series values under
polars 2.0. FlexTool requires
polars>=1.40without an upper bound, so fresh installs now pull polars 2.0. When consecutive rolls of a rolling solve have the same size, FlexTool reuses the already-built problem and only pushes the new time-series values (e.g. node demand /inflow) into it. Those values were matched to the problem's constraints by position, relying on a row order that polars 2.0 no longer guarantees. With more than one node the values could land on the wrong node and time step — for example one node's demand dropping to zero every other hour — without any error or warning. The values are now put in the constraints' own order before they are pushed, and the underlying node × time-step tables are built in an explicit order, so results are the same with polars 1.x and 2.x. - If you ran rolling solves with polars 2.0 (check with
pip show polars), re-run them with this release: results of every roll after the first may be affected. Installs that used polars 1.x were not affected. - The GUI opens .sqlite files in the Spine DB Editor also when FlexTool is
started from a virtual environment that is not activated (e.g.
.venv/bin/flextool-gui). The GUI looked for thespine-db-editorcommand on PATH, which does not include such a venv, and so reported the editor missing even though Spine Toolbox was installed. It now starts the editor with the same Python that runs FlexTool, like the command-line launcher.
Release 4.0.4 (21.9.2026) — VRE curtailment output & solver option fixes
Patch release. No schema changes. This release also ships the 4.0.3 changes below, which were prepared but never published to PyPI.
- VRE potential and curtailment outputs no longer collapse to empty when a
wind/solar process leaves
availabilityat its default. The potential-generation calculation multiplied the resource profile by theavailabilityparameter, but that parameter only carries processes that set it explicitly. Profile-driven VRE that relies on the implicit 1.0 default was silently dropped to NaN, zeroing the potential and curtailment outputs. The 1.0 default is now applied to every VRE process, matching how the solver treats it. - Curtailment output no longer crashes when a flow carries more than one
upper_limitprofile (e.g. an availability profile plus a scheduling upper bound). Such flows now collapse to their binding (minimum) potential instead of raising on a duplicate-column subtraction. - Cost summaries no longer crash when every node uses
penalty_method=off. With node-state slack disabled model-wide the upward/downward slack-penalty lookup raisedKeyError; it now falls back to zero, matching the reserve-slack terms. - The
highspyrequirement now has a lower bound of 1.14.0. Autoscale Layer 3 conditions the objective through HiGHS'user_objective_scaleoption, which older 1.x wheels (before HiGHS renameduser_cost_scale) reject — silently degrading the solve to an un-conditioned objective. Fresh installs can no longer resolve to a wheel that lacks the option.
Release 4.0.3 (11.9.2026) — multi-period stochastic continuation fix
Patch release. No schema changes.
- Multi-period stochastic solves no longer silently truncate the horizon after the branching period. Previously, periods after the one where the stochastic branches fan out received no active timesteps at all — the solve built zero variables for them and quietly optimized a shortened horizon. The branch fan-out now extends through every continuation period: the realized branch keeps the real period names (with its realized output windows), and each non-realized branch with a nonzero weight gets its own copy of every later period, with the branch probability weights carried forward along each branch.
- The
years_representeddefault now covers every period of a solve. When a solve does not defineyears_represented, each of its periods is meant to default to representing one year. The defaulting logic seeded only a single (arbitrary) period, so multi-period solves relying on the default failed the years-coverage check — including plain deterministic ones. Explicitly definedyears_represented(full or partial) behaves exactly as before. - Cross-period step linkage follows each branch's own lineage. The first timestep of a continuation period (storage state carry-over, ramp linkage) now links to the last timestep of the same branch's previous period, instead of self-cycling or linking positionally across branches.
- Period year-share rows no longer leak between periods with
prefix-overlapping names (e.g.
p1/p10): the branch-copy matching in the years-represented bookkeeping now uses exact equality instead of substring containment. - Newly explicit error. Stochastic solves now require the solve's
years_represented(when defined) to cover all of the solve's periods, including continuation periods. Configurations that defined it for only some periods previously ran silently on the truncated horizon; they now fail with a clear configuration error. - Tests. New hand-calculated multi-period stochastic fixture (two periods, branch-varying timeseries) verified down to the objective value, plus unit tests pinning the continuation fan-out, cross-period step linkage, and the year-share fix.
- Docs. New "Stochastics over multiple periods" subsection in the how-to describing the continuation fan-out semantics.
Release 4.0.2 (2.9.2026) — results-database schema seeding fix
Patch release. No schema or model changes.
- The results database is no longer seeded from an outdated schema. On
install/update,
flextool-updatecreatedtemplates/results_template.sqliteandresults.sqlitefrom the legacy pre-v26 template (oldobject_parameters/relationship_parametersformat) rather than the current results schema, which could break the results-import / Spine Toolbox workflow. It now usesschemas/spinedb_results_schema.json(modernentity_classes/parameter_definitions), consistent with the output-write path'sensure_results_db. (#329)
Release 4.0.1 (1.9.2026) — Spine Toolbox install & detection fixes
Patch release fixing the in-app Spine Toolbox install flow (all GUI, no schema or model changes).
- Install Spine Toolbox from the GUI now works. The install action was tied
to the "Update FlexTool" button, which is disabled when FlexTool is already the
latest — so on a fresh install (always the latest) the Spine Toolbox extra
could never be installed. It is now a dedicated "Install Spine Toolbox"
button, shown only when Toolbox is missing, that installs just the extra
independent of any version update (
pip install "flextool[toolbox]", or the editable-e ".[toolbox]"in a git checkout). - Environment-scoped Toolbox detection and launch.
toolbox_installed()no longer falls back to a PATH lookup forspine-db-editor, which reported "installed" when a copy from a different environment was on PATH even though Spine Toolbox was not in the venv running FlexTool. Detection now checks only the current environment, and the Spine DB Editor is launched from it (python -m spinetoolbox.spine_db_editor.main) rather than a PATH executable — so the dialog and the open-.sqlitepath agree. - Docs. The recommended install now pulls the Spine Toolbox and result-viewer
extras (
pip install "flextool[toolbox,viewer]"); a note explains that plainpip install flextoolgives a spreadsheet-only install without the Spine DB Editor.
Release 4.0.0 (31.8.2026) — first stable v4: pip-installable; net-load representative-period clustering (schema v68); dispatch-plot & colors GUI rework; multi-use storage & existing-capacity output fixes
This is the first stable 4.0.0 release. FlexTool is now pip-installable
from PyPI — pip install flextool — instead of only being available as a git
distribution; main is now the default branch and master is deprecated.
Database migration v67 → v69 (two steps, automatic on load): v68 adds the
representative-period group flag (additive, defaults to prior behaviour — a
migrated database selects representative periods byte-identically until you opt
into net-load clustering), and v69 backfills parameter groups for two previously
ungrouped parameters (model.small_number_threshold, node.penalty_method) so
they are no longer dropped from group-filtered tabular exports.
Packaging & distribution
- Now on PyPI (
pip install flextool). A new OIDC trusted-publishing release workflow builds the sdist + wheel on everyv*tag, verifies the tag matches thepyprojectversion, installs the wheel into a clean venv to confirm imports, bundled package data and console scripts, twine-checks the metadata, and publishes to PyPI. process_inputs/*.jsonnow ship in the wheel. The xlsx/CSV import spec (import_old_excel_input.json) loaded at runtime by theflextool-read-tabular-inputCLI was covered by no package-data glob, so a pip-installed wheel (and the sdist) raisedFileNotFoundErroron that path; a tight glob now bundles it.mainis the default branch;masterdeprecated. CI (ci.yml/tests.yml) now fires on push/PR tomain, and a new docs workflow builds the MkDocs site frommainand publishes it togh-pages, replacing the manualmkdocs gh-deploy. The README now calls out polar-high's automatic problem scaling as its own capability (better conditioning often speeds the HiGHS solve well beyond the faster build).- GUI tests that open a real Tk window are opt-in (the
guimarker /--run-guiflag), skipped by default locally and in CI.
Representative periods — 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. Energy is duration-weighted, partial-coverage series are skipped, and co-located VRE raises a warning. 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 bootstraps on demand-match capacities, then 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, deterministically re-materialising an earlier winner. Subsets are run via amodel.solvesoverride alternative; a fail-closed guard rejects an active timedenergy_margin_adder.
GUI — dispatch plots & colors/order
- Live colors/order picker. The dispatch colors/order dialog now updates the plot live (debounced) and drops the separate Apply / Refresh-from-DB buttons; closing the dialog commits the edit instead of reverting it, and edited colors/order are honoured on plain navigation and on live edits even without a tree selection.
- Two-pane category/entity layout, driven by solved output. The dialog uses
a fixed category list plus an entity list (categories are not reorderable) at
a narrower/taller default size. Entities are discovered from the solved
output_parquet(dispatch) rather than the input DB — the DB is kept as a pre-solve fallback and the picker seeds from the union of input-DB and dispatch-output aggregates — and newly-discovered entries are ordered by standard deviation as a one-time default (deterministic name tie-break), while a storedplot_settings.yamlremains the source of truth. - Stack & legend ordering. The per-node dispatch stack follows the
plot_settingsorder, mixed-sign entities are placed adjacent to the zero axis, negatives are ordered like the list, and the legend reads top-to-bottom to match the stack. flowGroup bands take precedence over nodeGroup for band colors/order and reorder with their section; the colors/order dialog lists only dispatch-aggregator flowGroups; a special-category order is engine-fixed with an immovable, highlighted "flowGroups" divider marking where flow bands stack, and specials are reorderable only within each sign group (divider as the boundary) while their colors stay editable. Dragging reaches the top slot, Alt-arrow reorder works, and dropping onto unselected rows applies. - Plot fixes. Line traces and grouped bars are ordered by color-map key order, a lone grouped bar no longer overflows its slot with value labels, and the nodeGroup dispatch y-axis is scaled per scenario.
Outputs
- Multi-use storage nodes surfaced in node balance & price. Storage nodes were unconditionally dropped from the node-balance summary and nodal-price outputs to keep plain batteries from cluttering results, which also hid genuinely interesting multi-use storage hubs. An arc-level heuristic now surfaces a storage node only when it is wired to more than two distinct processes, so a battery stays hidden while a multi-use hub appears automatically — also the economically sound price split.
existingcapacity column is the pre-invest baseline, not cumulative. The unit/connection/node capacity outputs sourcedexistingfrom the cumulative chain-sum, so on a multi-solve run a later dispatch/rolling sub-solve reported previously-invested capacity underexisting. It now comes from the static pre-invest baseline;investedstays the per-sub-solve/roll investment andtotalstill carries the cumulative capacity, propagating to CSV / Excel / SpineDB / plots / GUI viewer.- Removed dead legacy per-period capacity CSV writers. An exhaustive sweep
found no live consumer of the
*_capacity__period.csv/entity_all_capacity.csvphase-3 artifacts (all paths readoutput_parquet/*.parquet), so the writers and their exclusive loaders were removed. - Upgrade-safe results DB schema.
ensure_results_dbshort-circuited on file existence, so additive schema changes (e.g. the per-entity cost break-down parameters) never reached aresults.sqlitecreated by an older FlexTool version andwrite_spinedbcrashed at end-of-run. It now takes a lock-free read gate that additively tops up a lagging DB under lock (a no-op when already current).
GUI — Calibrate investments
- Three-way RP-alternative disposition. The representative-periods tool's
single "add to scenario" checkbox is now a radio:
detached(create but attach to nothing),add(append to each selected scenario's stack, the default), ornew_scenario(clone each scenario into<scenario>_<alternative>carrying the same stack plus the new alternative). The legacy boolean setting migrates automatically (True → add, False → detached). - Final outputs from the project's File-outputs choices. The calibration dialog now regenerates the same output formats a regular run would — the project's "File outputs" checkboxes — from the final parquet, so a calibrated scenario's results match a normal run without a re-solve. The live CLI preview and the run path share the same setting.
- Per-scenario report folder. Calibration report CSVs are now written under
<output-location>/calibration_reports/<scenario>/instead of a flatreport/, so calibrating several scenarios into one shared output root no longer silently overwrites one scenario's report with the next. Final results are regenerated from the last iteration's parquet via the engine's disk-replay path, without re-solving.
Fixes — invest & representative periods
- Var-unit inter-period storage balance for blended weights. The representative-period blended-weights inter-period storage coupling multiplied the seasonal-drift right-hand side by unit size, but both sides of the balance are already var-units, so a storage node with unit size ≠ 1 (the default is 1000) either slammed its inter-period state into the invest ceiling or collapsed the store to zero. The spurious factor is removed; byte-identical at unit size 1.
max_flow_for_unconstrained_variablesoverride now honoured. Two source- parameter lookups queried the parameter under a spuriousp_-prefixed name that never matches the DB, so everyinvest_no_limitentity was silently capped at the hard-coded 1e6 default regardless of the model override. Both sites now query the raw DB name (the 1e6 fallback is kept for genuine absence).- Representative-period hull seeded with force-included periods. The
generator selected the greedy hull blind to force-included periods and bolted
them on afterwards, so forced and clustered picks could redundantly represent
the same region. Force-include now runs before clustering and seeds the greedy
hull (filling the rest to complement the forced periods), with the total
pinned at
n_rp. Thegrow/fixedcount modes and--force-count-modeare removed; no seed reproduces the byte-identical default. - v38
node_typemigration floor. The v38has_balance/has_storage→node_typeconsolidation blanket-wrotecommodityinto the Spine defaultBasealternative, which — being often the highest-ranked alternative — overrode realbalance/storageassignments in lower-ranked alternatives, turning every node into a commodity node and crashing the solve on a zero-balance node. The migration now writes thecommodityfloor into a dedicated lowest-rank alternative (default_node_type_from_migration) so any real assignment always overrides it.
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 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 a large multi-country 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 a large multi-country 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 customer-model 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— customer-model 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
a 1-year multi-region DES model (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 a 1-year multi-region DES model (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 scenarios of a large customer model.
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, customer-model 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, customer-model tests,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) — bug-fix suite (large customer model)
Built on 3.37.0's in-memory cascade. Fixes a cluster of regressions that surfaced on a large 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
- Customer-model bug 1+5+6: engine_polars/scaling.user_bound_scale recommendation is now column-only (a previous row+col recommendation collapsed solver progress on two of its scenarios)
- Customer-model bug 2 / BUG A2: engine_polars casts user_constraints coefficients to Float64 — previously left as int when authored as integer, breaking polars joins downstream
- Customer-model bug 3: co2_price shape probing uses value-domain inspection instead of column count when the shape is ambiguous
- Customer-model 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 customer-model regressions (an hourly representative-period base case and a CO2-cap slice) 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