Skip to content

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 new capacity_margin__d.
  • discount_rate 0 now means 0 % (before: 5 %), a negative rate is an error, and an entity that invests or retires with a cost needs a lifetime. Set 0.05 to keep the old results.
  • A zero or missing invest_cost no longer blocks investment (nor a missing salvage_value retirement) where invest_method allows it. To forbid a period, use a _period method with invest_max_period 0, or not_allowed.
  • A solve-level new_stepduration combines every time series by rule (mean, harmonic mean for efficiency, sum for inflow and energy_margin_adder); before, most took the first time step's value. To keep the old energy_margin_adder demand, divide it by the number of time steps a solve step combines.
  • VRE curtailment, VRE potential and a flowGroup's cumulative_flow per 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, existing as a time series, a used timeline without timestep_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, a realized_invest_periods outside invest_periods, partial or ungrouped stochastic inflow / reservation, a roll of a stochastic rolling solve that reaches no forecast, a realized: yes row that switches the realized branch between rolls, a contained single solve that would run under one name with different periods, a time-only energy_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_coeff flows, negative existing, time-series keys off the timeline, a CO2 cap on a method without one, an invest_method on 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_name are read as written. A profile keyed by periods, or a period × time profile with an unnamed level, read 0 before; a node penalty, availability or other_operational_cost time map without index_name no 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_cost is now charged on unit flows with ramp_method ramp_cost or both (new cost category ramps).
  • The engine refuses a database that is not at the current version. Migrate it with flextool-run --migrate or flextool-migrate-database; the FlexTool window, the Spine Toolbox workflow and execute_flextool_workflow.py migrate 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 / __dt suffix, several shipped sheets are renamed, a unit of measure header row or a unit block is added, and dispatch data has a period column. 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 keep output_<scenario>.xlsx.
  • Smaller output changes: a storage node's entity_annuity_d_p is per MWh (was per MW; values unchanged), no_reserve groups no longer have an empty reserve-shortfall column, and only groups whose co2_method caps 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, interest 0). 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_dispatch solve without realized_periods gets 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-limit and --solver-mip-gap now reach Gurobi, CPLEX, Xpress and COPT and win over the database and option file; the environment variables are now FLEXTOOL_SOLVER_TIME_LIMIT / _MIP_GAP (the FLEXTOOL_HIGHS_* names still work for HiGHS only). Commercial runs with these flags can give other results.
  • --threads now 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 and solver_arguments, as for HiGHS, even a value meaning all cores (0); the run prints a line when it replaces a value. A GUI run with Threads 0 in gurobi.opt now uses 1 thread: raise Threads per run. Without --threads and FLEXTOOL_THREADS the option file keeps it; per-algorithm thread controls (Xpress BARTHREADS, COPT BarThreads, ...) are not changed.
  • A Spine Toolbox run uses 1 thread unless --threads or FLEXTOOL_THREADS is 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 N to 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, Gurobi BestObjStop). A memory limit, an interrupt, or a solve not certified optimal still stops the run. A HiGHS MIP solved with objective_bound gets 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; Gurobi BestObjStop, BestBdStop, Cutoff, MIPGapAbs; CPLEX cutoffs, objective stops and absolute gaps; Xpress MIPABSCUTOFF, MIPADDCUTOFF, MIPABSSTOP; COPT AbsGap. 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's scale_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_config folders, 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 own solver_config/: its runs take each solver's option file from the project, else the workspace, else FlexTool's default. Check the Solver config: line of the run log, which names the folder of each file, and move your .opt files 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 gets solver_mip_gap; one value per solver parameter, whatever its case; Xpress solves a MIP with mipoptimize (it returned the LP relaxation); Gurobi file-writing parameters are not passed on. A CPLEX option name starting with set (FlexTool adds it) is refused with a message naming it, from every source (CPLEX refused the set 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_periods or period_timeset), the split solves ran on HiGHS, without solver_mip_gap and with the solver_arguments names 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 its solver_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_periods or fix_storage_periods splits a solve into one solve per row (<solve>_<row>). Before, the split solves silently lost parameters of their solve: since 4.0 stochastic_branches and stochastic_invest_method (they ran deterministic) and new_stepduration (they ran on the coarser time steps with hourly data, at a fraction of the demand); since 4.1 dispatch_realization_method (a separate_dispatch solve had no realization solve) and shared_operation_periods / shared_invest_periods. A 1-D realized_periods, realized_invest_periods or fix_storage_periods next 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, and years_represented its own periods (discounted from the start of the run, see the entry on discounting below). A solve listed after the split solve in model.solves now 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 (or realized_invest_periods) next to a 2-D invest_periods that 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. Make realized_periods a 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 ignore stochastic_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 leave stochastic_branches out of the solve that has none of their periods. A 2-D period_timeset is now an error too: give period_timeset all 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_branches rows 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_periods or realized_invest_periods) of the row of the 2-D maps whose solve should realize it.
  • years_represented without 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 example year) now stops a model with investments: name the index period, or leave it without a name.
  • Runs of several solves with an inflation_rate other 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 its years_represented held 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 whole years_represented map), 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 fractional years_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. A years_represented map 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.csv slack lines and the period reserve averages use the time-step weights; without realized_invest_periods a solve realizes only the realized_periods that are also invest_periods; invest_period_total could retire and retire_period_total invest; a virtual_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 mipgap without its value) and a group of parameters with a value (mip tolerances 0.01). An empty HiGHS value from solver_arguments or a flag, which --save-memory already 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_arguments could make the solver, HiGHS included, write over or read files anywhere on your computer (for example HiGHS solution_file / log_file, Gurobi LogFile / InputFile, CPLEX workdir, Xpress TUNEROUTPUTPATH), 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's license.dat / license.key / client.ini, Xpress's xpauth.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 (CPLEX cpumask), is refused with a message naming the option, its value and the solve. Move such options to solver_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 an XPRS_ prefix, CPLEX reads . as a space); the full list is in the solver docs. Gurobi's ResultFile is FlexTool's own and is ignored, with a warning, wherever it is set. FlexTool's results are in output_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 (with flextool-run or 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_arguments may 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 (write mip tolerances mipgap, not mip 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's JobID / Dummy and CPLEX's defaults. FlexTool 3's solver, parallel and presolve (moved into solver_arguments by 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 in solver_config/<solver>.opt (an unknown name there gives a warning and is passed on), or allow such names from databases on your computer with FLEXTOOL_ALLOW_UNLISTED_SOLVER_PARAMETERS=1 or "allow_unlisted_parameters": true in solver_settings.json in 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 CPLEX distmip ...) is refused, and so is a CPLEX abbreviation of a listed name (d c for distmip 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 .opt files now get the same limits as the database's solver_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 in solver_settings.json in its user settings folder, outside the workspace: flextool-update and the FlexTool window add their workspace when they create its solver_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 edited solver_config/*.opt and 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-dir or FLEXTOOL_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's Solver config: line and the run record say whether the folder was trusted. The same holds for a GUI project's own solver_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 run flextool-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_HOME and XDG_CACHE_HOME must 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.json in 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_precommand is 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, .sh and the like) or files outside input_sources/, output_parquet/, solver_config/ (.opt files), settings.yaml and plot_settings.yaml; an export leaves such files out of input_sources/ and the result runs, with a note. Solver option files travel only in the package's top-level solver_config/: a package with a solver_config or imported_solver_config folder 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-url in the Output settings database must now be a sqlite: URL (a file on any drive, a mapped network drive or a UNC share \\server\share too). Another URL there stops a run that writes the results SpineDB (output-spinedb), before the solve, with a message; with output-spinedb off 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 also wsl.exe, bash.exe and similar programs that read their arguments as a command line (use a program, or python with a script). FlexTool 3 users of solver_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 of PATH, 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 (m3 for water, t for hydrogen, MWh_th for heat; default MWh). 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, Units and _output_metadata sheets), 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_th shared, MW; MW_th per 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 labelled Mixed 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-row unit of measure column in Excel, a row column in units.csv and 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 of default_plots.yaml are unit templates ({A}/h, {R}, CUR/{kR}) that read as before for MWh nodes; a label in your own plot_settings.yaml without 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 (inflow in m3, penalty_up in CUR/m3, startup_energy in m3 per MW started) and does not add up values of nodes of different units. A node without unit_of_measure is 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 is invest_cost 0.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 Units sheet: one row per result sheet with its table, unit(s), semantics and description, the sheet names linking to the sheets; _output_metadata follows it. A sheet with several header rows (two or more column levels; a comparison adds the scenario level, so this is every comparison sheet except those of entity-less tables such as costs_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 at A1, but its data is now an Excel Table tbl_<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_excel of the whole sheet also returns those columns: read the Table, or pass usecols). The CSV files are unchanged; a new units.csv lists the unit of every value column (file, position, header labels, unit, semantics), and datapackage.json now names each field by the header written to the file (labels joined with | as Frictionless joins them, headerRows for several header rows, commentRows for 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); the Units and _output_metadata sheets hold the same information, and in a comparison _output_metadata gives the scenario of a column in its own column. A mixed-unit table with unitless columns lists per unit among its units in the results database, the plot labels and the GUI hover as on the Units sheet (for example MWh/a; per unit). units.csv and datapackage.json are reserved names in the rename section. Fixed: the per-column units of reserve__upDown__group__d (and of any mixed-unit table with several column levels) were missing from the parquet metadata, the _output_metadata sheet and datapackage.json, and the result tools of the AI assistant gave the capacity-margin penalty_cost and capacity_price columns the unit MW. A single-result write updates only its own entries in units.csv and datapackage.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_method share_of_peak with group.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 and new_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 database group.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 prints capacity 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 database group__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 prints reserve 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_period node 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, like startup_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 of constraint_flow_coeff, flow groups or the capacity margin; left out with the start-ups under reduce_unit_detail_method online / online_ramps, and a use of the online variables under integers. New outputs unit_startup_energy__dt / __d (results database unit__node startup_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 the constant is 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 on unit and connection (index: constraint name) puts the number of units online in each time step on the left side of a user constraint, for example "at least two of three units online": constraint_online_coeff 1 on each unit and constant 2 with sense greater_than. One unit is virtual_unitsize (else the largest existing capacity, 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_method linear or binary; on a unit also a minimum_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_cost is charged. A unit flow with ramp_method ramp_cost or both pays ramp_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 any ramp_speed; both adds the limit. On a constant_efficiency unit with one input and one output, an input-side cost prices the change of the unit's output flow. A negative ramp_cost is 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 of ramp_method and ramp_cost only); a model whose ramp_method was already ramp_cost or both with a ramp_cost value now pays that cost.
  • inertia_limit and non_synchronous_limit per 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 whose reduce_system_detail_method leaves 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 (group new_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_weights storage 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 row rp_inter_period_fan_start, also with fix_start). For a storage node in an include_stochastics group 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 of storage_binding_method and representative_period_weights only); 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 a solve to make that solve faster, typically an investment solve followed by a full dispatch. none (default) keeps everything. integers: units with startup_method binary get continuous units online and exact connections behave as regular, so the solve is an LP. integers_ramps also leaves out ramp limits and ramp costs, integers_min_limits min_load, the capacity_min_coeff floors 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_efficiency units 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 a constraint_online_coeff term 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 or capacity_min_coeff floor, minimum up / down time, part-load curve, online term in a user constraint, reserve, inertia, ramp limit or parent commitment left) loses them, as with startup_method no_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 of dc_power_flow_with_angles groups: 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 or exact method 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_method or reduce_system_detail_method other 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.json records reduced (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 (also run_flextool.py, python -m flextool.cli.cmd_run_flextool) migrates an older input database before the run, the same way as flextool-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 --migrate on 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 and execute_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, set FLEXTOOL_MIGRATION_LOCK_TIMEOUT in 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, --unfreeze removes the mark and --status shows it. A frozen database is never migrated in place without consent: flextool-migrate-database exits 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-update skips 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, or flextool-project export|import|verify in 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.yaml without 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.sqlite and scratch folders are left out. manifest.json records 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>_2 when 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's solver_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 with set (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 in imported_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 to solver_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 all leaves it out); a .sqlite input that may be in the middle of a write is copied through SQLite, -wal/-shm/-journal files 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 (--force in the terminal); errors are one message, no traceback; a stopped import's hidden .<name>.partial folder is not listed as a project; import names inputs left out of the package; verify prints one pip install line pinning FlexTool, polar-high, highspy and polars.
  • A project's own solver options, file by file. Put a <solver>.opt in projects/<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-dir can be repeated, and FLEXTOOL_SOLVER_CONFIG_DIR takes several folders separated by : (on Windows ;), highest first; only the folders given are read. With FLEXTOOL_SOLVER_CONFIG_DIR set 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 with set) 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, a subplot column when a file has several subplots, values after multiply_by; empty result cells stay empty. Turn it on with Write plotted data (CSV) in the GUI's Png settings, --write-plot-data on flextool-run, flextool-write-outputs and flextool-scenario-results, or a top-level write_plot_data: true in the project's plot_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 N for another) and plots off unless --plots on|both; a --db database 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 --strict when a run is over the target; --csv writes the table to a file. See Check that exercises run fast enough in Running FlexTool from the terminal.
  • read_input row in timings.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.xlsx writes 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 with flextool-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, an invest_method that 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. See explain_entity in "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, /retry asks the last question again and /edit changes 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, --scenario in 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 with new_stepduration), the share of a year of each realised period (from period_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 in get_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_tables knows 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_n 0) are left out instead of giving an empty or refused result; list_runs says how many runs its filters hide, list_entities when 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"), and explain_entity says first when a limit is set that the invest_method does 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, in query_result and compare_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 --alternatives names 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_summary Spine Toolbox item is dropped from an old project when FlexTool updates it, and its specification (which named a missing open_summary.py) is no longer shipped.

Investment and discounting

  • An invest_method offers investment and retirement also without invest_cost / salvage_value. A unit, connection or node whose invest_method allows investing now has its investment variable in every investment period of the solve, also where invest_cost is missing or 0; one that allows retiring has its retirement variable in every period with existing capacity, also without salvage_value. Such capacity has no annuity; its fixed_cost still 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 an invest_method without a cost silently offered nothing. A 0 in an invest_cost map 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 _period invest_method (invest_period, invest_period_total and their invest_retire_ forms) and set invest_max_period 0 for it, or use not_allowed: the other methods do not read invest_max_period. With neither invest_cost nor fixed_cost the invested amount is arbitrary (any amount is optimal). A zero-cost investment needs a lifetime only with lifetime_method reinvest_choice / no_investment. The FlexTool 2 importer no longer reports a storage with a max invest (MWh) and no inv.cost/kWh: FlexTool 4 now adds it at no cost, as FlexTool 2 does. Fixed with it: invest_period_total allowed retiring and retire_period_total investing (the method lists left them out), and an invest_period_total entity with a salvage_value and no lifetime got a false lifetime error. The database upgrade writes the marker invest_without_cost_offered on a database with an entity that can invest (retire) without an invest_cost (salvage_value) or with a 0 in it; the run then names the entities and periods that are newly offered.
  • discount_rate 0 is 0 % (annuity = invest_cost / lifetime); before it was 5 %. Any discount_rate of 0 or below used to be replaced by 0.05 in the annuity, and a missing or non-positive lifetime by 20 years. Now 0 gives the straight-line annuity (salvage_value / lifetime for a retirement) and a missing rate is the schema default 0.05, as before. A negative discount_rate is an input error. A unit, connection or node that invests with an invest_cost or retires with a salvage_value without a lifetime is an error, and so is one with lifetime_method reinvest_choice / no_investment that invests, retires or has existing capacity (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 an interest of 0 as discount_rate 0 (before it was left out, so it became 5 %); a lifetime of 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_periods lists a period that is not in its invest_periods now stops with an error naming the solve and the periods. This includes a solve with realized_invest_periods but no invest_periods (before, a rolling solve only got a warning and a single solve ignored the value): a database with a leftover realized_invest_periods on a dispatch or rolling solve needs that value deleted. A 1D realized_invest_periods on a solve whose invest_periods is 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 every realized_periods period (#319). It now realizes the realized_periods that are also invest_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_periods but not in realized_periods was missing from the results. The solve made the investment, but v_invest / v_divest had no row for that period, so unit_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_stepduration combines every time series over the new time step (results change). A solve with its own new_stepduration used to take the value of the first original time step of each new step for every time map other than profiles and inflows: commodity price, co2_price, availability of 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_weights and 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_value takes the value at the step's end; inflow stays 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 a min_load that varies within a new step makes the min_load_efficiency curve of that step approximate (a warning names the units). Results change for models that combine a solve-level new_stepduration with such time maps; models without a solve-level new_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 with new_stepduration prints a notice naming the parameters and entities whose values change.
  • energy_margin_adder is summed over a solve's new_stepduration step (results change). The adder is MWh per time step of the timeline. When the investment solve has new_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 both new_stepduration and energy_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-only energy_margin_adder map (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 notice capacity_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__d showed 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__group whose group has no group__node row 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 with reserve_method no_reserve). Models where every reserve group has nodes are unchanged.
  • A reserve group with reserve_method no_reserve no longer gets reserve variables. Such a group (also a reserve__upDown__group without 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_method off, 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__dt and 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_time no 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's scaling (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 default x silently 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 in shared_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_branches rows 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_periods look 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 with KeyError in 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_solve that 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 a rolling_window solve, 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 a rolling_window solve, 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_arguments entry 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-memory HiGHS solve, are now built from the merged parameter list itself rather than from the <solve>.opt file written and read back; <solve>.opt stays 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_arguments entry such as log_file, write_model_file, read_basis_file, Gurobi InputFile / ComputeServer / CloudAccessID, CPLEX workdir or Xpress TUNEROUTPUTPATH could 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 (Xpress COMPUTE...) is refused, with a message naming the option, its value and the solve and saying to set it in solver_config/<solver>.opt instead. 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 an XPRS_ prefix, CPLEX abbreviations of its file options), so another spelling does not get past the check.
  • solver_precommand is 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 of solver_arguments are updated by the v79 migration.
  • --solver-time-limit and --solver-mip-gap now apply to Gurobi, CPLEX, Xpress and COPT (the GUI's time limit too), and win over the solve's solver_mip_gap, its solver_arguments and 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 now FLEXTOOL_SOLVER_TIME_LIMIT and FLEXTOOL_SOLVER_MIP_GAP; the old names FLEXTOOL_HIGHS_TIME_LIMIT and FLEXTOOL_HIGHS_MIP_GAP still work, for HiGHS only, so a setting made for HiGHS never limits a commercial solver. --threads sets 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=0 a 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, HiGHS mip_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, Gurobi BestObjStop / BestBdStop) keeps its solution with a warning, as at a search limit. A HiGHS MIP solved with objective_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 by scale_the_objective and HiGHS's user_objective_scale (set by the autoscaler or by you) before the solver gets it, in-process, with --save-memory and in the commercial solvers' option files. Before, the value reached the solver unchanged, so it meant a different amount for every scale_the_objective and autoscale factor. The Solver args line 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-memory or 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-memory solve 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 as 0.0000%.
  • A --save-memory HiGHS 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 Windows taskkill or 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_arguments now reaches HiGHS. The input database stores every number as a decimal (3.0), and HiGHS refuses that for a whole-number option such as user_objective_scale or simplex_iteration_limit, and 1.0 for a true/false switch: an in-process solve dropped the option with a warning, a --save-memory solve 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 got 3.0 as 3).
  • 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-memory as well.
  • Solver option files come from one solver_config folder chosen per run, no longer from the folder FlexTool is started from: --solver-config-dir (new) or FLEXTOOL_SOLVER_CONFIG_DIR, else solver_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 .opt files 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 is solver_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, whose solver_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 name mip.tolerances.mipgap, which the CPLEX interactive optimizer that FlexTool runs does not know; it is now sent as mip tolerances mipgap. CPLEX results with a solver_mip_gap can change.
  • One value per solver parameter. Parameter names from the option file, solver_arguments and the flags are now matched as the solver reads them: without regard to case (mipgap and MIPGap are one parameter; for CPLEX a dot is read as a space; Gurobi also ignores _, Xpress an XPRS_ 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 TimeLimit in solver_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 ResultFile is 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 lpoptimize for every solve. A solve with integer variables now runs mipoptimize, 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 by MAXTIME, MAXNODE or MAXMIPSOL with 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 sets OUTPUTLOG 1 (and MIPLOG 1 over MIPLOG 0) after your controls, with a printed line. --solver-time-limit on a MIP is now a hard MAXTIME -<limit> (a positive MAXTIME stops a MIP only once it has a solution); a MAXTIME in xpress.opt or solver_arguments is passed as written. The log texts were checked with the xpress Python package, not yet with a licensed optimizer console.
  • The Solver args line of a commercial solve showed HiGHS options; it is now Solver 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 as solve_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 silent now also hides a commercial solver's log (FlexTool still reads and saves it); verbose remains HiGHS-only.
  • Whole numbers reach a commercial solver as integers (Threads=4, TimeLimit=60, not Threads=4.0), from solver_arguments, solver_mip_gap and the flags alike (the database stores every number as a decimal). An integer parameter can refuse 4.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, the solver_options map, solver_time_limit / solver_threads); their header now gives the actual mechanism and precedence. The FlexTool window (at start) and flextool-update replace a workspace solver_config/<solver>.opt that 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 lacked parallel=off, which FlexTool sets anyway). A seeded .opt file 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_node and the dual_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-memory run 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-run and 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_weights storage 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_method is 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). With fix_start / fix_start_end each forecast starts from the storage_state_start share 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 / time map) 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 every include_stochastics group, 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). A separate_dispatch solve without realized_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: yes branch run again (4.2.0 to 4.3.0). A run of two or more solves in which a stochastic solve designates a realized: yes branch 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: yes branch. The solve made the investment, but v_invest had no row for the period before the reveal, so unit_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_branches at 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: yes row that switches the realization is an error. In a rolling solve, a realized: yes row 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 inflow outside every include_stochastics group needs a realized branch. In a fan without a realized: yes branch 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 an include_stochastics group, 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_period or co2_max_total on a group whose co2_method has no such cap is reported. The value is ignored (for example co2_max_period with co2_method price); the run now warns, naming the group, the alternative and its method. Before, such a group with co2_max_period was 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 covers co2_max_period, inertia_limit, non_synchronous_limit, capacity_margin, the per-period investment and retirement limits, cumulative_max_capacity / cumulative_min_capacity and 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_period with 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_flow and min_instant_flow dropped 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_method has period in it and no co2_max_period got a cap of 0 in every period (or the run stopped with an internal error); a total method without co2_max_total had 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_method defaults to upper_limit on unit__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. A node__profile or connection__profile link without a profile_method (default none: not applied) gets a warning.
  • A unit flow with conversion_flow_coeff 0 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), and capacity_max_coeff / capacity_min_coeff on 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 efficiency at or below 0, unit min_load outside [0, 1], availability below 0, capacity_max_coeff below 0, an invest / retire / cumulative minimum above its maximum (total, and per period) when the invest_method uses it, lifetime at or below 0 on an entity that can invest or retire, and existing as 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: availability above 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), connection efficiency above 1 and virtual_unitsize at or below 0. Model parameters → Parameter groups and conventions has a new section Input checks listing them. Valid data runs as before.
  • A negative existing ran without a word (#170). A unit, connection or node with existing below 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_load or invest / retire limits it can make the problem infeasible.
  • An invest_method on 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 the invest_method or make the node a storage node.
  • A virtual_unitsize at or below 0 was not ignored, although the warning said so. A negative value became the unit size and multiplied existing (200 units of −5 MW: an anti-energy unit of −1000 MW). Now it is ignored as the warning says: existing is 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 positive inflow, or whose units and connections only bring energy into it and that has no negative inflow (demand), gets a warning too. A connection's direction follows its transfer_method, or the transfer_method of a group that holds both its nodes; a unit with a negative existing counts 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 existing 0, an extra key such as p2030x was ignored, and startup_cost keyed 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 or model.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 in model.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 without index_name of 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_duration stopped the run. Only the timelines a solve uses (through the timesets of its period_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 without index_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_duration block whose start is not a time step of the timeset's timeline (a typo, a different case such as T0001) was left out of the model, and when no block was left the error pointed at the timeline parameter. 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 whose timeline is 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_name of a node penalty_up / penalty_down / availability / self_discharge_loss / storage_state_reference_value or of an other_operational_cost added its time steps to the model's periods. It now reads only as a time series, as the same map with index_name time does.
  • A run reloaded from edited solve_data files 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 profile map without index_name keyed 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 with index_name period 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 its index_name to period or time. A profile period map (with or without index_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) profile map with an unnamed level gave 0. Two shapes were affected: a map without index_name on its outer level (its inner level with or without index_name time), which was taken for a stochastic map (those have three levels), and a map with index_name period 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 with index_name period 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 constant when another constraint had one (since 4.0). When some constraint used constraint_cumulative_pre_built_capacity_coeff or constraint_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_coeff or constraint_in_construction_capacity_coeff (capacity an earlier solve decided), and constant was a single value, every constraint without such a term got a right-hand side of 0 instead of its constant (e.g. flow ≤ 800 became flow ≤ 0), in per-step and once-per-period rows alike. Models without these terms, or with a period-map constant, 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.md says 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 in flextool-run's console output and _run.log, in a model run's own console output, in flextool-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 example flextool-time-runs and 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__d and the cumulative_flow of group_flow__d were 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_flow is 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 tables group_node__horizon and group_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__dt and group__unit__node_inertia__dt had 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_p holds 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 (was CUR/MW), or CUR/m3 for a node whose unit_of_measure is m3. A unit's or connection's capacity is a rate, so its annuity stays per rate unit (CUR/MW, or CUR/(m3/h)). This changes the storage-node columns' unit in the parquet footer, the Excel unit row, Units and _output_metadata sheets, units.csv and datapackage.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.csv are weighted like the objective. The Created / Removed / Inertia / NonSync / Reserve lines summed the slack over the time steps × duration without the timestep weights, so with timeset_weights or 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 solve column of the …__d tables (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__dt became the sheet unit_curtailment__outputNode__d and the real per-period table unit_curtailment__outputNode_1. A long name now keeps its __d / __dt suffix 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 table unit_curtailment__outputNode__d / __dt gets the sheet name unit_VRE_curtailment__d / __dt like 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.xlsx now have a period column before time.
  • 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_g was an average of the time-step shares and VRE share of demand lost the VRE of a time step without inflow. Every period share (group indicator, 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 an upper_limit profile), 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__profile on 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 in summary_solve.csv was 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_1 also removed base_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 it output_<scenario>.xlsx as 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_parquet folder, 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 for python -m flextool.calibrate with --output-location set to a folder that held other results; with --debug the earlier rounds are now kept in out_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 (and run_flextool.py, python -m flextool.cli.cmd_run_flextool) now stops with a message that names flextool-migrate-database <file>; a database newer than FlexTool asks for a FlexTool update. The standard ways to run migrate first: execute_flextool_workflow.py migrates 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 .sqlite source 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 a spinedb_api update) 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_flow on the time-series sheets; they are constant or per period and are now on the period sheets.
  • The Solver options dialog said the .opt files are in the project's solver_config folder. They are in the solver_config folder of the FlexTool workspace (or $FLEXTOOL_SOLVER_CONFIG_DIR).
  • The Spine Toolbox tool specifications listed files that no longer exist (bin/highs.opt of 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 %xx in a database address once more, so a project folder such as 100%25 RE or G #2 was 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 read G for the folder G #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_invest 0) never made, nor any solve for an entity without an investment cost, so that capacity was lost; with capacity (MW) empty it was also written as existing, so counted twice; and a max invest beside it was dropped. Now existing is capacity + invested capacity (units, storage, connections, also in sensitivities) and max invest stays 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 sets invested storage (MWh) or storage (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 invest can invest. FlexTool 2 derives the other limit through the fixed 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 on inv.cost/kWh sets 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_rate and lifetime and ignores that column.
  • The FlexTool 2 fixed kW/kWh ratio was 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 is flextool-import-sensitivities on 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 generation and use max generation with their ts_unit series (shares of capacity) become profiles with profile_method fixed, lower_limit and upper_limit on the unit's primary output (before: ignored, so must-run units were free). storage start / storage finish set storage_start_end_method explicitly (fix_start, fix_end, fix_start_end, else fix_nothing): before, a storage start of 0 fixed the first state at 0 (FlexTool 2 leaves it free), a storage finish was not read, and with both set the storage still wrapped from the last step to the first (now bind_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 --strict makes 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 ratio was divided by the capacity and written as conversion_flow_coeff, which changed the fuel use. The output2 eq / gt / lt rows had output1 and output2 swapped (heat >= 1.4 × elec became elec >= 1.4 × heat), their constants were not multiplied by the capacity, and a row with only a constant was dropped. Now the output2 edge has conversion_flow_coeff 0 (no fuel, outside the capacity, ramp and minimum-load limits, as in FlexTool 2), and each row, and the capacity ratio, becomes a user constraint output2 − 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 of output2 max capacity ratio) gives the output2 eq / gt / lt constant in 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 availability no longer derates dispatch. FlexTool 2 uses it only in the capacity margin; the import wrote it as FlexTool 4 availability, 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_ramps off 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_time step has in_use (or in_use_invest for 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_years that 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 compares time_in_years with steps × duration / 8760 (before: with 1, which missed a two-week workbook with time_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 outside input_sources and 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.sqlite or non_sync_and_curtailment.sqlite left 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 run python -m flextool.update_flextool.canonical_databases materialize in 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 (set inflation_rate to 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 for rolling_solve_jump, rolling_solve_horizon, contains_solves and fix_storage_periods. Results and Reading results now give invest_marginal in CUR/kW (CUR/kWh for a storage), the unit of invest_cost and 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_coeff with sense equal), why min_load alone is not a must-run and why a negative cost is a poor must-run bonus. Eight example scenarios must_run_* in examples.sqlite.
  • How-to "How to compare emission trajectories" (Emissions and system limits): one co2_max_period trajectory per alternative on the same four-period system, without, with existing and with additional measures, and how to compare them. Three example scenarios co2_*_measures in 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_cost per m³/h is entered ÷ 1000) and a water storage that lets the plant run flat. Four example scenarios desal_* 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 scenarios csp_* 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_online as something a user sets; it names the online status a stochastic realization fixes and a user constraint on the units online.
  • A unit efficiency may be above 1 (no change in results). A heat pump with COP 3 has efficiency 3; 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 the efficiency and efficiency_at_min_load descriptions to say so.
  • The user-constraint capacity coefficients (constraint_capacity_coeff, ..._invested_capacity_coeff, and the other constraint_*capacity_coeff of 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-threads still 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 existing is a Map by period (without virtual_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 --tier offer 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|auto in 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, -q with a --tier above the model's levels exits with code 2 and sends nothing; the start of the chat shows the level and why, and /context the context size and the result size. get_entity and list_entities also 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 --mcp sees the window's selections through get_gui_context while 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-notice accepts 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" (or pip 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 basic offers 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" (or pip 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_inertia is [CUR/MWs per hour] and penalty_reserve [CUR/MW per hour] (both are paid for each hour of the shortfall); fixed_cost and penalty_capacity_margin are per year ([CUR/kW/a], storage [CUR/kWh/a]) and annual_flow is [MWh/a]. One spelling per unit ([tCO2/MWh], [CUR/tCO2], [hours]). inflow, peak_inflow and energy_margin_adder say they are MWh per time step, when you must rescale them for another step length and when FlexTool does it; max_flow_for_unconstrained_variables no 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_g and the "Timestep inflow" of nodeGroup_gdt_p are MWh per time step (labelled MW before); entity_annuity_d_p is the discounted investment cost per MW (labelled as a yearly annuity before); the capacity factor tables unit_outputs_cf_d_ee, unit_inputs_cf_d_ee and connection_cf_d_e now 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.json says 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.log next 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 with python -m flextool.cli.cmd_run_flextool records the Python console output; native solver lines and crash dumps are not in that log.
  • New flags --input-source-file PATH and --run-launcher NAME for run_flextool: the file the input database was made from, and who started the run, both recorded in _run_status.json.
  • Changed: flextool-run runs 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-memory solve'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-run asks 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, or pip install -e . in a source checkout) to get the new flextool-run; until then it runs the engine in the same process as before. The documentation and the command the GUI shows for a run use flextool-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-memory solver process running; it now stops the run's whole process tree.
  • Fixed: timings.csv and the output-info database ignored an output-location from 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_time as 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 without virtual_unitsize whose existing is 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. With startup_method = binary such a unit could never be online, and a min_instant_flow floor that needed it made roll 1 infeasible. The unit size is now the largest existing over the periods in every solve, as for a constant existing. 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 existing dropping from 17 to 8.5 MW without virtual_unitsize: 0.5 units) now gets a warning naming it, the count and the unit size.
  • Gurobi options are applied. gurobi_cl takes parameters only as Name=value arguments; it ignored the ReadParams=<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 own Name=value argument, so Gurobi results can change (for example a MIP now stops at the requested solver_mip_gap instead 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_stepduration on time steps other than 1 h. A solve's new_stepduration now combines the right time steps for any step length (0.5 h, 2 h, mixed): inflow is summed, other time series are averaged (weighted by duration when the combined steps differ in length), and a constant inflow counts 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_stepduration must fit the time steps. A new_stepduration that 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_flow now meets annual_flow on 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 own peak_inflow (the peak was taken over the whole timeline, so periods missed their peak). With new_stepduration, peak_inflow is matched per original time step. The input check's reach warning counts the time steps in a year.
  • Stochastic solves: scale_to_annual_flow and scale_to_annual_and_peak_flow work in branch periods (they stopped with an error).
  • scale_in_proportion with new_stepduration: a constant inflow is scaled as it is without new_stepduration (it came out about 2/3 of that on 2 h new steps).
  • Nested solves: a solve without new_stepduration that runs after one with it scales scale_to_annual_flow over its own time steps (it used the earlier solve's total). Results of such nested models can change.
  • max_flow_for_unconstrained_variables below 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_method not_allowed becomes not_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_stepduration groups decide once per coarse step: flows, online variables, reserves, exact connections 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_THREADS for 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: yes branch adds a realization dispatch; normal outputs hold realized results only (forecast rows in the output_horizon raw dump); per-path CO2, ladder and flow caps.
  • Construction lead time: invested is the capacity coming online; new in_construction column and constraint_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_method is fix_nothing; fix_end / fix_start_end pin the end; self-discharge is exact for any step length; bind_within_period and bind_intraperiod_blocks fixed.
  • 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-digits and --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-workers and --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_THREADS can set a lower limit), HiGHS gets threads=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 N to the FlexTool Tool's arguments). The run header prints the threads in use. The workflow runner and the calibrator take --threads too 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 set FLEXTOOL_ACCEPT_PRIMAL_REL still 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 flextool folder (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.sqlite open 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_THREADS was ignored by run_flextool. The --highs-threads flag 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 scaling setting. Its region subproblems were scaled with full, or with the scaling of the solve that ran before it in the chain. Each solve now uses its own scaling; --scaling / FLEXTOOL_SCALING still 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 from solver_config/ or FLEXTOOL_SOLVER_CONFIG_DIR. A run no longer sets FLEXTOOL_SOLVER_CONFIG_DIR, and run_flextool no 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, while FLEXTOOL_HIGHS_TIME_LIMIT=0 was ignored. Both forms now warn and fall through to the next setting (no limit by default). The same holds for a negative --solver-mip-gap and for --highs-threads 0.
  • Docs: --warm-start also 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 N and --basis-cache-dir PATH for run_flextool, the command-line twins of FLEXTOOL_BENDERS_WORKERS and FLEXTOOL_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's scaling (choosing "Full" now forces full over the solve's setting). The GUI no longer has a MIP-gap setting: each solve uses its solver_mip_gap, else mip_rel_gap in solver_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 presolve choose and scaling full become "Default" (a stored presolve on / off is kept), and the GUI shows the change once. --solver-mip-gap and FLEXTOOL_HIGHS_MIP_GAP remain for terminal and Toolbox runs.
  • Removed --matrix-file-format and FLEXTOOL_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_DIGITS and FLEXTOOL_REPORT_NEAR_DUPS. They never affected a normal run (only --region passed 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_flextool flag 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 every run_flextool flag 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 a flextool[compat] install ran about 10-30% slower on every processor. import flextool now 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/cpuinfo and the processor itself (cpuid) report it, so emulators are read correctly.
  • Fixed: python -m flextool.env_check crashed 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_check points 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 has polars-lts-cpu is moved to polars[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 option pip install "flextool[compat]"; python -m flextool.env_check reports the processor's features and the installed polars builds; set FLEXTOOL_NO_ENV_CHECK=1 to 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 base example 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_AFFINITY set 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_method scale_to_annual_flow, scale_in_proportion and scale_to_annual_and_peak_flow now stop with an error naming the node, method and period when the period has no annual_flow (or a negative one), when scale_to_annual_and_peak_flow has no (or a zero) peak_inflow, when the node has no inflow profile (or one that sums to 0) to scale, and when peak_inflow has the opposite sign of the inflow. Before, the node's inflow silently became 0 in such a period, and scale_to_annual_and_peak_flow without an inflow profile gave a constant supply of annual_flow / 8760 (for example +1027 MW for 9 TWh of demand). An explicit annual_flow of 0 still gives no inflow in that period. A warning (once per node) says when annual_flow is above |peak_inflow| x 8760, or when the peak scaling makes the inflow change direction at some time steps (annual_flow too small for the peak and the profile). The example database's ev and scale_to_peak_flow alternatives and the demand how-to's scale_to_proportion scenario lacked annual_flow: the EV battery now uses its inflow as given (use_original; before, its demand was 0), scale_to_peak_flow gets annual_flow 9 TWh (before, the demand of west was 0) and scale_to_proportion includes the scale_to_annual alternative.
  • Fixed: reserve provision results in MW (process__reserve__upDown__node__dt, ..._d_average, SpineDB reservation_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 sheet costs_discounted__d); before, it and the horizon total shared the name costs_discounted, so the CSV writer kept only the horizon total and the Excel sheets were costs_discounted (per period) and costs_discounted_1 (total). costs_discounted.csv / the sheet costs_discounted is the horizon total by cost category, as before in CSV. The VRE share of a node group is group_node_VRE_share__d per period and group_node_VRE_share__dt per time step (before, the per-period table carried the __dt name 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_time or with stochastic_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); under recourse each 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 under recourse no 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: for reinvest_choice / no_investment the first year of the run plus lifetime, for reinvest_automatic the 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 over lifetime from 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_investment existing capacity). It is the first year of the model run plus lifetime in 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_method is not recourse and that invest or have a reinvest_choice / no_investment lifetime). 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 a construction_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_annualized of 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 a construction_lead_time is 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 group new_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_method linear 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 than min_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_duration when 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 exact connection 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_method and storage_binding_method defaults, and the none members 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 as invested the capacity that comes online in the period, and a new column in_construction the 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) and constraint_invested_capacity_coeff count capacity coming online in the period; the new constraint_in_construction_capacity_coeff puts 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 the invest_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 or construction_lead_time_method immediate). 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 whose invest_periods is 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 (columns period, 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_use and d_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 named period); 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 01 or a are now their own tiers. Before, a label was read as a whole number: tiers 01, 02, ... were bought at no cost, 1 and 01 were merged, and tiers like a were dropped (results change; an upgraded database whose ladder has such labels prints a notice naming the commodities). A tier with a quantity but no price is now an error naming the commodity, period and tier (before: price 0). A tier without a quantity stays unlimited.
  • A unit whose min_load map 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 whose reservation adds 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_timeset that uses the model's only timeset now gets the same periods everywhere. A price_ladder_annual given without periods now applies in every period of such a solve when the model has no periods_available either; before, it was skipped with a warning (results change; an upgraded database prints a notice naming the commodities). A multi-period stochastic solve without period_timeset now 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_timeline covering 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-dump shows period + time maps of node availability, penalty_up / penalty_down and self_discharge_loss. The dumped solve_data/pdtNode.csv listed 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_method is now fix_nothing. Storage nodes without their own method start free. Before, where some node had a storage_state_start, the old default fix_start fixed them at the start: at their own storage_state_start, or at 0 when only other nodes had one. The database upgrade writes fix_start on the nodes that have their own storage_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 a storage_state_start, the old default fixed nothing and results are unchanged.
  • fix_start / fix_end / fix_start_end without storage_state_start / storage_state_end is 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_dispatch without a realized: yes branch 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_cost and the user-constraint constant can now be a constant or a period map; efficiency, min_load and efficiency_at_min_load of a unit, efficiency of a connection, profile, self_discharge_loss and energy_margin_adder also take period + time maps. Ten parameters that had no allowed value type (for example penalty_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 constant can 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_method values: not_allowed is now not_active (the default: the group sets no investment or retirement limits). The upgrade renames stored group values; a group value not_allowed in 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_total and retire_min_total of a group (with a retire option in its invest_method) were read by FlexTool but missing from the database; they are now there.
  • New user-constraint parameters constraint_capacity_coeff and constraint_retired_capacity_coeff on units, nodes and connections. constraint_capacity_coeff puts 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_coeff puts 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_coeff is 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_horizon raw dump these rows are process_constraint_{equal,less_than,greater_than}_period with 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_coeff got 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 input capacity_max_coeff / capacity_min_coeff) and lists any values they held; an input capacity_max_coeff value moves to input_share_max (its name since database version 70) where that input has none of its own. solve_mode single_shot becomes single_solve. Results unchanged.
  • Fix: DC power-flow lines are always the connections inside dc_power_flow_with_angles groups (results change for DC power-flow models that also have an is_DC connection; a notice names the groups). Such a model used its is_DC connections as the DC power-flow lines: the group's lines got no angle constraints and the reference node could change. An is_DC connection inside such a group is not a line; it keeps its own transfer_method. A startup_method on an is_DC connection 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 the is_DC connections; 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 existing is not a whole number of units for a unit or connection with integer online variables (startup_method = binary) and virtual_unitsize. With virtual_unitsize, existing is 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, existing is 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 existing as the number of units with virtual_unitsize (results change: the selected representative periods can differ for a VRE unit with virtual_unitsize other than 1 and a non-zero existing, when the representative periods are selected again). The existing VRE capacity is now existing × virtual_unitsize, as in the model; before, the clustering used existing as MW. The tool prints a note naming such units. Stored selections are not changed.
  • Fixed: the --region export gave a cross-region connection with virtual_unitsize only existing MW. The virtual half-flow connection of the region's input now carries existing × virtual_unitsize. Solve results (including the Benders decomposition) are unchanged.
  • Fixed: the automatic DC power-flow reference node reads a node's existing as the number of units with virtual_unitsize. The reference is the node with the largest existing capacity (existing × virtual_unitsize); before, existing was compared as is, so a node with virtual_unitsize could 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_coeff is 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_ratio applies 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 (unlike large_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 group invest_method value not_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_downtime walked 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_total in 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 times years_represented. The history was therefore under-counted by years_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-path co2_max_total constraints 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 = recourse that 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_period caps 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_period allot 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. The co2_max_total share of a fan counts those hours once, and so does the fallback weight of the cumulative ladder. A forecast's co2_max_period cap 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 = yes no 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 under horizon/. 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_e next 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 = yes no 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 writes output_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, with solve, roll and realized columns, raw primal values, reduced costs and duals. Not available for solves decomposed with Benders.

  • timeset_weights now reach the forecast branches of a stochastic solve, and a map indexed by time is 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. A timeset_weights map whose index_name is time (or t) was read as no weights at all in a single solve; it now gives the same weights as a map without an index name. A timeset_weights value that is not a timestep map (a scalar, or a map indexed by period) 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 under recourse. Objectives and reported investment costs of such solves are lower; the chosen investment can change (it was biased low).

  • A realized: yes branch in a stochastic solve's stochastic_branches now makes FlexTool run a realization dispatch right after the stochastic solve, without setting dispatch_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 with stochastic_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 in model.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; under stochastic_invest_method = recourse a warning says that no realization dispatch is added. When the inherited units online cannot cover the realized net load of a node with penalty_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_capacity now apply to every forecast branch. The cumulative-capacity limit of a cumulative_limits entity sat on the period's original name, which has no investment under recourse: a cumulative_min_capacity made 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 the realized: yes entry 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 through model.output_horizon. Under stochastic_invest_method = recourse the 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 __anchor path 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: yes branch, 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: yes branch) 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 designated realized: yes branch's value feeds the realization dispatch. An entity in no include_stochastics group 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 the reservation of a reserve group without include_stochastics (before, it silently became 0). A stochastic-time profile is read per profile: when any of its users is in an include_stochastics group, all its users read the forecast branches' own series. The how-to stochastics example 2_day_stochastic_dispatch is a forecasts-only fan of four forecasts again, so it reports no realized dispatch (the forecast branches are visible through model.output_horizon); 1_week_rolling_wind keeps 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: yes branch'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. existing or fixed_cost set 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_represented weight 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 for p_inflation_op even 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 with years_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_price as 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_content row, but no group with an active co2_price or co2_max_period/co2_max_total on 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 nonzero co2_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_coeff in its fuel use, cost and CO2, not only a multi-flow unit. The input/output coefficient on unit--inputNode / unit--outputNode used 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 authored efficiency / efficiency_at_min_load are then no longer exactly its energy efficiency at full or minimum load (see the updated conversion_flow_coeff documentation). 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_total of 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_limits entity (unit, connection or storage node) with only a cumulative_min_capacity floor and no cumulative_max_capacity ceiling 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 a cumulative_max_capacity is 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_limits investment method now gets the same "give your own maximum once a sibling sets one" check as an entity does. Once one cumulative_limits group sets cumulative_max_capacity for 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_capacity parameters. Groups previously needed undocumented invest_max_cumulative / invest_min_cumulative values (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's invest_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_capacity on 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 explicit cumulative_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_total used 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 as x) 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's self_discharge_loss: one node's unrelated, unresolvable Map could silently zero out every other node's self-discharge loss. Setting the Map's index_name explicitly to period or time (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 named tier / period explicitly. 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_duration now 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 example model_builder.build_model's generated models, which never write timeset_duration at 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-dump run) 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-dump debug 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_marginal now shows the dual of an unbuilt entity, and group investment caps/floors reach their members. For a unit, connection or node entity that is eligible to invest but not built, invest_marginal (and the unit_invest_marginal__d / connection_invest_marginal__d / node_invest_marginal__d outputs) always showed 0 (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 in docs/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.csv and p_entity_period_existing_capacity.csv are solve-to-solve handoff mirrors that nothing in FlexTool reads back; they are debug output for --csv-dump only and are no longer written on an ordinary run. --csv-dump still writes all five, unchanged.

  • --csv-dump writes 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 to input/ and solve_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); a decomposition=benders solve dumps its whole, undecomposed input. The dump also keeps the solve's objective scale in solve_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 a SchemaError (seen with rolling separate dispatch and nested fix_quantity runs). The run now takes everything from its input database and ignores such files. load_flextool now needs the run's data provider and database reader; loading a model from a CSV dump is done only by the unsupported flextool.engine._csv_reload package.

  • 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-dump of 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_method fix_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_method use_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's storage_solve_horizon_method, since nothing else continues from it.

  • The csv dump shows what each solve was built from. With csv_dump, handoff/*.csv now 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 in produced_handoff/*.csv.

  • fix_start_end ignores the storage binding method. As documented, a storage node with storage_start_end_method fix_start_end now chains forward like bind_forward_only whatever its storage_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 different storage_state_start and storage_state_end the solve needed slack or ended away from storage_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_end fix the end of the storage. The storage_state_end of a fix_end or fix_start_end storage was never applied: the storage ended wherever the model chose. The end state is now pinned to storage_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_method is meant for simple models; storage_state_reference_value with 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_method use_reference_value and storage_nested_fix_method fix_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). Now storage_start_end_method fix_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_start of 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_DC connection's flow into the group is counted as non-synchronous also when a unit is flagged is_non_synchronous (it was dropped then), and the reverse flow of a no_losses_no_variable_cost or DC connection is counted (infeed into the group, supply out of it). Models with a non-synchronous limit change (the non_sync_and_curtailment how-to: higher cost, the battery no longer cycles). A warning names a no_losses_no_variable_cost connection that crosses the boundary of a group with a non-synchronous limit (flowing both ways at once is free and loosens the limit; use exact).

  • The energy margin no longer enters the non-synchronous limit and the capacity margin. An energy_margin_multiplier or energy_margin_adder added 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 (set capacity_margin for 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-dump of a solve with an energy margin now also writes pdtNodeInflow_unmargined.csv, so a reload keeps the real demand.

  • Reserve price of groups with several requirements. reserve_price_t is 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), and timeseries_and_dynamic groups reported the timeseries dual alone, which is 0 when the dynamic requirement is the binding one. When a requirement is short the price is now penalty_reserve.

  • Time-varying self_discharge_loss. A self_discharge_loss given 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 a bind_intraperiod_blocks block decays by the sum of its hours' losses.

  • use_reference_value counts invested storage. The end state of a storage with storage_solve_horizon_method use_reference_value was pinned to storage_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_end fixes the start state. A storage node with storage_start_end_method fix_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_end fix the end of the storage" above).

  • fix_start of 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 lost self_discharge_loss once 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 and bind_intraperiod_blocks blocks used a linear loss × hours that 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_t Self discharge) now equals the loss the optimisation used; before it was state × loss per hour, which differed for steps other than 1 h, coarse nodes and blocks.

  • bind_intraperiod_blocks storage 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 of min_load_efficiency units 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 with fix_start the 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_roll test model: total cost −1.1 %).

  • Blended-weights storage in solves without representative periods. A solve without representative periods treats a *_blended_weights node 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_period storage works. A storage node with storage_binding_method bind_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. With fix_start the start state is pinned at the last step of the first period (the step the first period wraps to). use_reference_value does 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_weights now gets its own weights, representative blocks and base-period storage chain. Before, only the first such period did (unless a node used bind_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 the bind_within_solve_blended_weights / bind_forward_only_blended_weights storage 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_gap was ignored on the default (warm) HiGHS path; it is now honoured. A solve with solver_arguments or solver_mip_gap that runs cold (warm=False, --save-memory) no longer loses solver_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, or threads in highs.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 of min_load_efficiency units. 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_t is 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 that timeset_weights leaves out) has no price. The nested fix_price storage handoff passed the dispatch solve a price a million times too small (and weighted); it now passes the storage solve's price_t to the dispatch solve, also to the rolls of a rolling dispatch solve (before, a rolling dispatch solve always received 0) and also for bind_intraperiod_blocks storage nodes.

  • bind_intraperiod_blocks storage 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_start starts the first roll, and storage_solve_horizon_method sets the end of a roll that does not loop). A rolling_solve_jump that is not a whole number of periods gives a warning.

  • Nodes in a new_stepduration group sum their flows over each coarse step. A node of a group whose new_stepduration is 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-level new_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 its storage_binding_method, fix_start, storage_solve_horizon_method (reference value) and nested fix_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, and bind_intraperiod_blocks storage 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_stepduration keeps 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 with new_stepduration now 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_proportion inflow scaling now also works with new_stepduration and scales as the unaggregated solve does. A timeset shared by a solve with new_stepduration and a solve without it is now an error.

  • Benders decomposition checks connections between regions. A connection between regions with a startup_method, the exact transfer 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_cost or 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.

  • exact connections flow in one direction at a time. The exact transfer 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 lossy exact connection could still burn a surplus through its losses, and an invested exact connection could lose capacity to the unit count.

  • Online connections. A connection's startup_method works again, for one-way and two-way connections: the connection gets online capacity that limits its flow in both directions, startup_cost is 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 through minimum_time_method, keep their online variables (they lost them, or had their inertia counted twice).

  • Group transfer_method applies to the network topology. A group's transfer_method was 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 group unidirectional override 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 dynamic or large_failure requirement did nothing unless some group of the same reserve also had a reservation. It now applies whenever a unit or connection at a node of the group has an increase_reserve_ratio or a large_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_efficiency unit), 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 dynamic reserve_method the 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_share at an input node is a share of the unit's input capacity there.

  • co2_max_period applies in rolling solves. Each roll of a rolling_window solve 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, and min_load_efficiency units get defaults instead of losing their fuel use. An online unit stopped the model when no unit in the model had a min_load. A min_load_efficiency unit without min_load (or without efficiency) could stop the model, or, next to other min_load_efficiency units, use no fuel at all; it now uses min_load 0 (and efficiency 1, the default), and no longer gets a minimum-load floor. A min_load_efficiency unit with a min_load above 0 but no efficiency_at_min_load used no fuel on single and stochastic solves and a wrong curve in rolling solves; it now uses its efficiency at minimum load too (a flat curve, as with constant_efficiency) while the minimum load still applies, and a warning names it. Periods or timesteps that an efficiency_at_min_load map leaves out use efficiency in 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_limit or non_synchronous_limit on a group without capacity_margin_method = manual / has_inertia = yes / has_non_synchronous = yes created 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_stochastics group, 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_cost is charged in the objective. An other_operational_cost of 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_down on 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 stochastic penalty_up, and a period-and-time penalty_up or penalty_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 efficiency or a unit min_load given as a map keyed by period, and a profile keyed by period, now apply in every roll of a rolling_window solve 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 profile keyed 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 inflow is now an error. inflow is 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 to annual_flow for demand that varies by period.

  • A constant cumulative_max_capacity or invest_max_period no longer blocks investment. A unit, connection or node with invest_method cumulative_limits, or an invest_method with per-period limits (invest_period, invest_period_total, invest_retire_period, invest_retire_period_total), and a single value for cumulative_max_capacity (or invest_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 (with cumulative_limits not 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, commodity price, availability, other_operational_cost, penalty_up / penalty_down, profiles, CO2 price and per-period limits such as co2_max_period or capacity_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_annual prices 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_annual or price_ladder_cumulative commodity 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_total limits the emissions of all represented years. The total CO2 limit now applies to each period's annual emissions times its years_represented, summed over the periods: the same measure as the model-wide CO2 output. Before, each period counted as one year, so with multi-year periods the limit was years_represented times too loose. Models that use a co2_method with total and years_represented above 1 get a tighter limit. Chains of solves and rolling solves now also subtract the emissions realized before them (see the co2_max_total and remaining-budget entries above).

  • Years represented in rolling solves and stochastic branches. The outputs that scale annual values by years_represented (the model-wide CO2 [Mt] and years_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 that min_load_efficiency units 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 used timeseries_only. Every group now gets the requirements its method names.

  • N-1 reserve requirement per failing unit or connection. A large_failure reserve 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_method linear or binary, or a minimum_time_method other than none) 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_cost on 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_cost and salvage_value. A model with a period map of invest_cost or salvage_value on 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 than period (e.g. year), raises a configuration error instead of being accepted.

  • Internal: the engine package is renamed flextool.engine_polars → flextool.engine and split into subpackages (input, preprocess, model, solve, output, autoscale); flextool/engine/README.md describes the layout. flextool.engine_polars stays as an alias for the public API. The model build (model/core.py, formerly model.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_flow uses 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 way scale_in_proportion does, 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 use scale_to_annual_flow with 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__dt balances under timeline coarsening. With new_stepduration (step duration ≠ 1) the Inflow column of node__dt was 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, and node__d is unchanged.

  • Internal: the hours-per-year unit (8760) is centralised as a single HOURS_PER_YEAR constant 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_flow that is a Map(period) on one group and a Map(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]) and construction_lead_time_method (immediate / closest_seam / previous_seam / next_seam) model physical build time on unit, connection and node investments. 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 (or immediate) every existing result is byte-identical.

  • Standard two-stage stochastic investment (the main mode). The new solve.shared_invest_periods parameter names the periods over which investment is a single shared here-and-now decision: v_invest and v_divest are tied across the stochastic branches over those periods, while later periods invest per branch (recourse). Compose it with stochastic_invest_method = recourse and shared_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-recourse solve 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 recourse this 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. Set dispatch_realization_method = separate_dispatch and the engine automatically runs a <solve>__realized_dispatch sibling 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 default within_solve keeps 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 with conversion_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 with min_load and 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_coeff on 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 full min_load applies. Lower min_load instead (min_load = 0 switches it off). The model warns about such units;
  • one-input/one-output units behave as before, except that an authored capacity_min_coeff below 1 no longer softens their floor.
  • capacity_min_coeff on 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 least capacity_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's capacity_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's input_share_max, and a share on a unit with a negative input conversion_flow_coeff; a share on an input with conversion_flow_coeff = 0 or on a unit with one input has no effect (a warning). The schema migration deletes all values of the old unit__inputNode.capacity_min_coeff (the model never applied them, and their old meaning is unrelated) and prints the deleted values; re-author them as input_share_min where 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 with capacity_min_coeff below its floor). Before, a unit could offer its full capacity as downward reserve even while off. The max_share limit (× 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 — reservation for timeseries requirements (unchanged), Σ capacity × increase_reserve_ratio for dynamic and Σ capacity × large_failure_ratio for large-failure requirements, summed when a group combines methods. Before it was sized by reservation only, so a dynamic or large-failure group with a small or no reservation of its own could leave its requirement unmet only up to that amount (or not at all) and, without a reservation, without penalty — a requirement that could not be met made the model infeasible. The reserve shortfall outputs use the same sizing.
  • Fix: penalty_reserve defaults to 5000 again (schema v70). A reserve group without its own penalty_reserve had 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 delay is ignored, with a warning. The reference said it works with transfer_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 a regular connection became one-way. Until connection delay is implemented, a connection delay is 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); connection delay is not applied (it is ignored with a warning).
  • Fix: multi-input/multi-output (indirect) units with conversion_method = min_load_efficiency now burn the no-load fuel. Their input was slope × output only; the section × online capacity part of the efficiency curve was missing, so fuel use, commodity cost and CO2 were understated (by 31 % at full load for a unit with min_load 0.2, efficiency_at_min_load 0.4 and efficiency 0.9). The input is now slope × output + section × online capacity, as for one-input/one-output units.
  • Fix: a unit with a constant delay runs 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: delay on connections is not applied; it is ignored with a warning (see above).
  • Fix: input_share_max on a delayed unit's input divides by the input's conversion_flow_coeff. For delayed units the input limit used min(conversion_flow_coeff, 1), so an input with a coefficient above 1 was allowed too much (e.g. coefficient 2 with input_share_max 0.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 most input_share_max × the input that runs the unit at full output ((slope × W + section) / conversion_flow_coeff per unit of capacity, W being the most fuel the outputs can demand within the capacity and their capacity_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 authored input_share_max as a cap on their flow; source-side ramp limits of indirect inputs scale with the input limit. The previous fuel bound summed conversion_flow_coeff × capacity_max_coeff over 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 uncapped conversion_flow_coeff = 0 edges 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). On unit__inputNode, capacity_max_coeff is now input_share_max (values move unchanged) and capacity_min_coeff is input_share_min (see below: its old values are deleted); unit__outputNode keeps capacity_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 output conversion_flow_coeff above 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_coeff on the outputs of indirect units is enforced again. Indirect output flows are bounded by existing capacity (plus investment), but the per-output capacity_max_coeff factor 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 maxOutputSum constraint bounds Σ outputs + upward reserves by existing plus invested capacity (times availability); online units additionally bound Σ outputs by the online capacity. Outputs with conversion_flow_coeff = 0 stay excluded; capacity_max_coeff remains 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_coeff and availability multiplied only the existing capacity; invested (and retired) capacity entered the capacity constraints unscaled. An output with capacity_max_coeff = 0.2 on a unit that invested 900 MW could deliver 900 MW instead of 180 MW, and a unit with availability = 0.5 could 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 apply capacity_max_coeff × availability to existing + 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's capacity_max_coeff was not applied (the documented behaviour).
  • The reverse direction of a two-way connection can use invested capacity. Reverse flow of no_losses_no_variable_cost connections was capped at the existing capacity only.
  • capacity_max_coeff = 0 is a zero cap, as documented. The engine treated an edge with capacity_max_coeff = 0 as uncapped (bounded only by max_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 the conversion_flow_coeff = 0 ones; those are now also kept out of the online flow cap, ramp limits and min-load constraints, and are no longer scaled by availability. The max_flow_for_unconstrained_variables and capacity_max_coeff descriptions are corrected (schema v70).
  • capacity_max_coeff now 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_limit lifts the investment limits like invest_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.sol file, and HiGHS no longer warns "The cost row for the Glpsol solution file is reported last". The highs.opt template 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's solver_config/highs.opt are 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's solver_arguments.
  • Dependency versions are capped at the tested series: polars<3, pandas>=2.2.3,<4, spinedb-api>=0.36.5,<0.37, and highspy>=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.40 without 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 the spine-db-editor command 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 availability at its default. The potential-generation calculation multiplied the resource profile by the availability parameter, 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_limit profile (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 raised KeyError; it now falls back to zero, matching the reserve-slack terms.
  • The highspy requirement now has a lower bound of 1.14.0. Autoscale Layer 3 conditions the objective through HiGHS' user_objective_scale option, which older 1.x wheels (before HiGHS renamed user_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_represented default now covers every period of a solve. When a solve does not define years_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 defined years_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-update created templates/results_template.sqlite and results.sqlite from the legacy pre-v26 template (old object_parameters / relationship_parameters format) rather than the current results schema, which could break the results-import / Spine Toolbox workflow. It now uses schemas/spinedb_results_schema.json (modern entity_classes / parameter_definitions), consistent with the output-write path's ensure_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 for spine-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-.sqlite path agree.
  • Docs. The recommended install now pulls the Spine Toolbox and result-viewer extras (pip install "flextool[toolbox,viewer]"); a note explains that plain pip install flextool gives 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 every v* tag, verifies the tag matches the pyproject version, 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/*.json now ship in the wheel. The xlsx/CSV import spec (import_old_excel_input.json) loaded at runtime by the flextool-read-tabular-input CLI was covered by no package-data glob, so a pip-installed wheel (and the sdist) raised FileNotFoundError on that path; a tight glob now bundles it.
  • main is the default branch; master deprecated. CI (ci.yml / tests.yml) now fires on push/PR to main, and a new docs workflow builds the MkDocs site from main and publishes it to gh-pages, replacing the manual mkdocs 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 gui marker / --run-gui flag), 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 any unit__node__profile arc whose profile_method is upper_limit (the schema default). --vre-penetration scales 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-group yes_no flag that marks a region-group as a net-load aggregation unit: when one or more groups carry yes, 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-best additionally dispatches each mature iteration and keeps the lowest full-year dispatch-cost set, deterministically re-materialising an earlier winner. Subsets are run via a model.solves override alternative; a fail-closed guard rejects an active timed energy_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 stored plot_settings.yaml remains the source of truth.
  • Stack & legend ordering. The per-node dispatch stack follows the plot_settings order, 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.
  • existing capacity column is the pre-invest baseline, not cumulative. The unit/connection/node capacity outputs sourced existing from the cumulative chain-sum, so on a multi-solve run a later dispatch/rolling sub-solve reported previously-invested capacity under existing. It now comes from the static pre-invest baseline; invested stays the per-sub-solve/roll investment and total still 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.csv phase-3 artifacts (all paths read output_parquet/*.parquet), so the writers and their exclusive loaders were removed.
  • Upgrade-safe results DB schema. ensure_results_db short-circuited on file existence, so additive schema changes (e.g. the per-entity cost break-down parameters) never reached a results.sqlite created by an older FlexTool version and write_spinedb crashed 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), or new_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 flat report/, 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_variables override now honoured. Two source- parameter lookups queried the parameter under a spurious p_-prefixed name that never matches the DB, so every invest_no_limit entity 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. The grow / fixed count modes and --force-count-mode are removed; no seed reproduces the byte-identical default.
  • v38 node_type migration floor. The v38 has_balance / has_storage → node_type consolidation blanket-wrote commodity into the Spine default Base alternative, which — being often the highest-ranked alternative — overrode real balance / storage assignments in lower-ranked alternatives, turning every node into a commodity node and crashing the solve on a zero-balance node. The migration now writes the commodity floor 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_calib alternative 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, or uniform), 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 multiplicative energy_margin parameter is renamed to the self-documenting energy_margin_multiplier (a definition rename; existing values are preserved), and the previously reserved inflow_adder method 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 the penalty_up / penalty_down unserved-energy / over-supply slack variables; off removes 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 via FLEXTOOL_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_SCALING control, 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 = basic lets the DB carry that workaround per model. Default is full (unchanged behaviour). Precedence: operator override (--scaling / env) > solve.scaling

    default.

  • Scale-invariant coefficient floor (requires polar-high>=3.6.1). In full autoscale 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, so full agrees with off / basic again. Five scenario goldens (dr_shift_demand, test_a_lot, multi_fullYear_battery_nested_*) were regenerated to the corrected solutions; off / basic outputs 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) or inflow_multiplier (apply the energy_margin factor). Note FlexTool represents demand as negative inflow, so a factor > 1 correctly deepens demand.
  • group.capacity_margin_method replaces has_capacity_margin (schema v65). The yes/no has_capacity_margin flag becomes a method enum none / manual (migrated yes → manual), consistent with energy_margin_method and 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_weight knob, +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_method emit used the wrong column contract (storage_nested_fix_method vs the consumer's method), so every fix-node set was empty and the handoff did nothing; (2) the fix_usage throughput 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) the fix_price water-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' writeSolution permanently 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 (KeyError in calc_capacity_flows, silently-empty dual columns). The un-mangled names are now restored from the polars-rendered Solution.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- enforcing maxFlow RHS from p_flow_upper (the existing + invest_max ceiling) on every arc, so a greenfield unit (existing = 0) got free, unpaid capacity up to the investment ceiling — the output constraint v_flow − Σ v_invest ≤ invest_max/unitsize was satisfied at v_invest = 0. The indirect maxFlow RHS is now split by arc side, mirroring the direct- unit maxToSink/maxFromSource semantics: output arcs bind with the existing-only RHS (any output flow forces paid v_invest > 0), while input/fuel and zero-coef aux arcs keep the loose bound (pinning them to existing = 0 would make fuel hard-infeasible). Direct units are byte- identical. Sink-less indirect invest units now raise a clear NotImplementedError instead 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 FlexData load (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 .bas file and injected into the child via a new cmd_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 WarmProblem path: 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_folder never reached the subprocess.

Solvers — configuration

  • highs.opt is 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 a solver_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-N decomposition 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 an N = 10 mid-solve crash that previously aborted the decomposition.
  • Driver rewired onto the polar_high.benders coordinator (byte-parity with the previous in-line loop; pinned by a λ = 0 reference 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 with parent failed to read MPS back — it re-read the just-written MPS through highspy.Highs.readModel purely 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 released Problem's _vars / pre-release _cstrs). No parent-side readModel (which also spiked tens of GB of RSS on large LPs).
  • CPLEX / Xpress / COPT .sol parsing fixes surfaced while adding real-solve coverage: COPT writes # Objective value <v> with no = (objective is now parsed); Xpress' default writesol emits an index-based .asc/.hdr CSV that was never parseable, so the Xpress path is switched to the MPS-like SLX solution (writeslxsol, name-based) for the primal plus writeprtsol for 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 via polar_high.WarmProblem.compact_cuts, with a slack (default) or non-default dominance selection policy (FLEXTOOL_BENDERS_CUT_POLICY). Note: this is an experimental knob — on load-bearing cut pools it does not reduce iterations.
  • Capability guard: compact_cuts needs polar-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 clear needs >= 3.5.0 warning 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 variable FLEXTOOL_BENDERS_IN_OUT_WEIGHT from b21). It is the weight λ in the in-out separation point f_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 when decomposition = '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 the solve class (default 0.0, solve_advanced group) — 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_out instead 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 variable FLEXTOOL_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.7 reached 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-free polar_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 a solve database 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 with FLEXTOOL_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 existing capacity by the per-period MAX, not the last row. _entity_unitsize_lf gated the existing-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 a unique(keep="last") that kept the last period's value. For a unit whose existing map decays to 0 at expiry, that collapsed the cascade input to 0, defaulted unitsize to 1000, and turned existing_count = existing/unitsize into 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 scalar existing (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_shapes resolver.

Decomposition (Benders)

  • The spatial-Benders cut self-check no longer hard-fails on solver round-off. After each master solve, _check_cuts_satisfied asserts 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, so cost_r and Σ slope·f̄ nearly cancel: the rhs collapses to O(1) while the coefficients stay at O(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: a 7e-4 slack on a 2.66e6-scale row — 2.7e-10 relative). 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 existing capacity for a unit whose constant value shares an entity class with a period-Map sibling. When one unit carries a scalar (constant) existing capacity while another unit of the same class carries a period-indexed Map, the Spine reader returns a single frame with one shared index column (named x by 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 with 0. Downstream this zeroed the output-facing existing/total capacity for such units — surfacing most visibly as negative VRE curtailment (potential − flow, with potential computed from a 0 capacity), and as 0 in 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-4 floor). polar-high 3.2.0's autoscale Layer 3 now geometrically centres the cost band over HiGHS' comfort zone via a power-of-two user_objective_scale: it lands the band straddling 1.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 <= cap self-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 reported max_primal_infeasibility, and it grows as cuts accumulate (~3e-6 at iter 4, ~1.2e-5 at 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-2 relative, or 1e3× 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) and LB <= best_UB sandwich (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 band max(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 Map index_name (period or time) for parameters that accept both a period map and a time series (e.g. flowGroup min_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_flow obligation's RHS cap was resolved correctly for all shapes (constant, period map, time map, period+time — including Spine's silent-default "x" Map index_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", at apply_projection_params) on a pure period map, which has no t column. Spine Toolbox cannot cleanly distinguish period from time maps, so authors routinely leave index_name as "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-coded 1e-6 absolute tolerance. For a unitsize-normalised coupling row C - f >= 0 with small capacity, HiGHS enforces primal feasibility on the internally-scaled row (default 1e-7), which maps to a larger unscaled slack on f <= cap — a normal solver artifact, not an invalid bound. On a full-scale model this tripped at iteration 4 (slack ~3e-6 > tol 1e-6) and aborted an otherwise-valid solve. The tolerance is now derived from the master solve's own Solution.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_files re-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 on MultiIndex columns only. Entity-less, period-indexed variables (e.g. costs_discounted_p_) carry a single-level column Index named scenario and so skipped it; two compared folders sharing a baked tag (e.g. trade_only_shipping_4 / _2, both baked trade_only_shipping) then produced duplicate columns and crashed plot_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/_neg so charge and discharge both show.
  • Read connection flow by the node's physical end, not flow direction. connection_leftward/rightward are 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 authoritative nodeGroup_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_schemes value-list member lagrangian → benders, and the decomposition_methods member lagrangian_region → benders_regional (every authored solve.decomposition / group.decomposition_method value is rewritten in place by the migration).
  • Knobs. The solve.lagrangian_alpha / lagrangian_max_iter / lagrangian_tolerance parameters are dropped (Benders has no subgradient step) and replaced by solve.benders_max_iter (default 50) and solve.benders_tolerance (default 1e-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_flextool over 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 via polar_high.parallel. The worker count is the machine-local FLEXTOOL_BENDERS_WORKERS env 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-workers flag / GUI knob, which never shipped.
  • Implementation. Driver in flextool/engine_polars/_benders.py (master + multi-cut loop, solve_benders / BendersResult); region slicing in flextool/engine_polars/_region_filter.py. Uses the polar-high primitives WarmProblem.add_cut_row / add_recourse_col / solve(retry_on_unknown=…) and the polar_high.parallel helpers (floor raised to polar-high>=3.0.0). Benders is HiGHS-only (the master is a persistent WarmProblem).
  • Stochastic branch weights reach the objective. Confirmed (and pinned by a regression test) that per-branch probability weights from solve.stochastic_branches flow through the derived cascade into the objective — the stochastic twin of the representative-period-weight fix above; the dense 1.0 baseline set in apply_derived_a is superseded by the real sibling-normalised weights in apply_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-iteration LB / UB / gap line and a lock-guarded "iter k: done (obj=…)" line as each region finishes (fired from the parallel worker via a new solve_benders subsolve_callback). The start line carries the run config inside its tag so it stays visible past the long region list. Set FLEXTOOL_BENDERS_VERBOSE=1 to restore the full native HiGHS log.

Desktop GUI

  • Fix a crash when the migration dialog's grab_set fires 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.decomposition parameter (value list decomposition_schemes); set it to lagrangian to decompose that solve's investment problem by owner region. The Lagrangian behaviour is tuned per solve by lagrangian_alpha, lagrangian_max_iter and lagrangian_tolerance (grouped under solve_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-high only when the installed version supports it.

Thread-parallel Lagrangian subsolves

  • --lagrangian-workers N (CLI; mirrored to FLEXTOOL_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 to polar-high's max_workers and a per-subsolve progress callback, both version-gated via inspect.signature — an older polar-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 with timeset_weights on 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/Map values 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_stepduration resolution land in the block balance equation; the per-(d,t) price output extracts and broadcasts that block dual instead of raising a node_prices_dt_e KeyError.
  • Cost-by-entity comparison plot is now grouped + stacked correctly (and a frivolous scenario_rule is dropped).
  • Dispatch plots: build each scenario's dispatch DataFrame once to avoid pandas fragmentation; silence a degenerate dispatch y-limit; correct the connection_dt_eee comparison column spec.

Input fix

  • Cast p_flow_max values to Float64 on 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 screeninfo for flicker-free, multi-monitor-aware positioning. Fixes the earlier single-monitor winfo_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 — and winfo_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 >= 0 and 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.85 fudge plus a raise-only guard made the result wildly machine-dependent. With the process DPI-aware, tk scaling is now set to the documented pixels-per-point dpi/72 unconditionally 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, default 0.0001): LP matrix coefficients and RHS terms whose absolute value is below it are floored to 0.0 when building the problem, narrowing the LP's numerical range (conditioning). The floor is a generic polar_high feature (Problem.coef_zero_threshold) applied at every coefficient/RHS finalize point; FlexTool reads the parameter (spec → provider → FlexData) and sets it in build_flextool. Registered in autoscale PARAMETER_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; MainWindow is the Tk root). Add a MIP relative gap control (a checkbox gates emission — off defers to the .opt baseline / solver default; default 0.001, 0 valid) 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 a co2_method=total model reported CO2 [Mt] = 0 despite a correct engine cap.
  • Read the CO₂-accumulator unitsize on its actual entity axis — the rolling-accumulator preferred the e-keyed p_all_entity_unitsize carrier but unconditionally selected p, crashing the output stage on the first co2_method=total model 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 real v_flow[unit, input_node, sink] arc, matching nothing → empty row → HiGHS presolve Infeasible (e.g. a nuclear unit with a fixed output 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, after results is 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_meta derives 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 inlined dt→d period- annualization expressions are centralized onto one annualize_dt_to_d helper (behaviour-preserving, golden-byte-identical including timestep_weight≠1).
  • Metadata is embedded in the parquet footer and surfaced in every renderer: a CSV datapackage.json sidecar (the CSVs themselves stay byte-identical), an Excel metadata sheet + header-cell hover comments, Spine fills the empty parameter_definition descriptions 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-fly a (total) and w (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; dt group/node flows and raw inflow are the un-integrated MW level (not per-step MWh); startup_cumulative is units/a; and default_plots.yaml value-axis units are corrected (nodeGroup energy, marginal investment, entity_annuity = CUR/MW/a). Schema-vs-_output_meta drift 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 / time index levels instead of positional slots, so an entity-first dt output (e.g. group_flow__dt with 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 / w plot variants.

Desktop GUI

  • Open .sqlite results 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_colors test fixtures to the v58 top-level flowGroup class, and regenerate the base_weighted node__d.csv golden for the timeslice-weight-aligned dt→d aggregation.

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 over t with RHS -Σ_t inflow, no storage / self-discharge terms, excluding nodes that also carry a state; the vq_state_up/down slack domain is widened to nodeBalance ∪ nodeBalancePeriod. Gated to a no-op (and collapses to the per-dt nodeBalance_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 the dt→d energy/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-dt balance 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 flowGroup entity class + group__unit__node / group__connection__node re-homed to flowGroup__*; the engine read/solve path (flow-limit readers, the 3-dim membership index-set populator, _param_shapes allow-list, autoscale PARAMETER_TYPES) now resolves the four flow-limit params against flowGroup.
  • flow_aggregator becomes a method enum (none / dispatch_plots_only / standalone_aggregator_only / both) that subsumes the dropped boolean output_flowGroup_indicators; output_nodeGroup_dispatch / output_nodeGroup_indicators are renamed to print_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.csv standalone timewise output; fixes a latent group_process_node backfill bug; heals missing DC-power-flow parameter metadata. Migration authored with corrected flowGroup / 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-project plot_settings.yaml at a project folder instead of the repo root. The folder is chosen by editing one line in the gitignored, user-local file templates/project_folder.txt (seeded by flextool-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.txt mechanism. The FlexTool run Tool's command argument changed from --flextool-location templates/flextool_location.txt to --project-folder-file templates/project_folder.txt. The legacy --flextool-location CLI argument still works as a fallback for old / forked setups.

Results SpineDB from parquet replay

  • output-spinedb=true in 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-spinedb now defaults to true in the Toolbox output-settings template, so a fresh project gets results.sqlite without flipping the option.
  • The cold-start results-DB build is cross-platform: the create-from-JSON is serialized by an exclusive sidecar lock (fcntl.flock on POSIX, msvcrt.locking on Windows) and the atomic publish retries the transient Windows "file still in use" (WinError 32) os.replace, so parallel scenario writers into one shared results.sqlite no longer race or fail on Windows.

Per-project plot_settings in Toolbox

  • A plot_settings.yaml placed 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 setting project_folder.txt to that project.
  • Spine-Toolbox Basic Console (Windows): the run survives the Console's python -i REPL, and the full HiGHS solver log is now visible there — the latter via polar-high>=2.5.0, which re-emits the log through sys.stdout (the Console only captures the Python stream, not the native fd-1 write).

Migration

  • Run flextool-update — it seeds templates/project_folder.txt and 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→solve maps) 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 a FlexToolConfigError naming 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; the group class is split into nodeGroup / flowGroup by membership.
  • Comparison plots colour their scenario series from an editable scenarios section; the dead config.yaml system 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_spinedb writer with round-trip tests + the checked-in results schema JSON; wired into the dispatcher and CLIs (--write-methods spinedb, or the output-spinedb setting in the output template). One Spine alternative per run; time series as nested Map values 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_e outputs. The sign convention is stated on the plot value-axis label.
  • Fixes: 1/scale_the_objective on the v_invest reduced-cost writer; period- cap constraints aligned to coef = unitsize / RHS-in-MW; a guaranteed period axis for the sole-binding maxInvest_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_coeff into the minFlow_minload floor and apply capacity_max_coeff to direct arcs (both were previously ignored on those paths).
  • Reverse flow for lossless 2-way connections (method_2way_1var_off) via a shared non-negative v_flow_back aux 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_sync and capacity_margin demand 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.py probes 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; highspy 1.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. faulthandler enabled in cmd_run_flextool. Neither remediation is made the default; FLEXTOOL_NO_ENV_CHECK opts out. 31 tests in tests/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-python stays >=3.9.
  • Pin polars>=1.40: 1.40 introduced pl.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-solve engine 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.py and an orphaned golden; reconcile the emit manifest.
  • Drop the dead group_label_width from 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 0 disables 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, and repr-based value rendering.
  • Converted families: pdtProcess, pdtNode, pdtProcess_source / pdtProcess_sink, pdtCommodity, pdtGroup / pdGroup / pdtProfile, the varCost pair + pssdt_varCost filters, 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-12 tolerance 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.yml workflow, the bin/glpsol CI permission step, and the GLPK clauses in LICENSE.txt. HiGHS (via highspy) is the only solver. The DB migration that rewrites a legacy solver=glpsol to highs is unchanged.

Docs

  • docs/dev/engine_polars.md documents the vectorized per-roll emit; CONTRIBUTING.md and CLAUDE.md record 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.py buckets 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 -> timeset timeline collapse is now deterministic.
  • Input: p_section wired on the synthetic-solve early return.
  • Input: an explicit null parameter value in a higher-priority alternative — the way a scenario clears a value set in the base alternative (e.g. nulling a node's constraint_invested_capacity_coeff Map) — now resolves to "unset" instead of crashing the solve with a misleading "Unrecognised Map index column(s) []" error.
  • The test_a_lot scenario 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, or pip install --upgrade for 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_CHECK environment 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 flextool command launches the desktop GUI; flextool/flextool-gui are windowed launchers (no stray console on Windows), backed by a rotating flextool_gui.log so 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 (including after) 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 explicit none off-member added to conversion_methods, profile_methods and ramp_methods; is_* / has_* flags retyped to the yes_no list; the redundant storage_nested_fix_method.no member dropped.
  • is_enabled re-added on constraint and the reserve__upDown__{unit,connection}__node classes (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_api and use_row_scaling parameters are gone — solver options now live in the solver_arguments map, and the rest are CLI flags (--scaling, --solver-log-level, --solver-time-limit, --solver-io-api, --highs-threads). db_migration.py carries every rename/fold forward.

Input-shape correctness — authoritative parameter-shape resolver

  • New engine_polars/_param_shapes.py resolver detects each DB-authored parameter's shape from its nesting depth + per-level index_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_load and other_operational_cost derivation 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_cstr now 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=1 turns 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_path rather than depending on a checked-in SQLite (which lagged the schema); a session-scoped schema_db_url fixture 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.md is 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-only progress_callback and cancel_check hooks plus a MigrationCancelled exception 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 through check_and_upgrade_database(); adds public helpers needs_flextool_migration() and get_target_flextool_version() so the GUI can pre-check which files actually need migration without performing one. A dedicated except MigrationCancelled branch 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 into input_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.py and migration_progress_dialog.py (modal with spinner, current-file label, thread-safe update_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 native tk.Menu popups to match the current sv_ttk theme (bg/fg/activebackground/selectcolor) and binds font="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 by flextool/schemas/default_plots.yaml, not referenced by any code path, not shipped via pyproject.toml package-data.
  • default_plots.yaml — scenario_rule: l added to three sub-configs; one unit_time_plots variant flipped from h to l.

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 .sol file no longer re-loads the entire MPS into a parent-side highspy.Highs instance just to extract column names and dual arrays. A new _parse_highs_sol parser plus a duck-typed _SolHighsShim expose 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}.parquet on cold runs used to write 0.0 for the objective (the prior getObjectiveValue() call had no value to read); the shim returns the value parsed from the .sol's Objective: line. If you have analysis built on v_obj parquets 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 after Outputs written" (kills the one-iter retention lag). Extended to also null flex_data, flex_data_provider, and solution._vars on 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-recent Solution.highs and flex_data_provider of 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._vars and flex_data are 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 drops flex_data_provider by 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 by FLEXTOOL_MEM_SAMPLER=1. Writes timestamped TSV-ish lines to /tmp/flextool_mem_sampler_<pid>.log (override with FLEXTOOL_MEM_SAMPLER_LOG=path) at 100 ms cadence (override with FLEXTOOL_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 /proc parsing 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 written makes 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.fill helper 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's docs/guide/env-vars.md for the POLAR_HIGH_* side.

  • docs/dev/inject_between_solves.md (new) — developer guide for the run_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 stale FLEXTOOL_AUTO_SCALE=0 references; the real variable is FLEXTOOL_SCALING=off.

  • Two stale _orchestration.py comments referencing the Stage A6-deleted _layer2._rewrite_term_lazy helper updated to describe the actual side-vector mechanism that replaced it.

Maintenance

  • polar-high>=2.0.2 → polar-high>=2.2.0 in pyproject.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.py and flextool/engine_polars/_orchestration.py — ruff clean. Earlier the sampler import sat between the MALLOC_ARENA_MAX setdefault and the workload imports, triggering 15 E402 errors; moved the start_mem_sampler() call to the first statement after imports. Pre-existing F401 / F841 / F821 warnings in _orchestration.py also cleaned (unused imports/locals removed, polars as pl and Callable added to the TYPE_CHECKING block).

  • _audit_reports/FLEXTOOL_PYRAMID_HANDOFF.md and _audit_reports/storage_binding_method_callsites.md removed from the git index (the _audit_reports/ and specs/ 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_layer2 no longer mutates Problem._cstrs or Problem._obj_terms. It writes the per-column factor into Problem._layer2_col_factor (as 1 / cf_math, the inverse convention required by consumers that multiply rather than divide) and the per-row factor into Problem._layer2_row_factor (forward rf_math). Sets Problem._layer2_locked = True and Problem._canonical_dirty = True. Var.lower / Var.upper continue to be mutated in place — they are scalar per family, the cost is O(n_var_families), and they must be visible to any caller of Var (not just to the matrix consumers). Layer2Plan's public shape is unchanged; unscale_solution is 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_ROWS family-size skip added in 4.0.0a2 to apply_layer2's row-factor loop is no longer needed and was removed. (The equivalent skip still lives in bucket_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_lazy helper.

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 plus bind_intraperiod_blocks as 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 the storage_binding_method Phase A-F commits.
  • DB schema v55 — migration auto-runs on DB load via migrate_database(path); bumps FLEXTOOL_DB_VERSION 54 → 55. Refreshes both the value_list members and the parameter description on existing DBs to match the new schema template.
  • autoscale package (commits 92b98efd through d4a4d9df) — semantic per-type scaling layer between the cascade and HiGHS; the legacy scaling.py is fully retired. --auto-scale / --scaling CLI 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_method parameter values must be migrated before use; see the v52→v55 migration chain in flextool/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 (commit 7b119abd) — three tests in test_handoff_cumulative_carriers.py referenced pre-Phase-4.1a column names (value, mwh) that no longer exist; producers emit the canonical p_ladder_cum_sim_hours / p_ladder_cum_realized_mwh. Test-only fix.
  • Group C-reader (commit 79dc699b) — three csv.reader sites in flextool/engine_polars/_blocks.py (_read_input_rows plus two header probes in emit_block_data_for_solve) read from disk directly, violating the Provider-only cascade contract. Routed through input._provider_open so the Provider serves the bytes in cascade mode and disk is consulted only as the non-cascade-caller fallback.
  • Group C-writer (commit 460539b1) — gated the p_entity_period_existing_capacity.csv disk write on a new csv_dump flag threaded from state.csv_dump through write_outputs_for_solve → write_all_handoffs → write_p_entity_period_existing_capacity. The in-memory SolveHandoff.realized_existing already carries the data, and _load_prior_existing already prefers it over disk; the unconditional CSV write was a redundant dump.
  • Group A (output path) (commit 6a05f5a4) — _entity_all_capacity in read_parameters.py needs each sub- solve's v_invest_p/n + v_divest_p/n long-form frames to compute the total column in unit_capacity__d.csv (and the sister CSVs). Polar-high's recent memory optimisation releases Solution._vars internally 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 into OrchestrationStep.captured_vars immediately before the step is deposited and exposed them through a SnapshotSolution wrapper via a new effective_solution property. Fixes multi_year and wind_battery_invest_lifetime_renew_4solve.
  • Group A (parity tests) (commit 905781e7, supersedes 993a0cde) — 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 with test_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:3427 used the wrong p_unitsize (process-indexed) instead of p_state_unitsize (node-indexed) on the use_reference_price objective term. Surfaced by a new γ-style child-only wiring test under tests/engine_polars/constraints/. The stale xfail at test_cost_aggregation_semantics.py:943 retired.
  • fix_usage producer (input.py:5706-5837) promoted to the full formula (efficiency slope + min_load section + noEff branch), mirroring the LP constraint LHS at model.py:1500-1537. Closes the legacy producer/constraint asymmetry documented in specs/feature_fixes.md §3.
  • Upward dispatch→storage state carrier (SolveHandoff.upward_roll_end_state + HANDOFF_UPWARD_ROLL_END_STATE provider 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 (commit 4e857535) — deleted three silently-divergent duplicates of tests/fixtures/{tests,stochastics,lh2_three_region}.json under tests/engine_polars/fixtures/. Same line counts, but drifted parameter defaults (e.g. null vs 1.0). The tests/engine_polars/fixtures/ directory's remaining role is exposing flex_toy_*.py modules on sys.path.
  • Stage 2a (commit eb352d3c) — extended schema migration coverage to tests/fixtures/*.json via a new flextool.update_flextool.test_fixtures module mirroring the existing canonical_databases.migrate_all pattern. Brought the seven test fixtures up to FLEXTOOL_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 (commits 3494d7d6 + c763e637 + 0ff95715) — built flextool/update_flextool/generate_canonical.py + flextool/schemas/canonical_databases/_recipes.yaml and wired templates_examples.json + howto_stochastics.json as generated projections of tests.json / stochastics.json. The remaining seven howto_*.json + templates_time_settings_only.json stay 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 (commit 79a02552) — flextool/update_flextool/ extend_tests_fixture.py adds an agent-friendly YAML-delta workflow for append-only additions to tests/fixtures/*.json (new entities + alternatives + scalar parameter values + scenarios). Validates against spinedb_schema.json with 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 (commit 4d73460b) — deleted four nested-battery replay-style parity tests + _handoff_seed.py. Same scenarios covered end-to-end via JSON-path in test_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/*.json at current schema
  • generate_canonical --verify — generated canonical files in sync
  • canonical_databases verify — authoritative canonical files at current schema (new; mirrors test_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 with csv_dump=True + keep_solutions=True and snapshots the last step's flex_data_provider so 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_fixture values 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.py adds 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 by test_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 in test_orchestration) replace discover_workdirs() glob with a curated PARITY_SWEEP_CASES list in a new tests/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.py shim deleted (Tier 4 Commit 3, 4813b490).
  • scaling.py / scaling_report.py / blocks.py / runner_state.py / solve_handoff.py consolidated into flextool/engine_polars/ — scaling.py + scaling_report.py merged with the engine-side copies, blocks.py relocated, RunnerState + PathConfig consolidated into engine_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 the FlexToolRunner class).
  • 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__.py no longer re-exports FlexToolRunner; callers that still need the class import it from flextool.engine_polars._db_loader directly. All in-tree call sites were updated; the only external surface affected is from flextool import FlexToolRunner, which is now from flextool.engine_polars._db_loader import FlexToolRunner.

Doc sweep

  • Cleanup passes A-D refreshed flextool.flextoolrunner.* references across tests/, specs/, docs/, migration/, and the top of RELEASE.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 over nodeState_rp × rp_base_period_set.
  • v_state_rp_start[n, d, t] — free starting state at each RP-block-first step, indexed over nodeState_rp × rp_block_first.
  • Intra-period state change inside nodeBalance (mirrors .mod:2197- 2200): for n ∈ nodeState_rp, within-timeset lag for (d, t) ∉ rp_block_first, and (v_state_rp_start − v_state) · unitsize for the first step of each RP block.
  • Inter-period constraints:
  • rp_inter_period_balance (.mod:2965-2975) — couples v_state_inter along rp_base_chain via the weighted sum of intra-block state changes.
  • rp_inter_period_cyclic (.mod:2978-2988) — closes the chain by equating the first-to-last v_state_inter delta to the same weighted sum keyed at b_first.
  • rp_inter_period_max_state (.mod:2991-2997) — capacity bound on v_state_inter at every (n, b, d).
  • maxState_rp_start — sibling of maxState that bounds v_state_rp_start (the .mod expressed this as a per-row Var.upper, which the polars engine doesn't yet support).
  • New storage_bind_using_blended_weights projection helper + SIMPLE_PROJECTIONS mapping entry in _projection_params.py — mirrors storage_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 a ValueError naming the missing field when nodeState_rp is non-empty but any of its tightly-coupled siblings is empty.

Supporting RP-set restoration

  • Reverses the relevant parts of 752dff3f to 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_frames and moves the rp_base_period_set / rp_rep_period_set derivation into the same place so it can see rp_weights (the previous derivation lived in emit_per_solve_sets which runs BEFORE emit_rp_data in the cascade — always wrote empty frames).
  • Adds nine K.SOLVE_DATA_RP_* / K.SOLVE_DATA_NODE_STATE_RP constants to _provider_keys.py (no .csv suffix, 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_dict returns representative_period_weights as flat triples [base, rep, weight] when the 2-level nested Map is flattened via convert_map_to_table, not as [base, inner_map_or_list]. Both copies of _decode_rp_weights (engine_polars/_timeline.py and flextoolrunner/timeline_config.py) now detect and decode the flat-triple shape; the nested-list shape stays in place. Without this fix state.timeline.rp_weights was silently empty, the RP gate in _native_run_model fell through to emit_empty_rp_data, and bind_using_blended_weights silently degraded to no state-continuity.

Tests

  • tests/test_representative_periods.py::TestRPAllRepresented::test_all_represented_matches_full — was strict-xfail at 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 → b2 chain), 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, proving maxState_rp_start binds.

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 K inside emit_per_solve_sets. Phase 1 of this release added a module-level K import whose use at line ~169 tripped UnboundLocalError because the dead function-local re-binding made K function-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-high v1.4.0 adds Problem(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 a user_bound_scale recommendation via setOptionValue before Highs.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 on Solution.streamed_lp_ranges for callers that want to inspect.
  • All three real flextool Problem() construction sites opt in to auto_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 to False. The legacy input-data heuristic was firing on every solve and clamping to N=-10 on 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 the project_user_bound_scale_geo_midpoint design note).
  • The new resolution order is:
  • --user-bound-scale N CLI flag (env var FLEXTOOL_USER_BOUND_SCALE).
  • DB solve.user_bound_scale parameter.
  • polar-high stream-time recommendation (Problem(auto_user_bound_scale=True)).
  • No user_bound_scale emitted — 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_ranges parameters 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 three FLEXTOOL_PEEK_LP_RANGES env-var-gated peek_lp_ranges() call sites (cascade warm path, cascade cold path, run_single_solve_from_db).
  • Test reorganisation:
  • tests/model/test_invest_chain_regression.py now reads step.solution.streamed_lp_ranges and calls polar_high.engine._recommend_user_bound_scale directly, instead of rebuilding the LP via Problem() + 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 deleted tests/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._MemoryRecorder now report RssAnon + VmSwap (anonymous resident set plus the same anonymous pages that have been swapped out) instead of total VmRSS. 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 what systemd-oomd's PSI signal effectively responds to and what the system monitor's "Used" line shows. Display labels are now mem= / Δmem= (previously rss= / Δ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.py looks up / creates one Provider per distinct level_key, stored on state._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_problem and _prior_data before 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_providers is initialised explicitly at both engine entry points (_fast_load.fast_load_single_solve_from_db and _orchestration.run_chain_from_db), beside the existing state.handoffs = {} init. The lazy hasattr probe in _native_run_model stays as a defensive fallback.

Solution slimming during cascade

  • Every per-iter polar_high.Solution used to be parked on OrchestrationStep.solution in _FlexpyCascadeSolver._all_steps with its full _vars dict (one Var.frame pl.DataFrame per variable, sized to the LP). The slim block at _native_run_model's tail (step.solution = None for non-last steps) only runs AFTER native_run_model returns — 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/ and flextool/textual_templates/ both retire, replaced by a single flextool/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_*.json templates → flextool/schemas/pre_v26/
  • flextool/textual_templates/* (YAML/TXT configs + canonical_databases/) → flextool/schemas/
  • Every caller (Python, docs, pyproject.toml package-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.csv from test whitelists; it is no longer emitted. scale_the_objective read routed through the Provider (B2 cascade fix).
  • Revert an accidental git add that pulled specs/ markdown files into version control; restore .gitignore intent.

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 only col_bound and returns 0 for FlexTool LPs in practice — the work was discarded. All three call sites in _orchestration.py gated behind FLEXTOOL_PEEK_LP_RANGES=1 for 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's user_bound_scale option (multiplies col bounds and RHS by 2**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-pinned on default for HiGHS's presolve. Useful for memory or numerical diagnostics; off will 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_solve from the emit_periods writer chain to an inline provider.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 each SolveHandoff field into a dedicated handoff/<field>.csv Provider key: realized_invest, realized_existing, divest_cumulative, cumulative_co2, cumulative_commodity, cum_sim_hours. Empty header-only frames written when the field is None so consumers can read unconditionally and check frame.height > 0.
  • Phase 2.2: migrate _emit_co2_accumulators to the translator.
  • Phase 2.3: drop the prior_handoff parameter from the preprocessing cascade — every consumer now reads handoff/<field> from the Provider.

Provider consolidation — kill the legacy paths (Phases 3-4)

  • Phase 3a: delete capture_post_solve dead code.
  • Phase 3b: drop the .csv suffix from key constants — keys are Provider lookups, not paths.
  • Phase 4.0-E/F: trim vestigial CROSS_SOLVE_KEYS entries; tighten the chain-cumulative handoff test.
  • Phase 4.1a-l: 12 sub-phases migrating the fix_storage cross- iteration path. Splits the wide SolveHandoff.fix_storage field into narrow fix_storage_quantity / fix_storage_price carriers 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_realized pair set, CSV fallback arms in _emit_chain_params, SolveContext.p_entity_period_existing_capacity, the _read_capacity legacy fallback, and ladder_cum_* CSV fallbacks.
  • Phase 4.2-1 (closeout): delete CROSS_SOLVE_KEYS plumbing — state.cross_solve_carriers, the iteration-start seeding loops, the iteration-end extraction loop, and the CROSS_SOLVE_KEYS tuple itself. The per-sub-solve Provider is now constructed exclusively from cascade_input_provider + the translator pipeline.
  • Phase 4.2-2: drop the FlexDataProvider bare↔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 (10 K.OVERRIDE_* constants parallel to K.HANDOFF_*). New translate_overrides_to_provider(overrides_dict, provider): maps user-facing K.HANDOFF_X keys to K.OVERRIDE_X writes, raises ValueError on unwhitelisted keys. read_handoff_frame checks override/<field> first and falls back to handoff/<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 source parameter on FlexDataProvider.put(key, frame, *, source=None) + companion get_source(name) accessor. Eviction clears the source entry too. translate_overrides_to_provider passes source="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_price into FlexData (the _emit_arc_unions producer was emitting-and-forgetting; the consumer placeholder at tests/.../decomposition/_components.py:847 was already gated on getattr(...)).
  • B1b: add the use_reference_price objective term per legacy flextool.mod:2107-2111:
    obj += − Σ_{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]
    
    Fixtures exercising use_reference_price will see their objective shift to reflect the previously-missing term.
  • B2: implement the fix_storage_price dual-extraction producer in build_handoff_from_flexpy (the inline comment at input.py:5108-5119 said it was left unfilled). Extracts row duals from the node-balance constraint for fix_price-method storage nodes at their fix_storage_timesteps, applies normalization (-1 / inflation_factor * period_share / scale_the_objective), and populates SolveHandoff.fix_storage_price with the canonical [node, period, step, p_fix_storage_price] schema. Downstream routes through handoff/fix_storage_price → derive_p_storage_state_reference_price → p_storage_state_reference_price → B1b's objective term. No fixture exercises fix_price yet (B5 territory); existing fixtures unchanged.
  • B3: mirror B2 for fix_storage_usage — extract v_flow primals for fix_usage-method storage nodes over the dispatch window, weight by step_duration, populate SolveHandoff.fix_storage_usage with canonical [node, period, step, p_fix_storage_usage] schema.
  • B4-pre + B4: load p_fix_storage_usage into FlexData; add the LP-side node_storage_usage_fix_le constraint per legacy flextool.mod:2775-2800 — net summed energy flow over the dispatch window for fix_usage-method storage nodes ≤ storage-solve target usage. LHS uses the full legacy formula with efficiency corrections for sink flows; RHS sums p_fix_storage_usage via dtt_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 for nodeGroupDispatch__* / nodeGroup* indicator sets. After the writer→emitter refactor (3.42.0) the per-sub-solve cascade keeps these 15 frames in the FlexDataProvider and only flushes to disk under --csv-dump. Toolbox runs and the test suite (which drive the cascade via run_chain_from_db) ended up with an empty workdir/input/ and partial workdir/solve_data/, so the _backfill_group_indicator_sets loop tripped FileNotFoundError before emitting group_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-dump and other disk-emitting paths still work.
  • engine_polars.spinedb_backend.tabular_reader: column-type assignment for Solve sequence parameters. The tabular reader was applying the wrong dtype to Solve'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_7 hand-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_keys module — a single source of truth for the parent-qualified key strings, replacing scattered f"{parent}/{stem}" literals across the emitters and callers.

Preprocessing fixes

  • pdGroup_penalty_capacity_margin: densify against groupCapacityMargin so the row coverage matches the constraint emission (closes SCEN-6).
  • investment_index: include period_in_use in the index so capacity_margin golden joins on the full (entity, period) grid (closes SCEN-1 + SCEN-2 — golden regenerated).
  • reserve__upDown__*.is_active orphans get assigned the reserve parameter_group instead of null, which was failing the export-to-tabular YAML round-trip.
  • solve_advanced export-to-tabular YAML extended with the v44 + v52 params it was missing.

Test infrastructure

  • tests/engine_polars/conftest.py: lift _reset_global_axis_enums to the top-level conftest.py so the Enum reset fires between every test (not just within engine_polars/). Closes SCEN-3 — the global-axis Enum cache was carrying state across test modules.
  • pytest collection collisions cleared via importlib mode + top-level pytest_plugins (closes WF-1).
  • Test-path bumps to track the 3.41.3 file move (closes WF-8).
  • Dead-test cleanup: six execution_manager_wrap retired-cap paths (closes WF-2); test_base_dat_declares_unidirectional (closes WF-4).
  • _reset_global_axis_enums lifted to top-level conftest (closes SCEN-3).

Dead-key cleanup

  • Drop the unread p_years_represented_d_calc.csv writer.
  • 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 nodeGroupDispatch Provider 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_csv round-trip).
  • --csv-dump is 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_*.py filenames + write_* symbols are gone from the public API surface of flextool.engine_polars. Downstream code calling them directly needs to switch to the emit_* 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 (from schemas/spinedb_schema.json + schemas/canonical_databases/*.json), ./templates/*.xlsx (NEW — derived via export_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 the preprocessing_solve_time.run dispatcher, 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.py and three other fully- dead legacy modules — the GMPL stubs left in place at 3.33.0 as placeholders for the native rewire (now landed via engine_polars._lagrangian).
  • FlexToolRunner.run_model + the legacy main entry point + the orchestration import — only reachable via the deleted --engine=gmpl path.
  • solver_runner.resolve_relax_feasibility / resolve_ipm helpers + their env-var constants — both mapped the removed --relax-feasibility / --ipm CLI flags onto HiGHS options. Only consumer was tests/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_solve monkey-patch — belt-and-suspenders diagnostic from the GMPL→polars transition.
  • Two retired test modules: legacy-vs-polars parity tests + the broken manual fixture-regeneration scripts.
  • Untracked-but-committed harness/scratchpad files cleared.

Scaling output

  • scaling_analysis.json gated 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_flextool entry sequence, and split the load_flextool entry 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-folder was omitted the CLI kept two variables (work_folder=None for the solver, wf=cwd for write_outputs), so run_chain_from_db spun up its own /tmp/flexpy_run_chain_* while write_outputs looked for output_raw/ under cwd and tripped a FileNotFoundError on v_obj.csv (output_csv/, output_parquet/, output_excel/, output_plots/ were skipped on the failure path). Visible from Toolbox runs as a write_outputs failed warning 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 only sqlite: / postgresql: schemes and prepended sqlite:/// to anything else, turning Toolbox's engine-server URLs (http://127.0.0.1:<port>) into sqlite:///http://... and erroring with unable to open database file. The guard now passes through any URL that already carries a scheme and only prepends sqlite:/// for bare filesystem paths — spinedb_api's DatabaseMapping already resolves Toolbox server URLs natively via get_db_url_from_server.
  • Scenario comparison: cross-scenario combine no longer fails with KeyError: '[nan] not in index' in prepare_plot_data when some scenarios produce empty result frames. calc_connections used to build empty connection_d / connection_losses_d without a column- level name; once the per-scenario parquet writer wrapped them with pd.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 to NaN, 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 KeyError under 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 the load_flextool entry 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_period now return Param with 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 with dt.
  • _filter_param_by_periods operates on p.lazy (NOT p.frame, which would trigger polar_high's eager cache), shape-aware (filters on whichever of {d, t} are present in p.dims), returns Param(p.dims, lf).
  • p_process_availability_from_source promotes per-class parts (unit + connection) to union dims via lazy joins on period_filter for 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.py flow_upper_rhs availability fold, storage_state reference value chain.
  • _region_filter._inject_half_flows concat of virtual half-flow availability rows.
  • _dump_csvs pdt*.csv slice 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_entity pandas 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.py with compute_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_dt stops populating the fields; slow-path FlexData(...) calls pass None.
  • 8 consumer sites in model.py (add_var domains for v_flow / vq_state / v_state; Sum / add_cstr over= arguments; one explicit join with pd_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_flows uses compute_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.

  • _MemoryRecorder now emits log lines unconditionally (RSS reads from /proc are essentially free). FLEXTOOL_MEMORY_DIAGNOSTICS=1 additionally enables tracemalloc (so traced_peak becomes a real number rather than -) and writes the per-checkpoint CSV under solve_data/memory_diagnostics.csv. Without the env var the log lines still appear but show peak=- / Δ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::_timed routes through the recorder when one is active, falling back to the legacy plain print only when no recorder is registered (e.g. unit tests outside run_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 handles period1_upper / _realized / _lower / _mid forecast branch rows.
  • autouse fixture resets global axis_enums ContextVar 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 as enum on left does not match enum on right SchemaError. Autouse fixture in tests/engine_polars/conftest.py clears it before/after each test. Unblocked ~20 previously-flaky tests across test_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_frames so 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_cluster overwrote five flex_data fields with provider-only reads, nuking seeds that _load_branch_artefacts had 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's invest_5weeks.invest_periods Map has BOTH levels named "x" so SpineDbReader._discover_index_cols emitted ["x", "x"] and _emit_leaf collapsed them deepest-wins, silently dropping the outer anchor index. Disambiguate colliding nested-Map index names by suffixing deeper levels with _<depth+1> (x outer + x inner → 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:1233 correctly 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.py and model.py — producer-side fix applied. Sub-cluster C: multi-year wind no-invest — _cumulative_invest.py join-key mismatch on p Enum (cross-vocab); applied align_join_dtypes / cast_dim pattern.
  • 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 established cast_dim / align_join_dtypes pattern. Cleared ~30 tests across test_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) on test_scaling_parity removed (the p_unitsize excludes-node-unitsizes bug was fixed by Phase 4 vocab union). _cumulative_invest.py Phase 4.8h boundary-cast pattern applied to 4 more emit sites. New FlexData.process_source_toSink_dc field carries the one-direction toSink frame for DC arcs. input.py:1787 threaded provider= to _read_active_solve in _load_invest synthetic-solve gate detection. cmd_run_flextool.py passes regions=regions_detected to solve_lagrangian. Various test-side cast_dim reads to match production Enum.
  • rename_to_axis entity→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-solve dtt seed routing.
  • Threaded provider= to chained-existing-capacity readers. Restored the cross-solve handoff chain for p_entity_all_existing (similar shape to v3.39.0's apply_derived_f fix).
  • Unscale sol.obj in place so step.solution.obj matches v_obj parquet. Objective values written to parquet were unscaled but the in-memory step.solution.obj was left scaled, surfacing as a parity gap downstream of the solver step.
  • Lazy NPV port — add missing ed_lifetime_fixed_cost arms. 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 downstream is_in semi-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_periods without realized_periods
  • unit_inputNode: coefficient applied 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_outputs to give the flow of the desired nodes in a grouped format
  • Option for loss of load sharing share_loss_of_load between 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