Skip to content

Indirect-unit capacity, input shares, per-unit min load and reserve fixes - #346

Merged
jkiviluo merged 26 commits into
mainfrom
Indirect_loose_fixing
Oct 11, 2026
Merged

jkiviluo merged 26 commits into
mainfrom
Indirect_loose_fixing

Conversation

@ArttuTupala

@ArttuTupala ArttuTupala commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Started by @ArttuTupala's observation that a multi-output indirect unit (e.g. a CHP) was limited by its fuel-input bound in dispatch-only solves but not in investment solves. Root-causing that uncovered a set of related capacity, min-load and reserve issues. They are fixed here together, plus a few things found along the way.

Capacity of indirect (multi-output) units

  • New maxOutputSum constraint: unit capacity (existing + invested − retired) caps the sum of outputs, as the docs define it. Previously this was enforced only by accident, through the fuel-input bound, and only in dispatch-only solves.
  • The fuel-input limit is no longer the capacity constraint. It is derived from the conversion equation, so it never binds artificially.
    • Units that can't invest or retire in the solve get a per-element variable bound (needs polar-high ≥ 3.8.0), which removes rows (coal_chp: 528 → 480 rows).
    • Units that can invest or retire keep a constraint row with a corrected investment multiplier.
  • capacity_max_coeff and availability now apply to invested capacity in every capacity-style constraint (maxFlow, reverse flow, ramps, maxOutputSum). Before, they scaled only existing capacity: an output with coefficient 0.2 on a unit investing 900 MW could deliver 900 MW. This also fixes reverse flow on newly built connections and ramping of newly built units.
  • Coefficient meanings clarified:
    • capacity_max_coeff = 0 means a zero cap.
    • conversion_flow_coeff = 0 means an uncapped edge (the hydro pass-through pattern).

Input shares (schema change)

  • On unit__inputNode, capacity_max_coeff / capacity_min_coeff are renamed to input_share_max / input_share_min.
    • input_share_max (default 1): the largest share of the unit's full-load input this input may supply. A poorer heating value is handled by conversion_flow_coeff.
    • input_share_min (default 0): while the unit runs, this input supplies at least this share of the unit's input.
    • Invalid shares are errors: outside [0, 1], minimum shares summing above 1, or min > max.
  • unit__outputNode keeps capacity_max_coeff. Its capacity_min_coeff becomes an optional extra per-output floor (default 0).

Minimum load and reserves

  • Min load applies once per unit, on the sum of outputs. It used to apply on every arc, which forced every input of a multi-input unit to be used: a two-input CHP had to burn its expensive gas (objective 330M instead of 21M), a high-efficiency unit never ran, and an extraction CHP in pure-heat mode made unwanted electricity.
  • Downward reserve can't take a unit below its minimum load.
  • Reserve shortfall (vq_reserve) keeps its 0–1 scaling, but its multiplier is now the largest possible requirement of the group. Before, dynamic and n-1 groups without a reservation could fall short by at most 1 MW, and that 1 MW was free.
  • penalty_reserve defaults to 5000 again. The default was lost in the v56 cleanup, which left reserve requirements without an authored penalty effectively optional.

Other bug fixes

  • Part-load efficiency: indirect min_load_efficiency units were missing the section term, so their input, cost and CO2 were about 31 % too low at full load.
  • Constant delay: a constant delay value silently stopped the unit. Rolling solves with any delayed unit crashed.
  • Delayed inputs: input_share_max was not enforced on delayed inputs with conversion_flow_coeff > 1.
  • Connection delay: delay on a connection silently changed the connection (it lost its losses, and a regular connection became one-way). Connection delay is not implemented, so it is now ignored with a warning.
  • No-limit investment: invest_retire_no_limit was not recognised as "no limit", so the unit could not invest.
  • Rolling roll loader: roll sub-solves ignored output capacity_max_coeff and uncapped edges.
  • polars 2.0 warm-start fix: also released separately as 4.0.5 (release: 4.0.5 — polars 2.0 rolling warm-start fix #347); merged from main here.

Housekeeping

  • Dependency caps: polars>=1.40,<3, pandas>=2.2.3,<4, spinedb-api>=0.36.5,<0.37, highspy>=1.14.0,<1.15 (allows patch releases).
  • Weekly canary workflow (.github/workflows/canary.yml): installs the newest versions with the caps lifted, runs the tests, and keeps a "canary: dependency drift" issue updated.
  • No more flextool.sol / glpsol warning: the GLPK-era solution file is no longer written. Old project highs.opt lines are ignored.

Schema / migration

  • One self-contained, idempotent step: v70. It contains:
    • the input_share_* rename;
    • deletion of the old input-side min values, which no engine since v3.32 applied;
    • deletion, with a printed list, of out-of-range or v36-leftover output capacity_min_coeff values;
    • new defaults;
    • description fixes;
    • the penalty_reserve default.
  • Generated files were rebuilt with the documented commands, and all verify commands pass.
  • This PR merges before the stochastics branch, which will then renumber its own steps after v70.

Results

  • Changed goldens: coal_chp_extraction (electricity can now reach its 800 MW cap; before it was stuck at 470) and test_a_lot (one hour differs at an identical objective). All other scenarios are unchanged.
  • Tests: new tests cover every fix above. CI is green on Linux, macOS and Windows.

Follow-ups (not in this PR)

Listed in docs/dev/unit_floors_and_reserves.md:

  • upward reserve from offline online-capable units; a design for activation and start-up times is in progress;
  • consumer reserve headroom;
  • a downward-reserve rule for connections;
  • the reserve-balance slope factor;
  • connection delay;
  • the dynamic / n-1 reserve activation bug;
  • n-1 as one constraint per failing unit.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q

@ArttuTupala

Copy link
Copy Markdown
Collaborator Author

It seems that the discrepancy on what the capacity of unit with multiple inputs means is in the tests as well.
Therefore it should be decided which of the two it means, and align all to this.

"Currently the documentation claims that the capacity limits the sum of outflows.
However, currently it applies to the outflows separately. Not sure which one is the intended one"
#341

…ct output capacity_max_coeff

The loose bound on an indirect unit's input (fuel) arc is a solver-region
bound only, but it was slope * capacity -- enough fuel for one output at
full capacity -- so multi-output units (CHP) and outputs with
conversion_flow_coeff > 1 were silently capped below their capacity.
Derive it from conversion_indirect instead:
cap * slope * sum_k(sink_conv_k * capacity_max_coeff_k) / src_conv,
scaled by 1/max(delay weight) for delayed units and unconstrained when
an output is uncapped (capacity_max_coeff = 0). This replaces the PR's
sum-of-capacity-coefficients factor, which ignored conversion_flow_coeff,
and drops the division by the input's capacity_max_coeff (backwards: a
coefficient > 1 tightened the bound).

Removing the fuel cap exposed a second bug: indirect OUTPUT arcs are
bound by the existing-only maxToSink RHS, but p_arc_max_cap_coef only
carried capacity_max_coeff for direct arcs, so indirect outputs ignored
it. Include indirect output arcs (process_output_flows) in the producer.

Regenerated goldens for the six affected scenarios (coal_chp,
coal_chp_extraction, aggregate_outputs_network_coal_wind_chp,
test_a_lot, test_a_lot_but_not_multi_year, 2_day_stochastic_dispatch;
the last is a degenerate alternative optimum at identical objective).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
@jkiviluo

jkiviluo commented Oct 9, 2026

Copy link
Copy Markdown
Member

working on this - more complicated than it seemed at first.

jkiviluo and others added 24 commits October 9, 2026 10:21
Unit capacity is documented as "the maximum sum of output flows", but the
engine only capped each arc (maxFlow); for indirect units the sum was
enforced only accidentally by the fuel arc's loose bound in dispatch
solves, and not at all in investment solves.

New constraint per (p, d, t), p in process_indirect with output arcs
(process_output_flows; conversion_flow_coeff = 0 outputs excluded):
  sum_k v_flow[p,p,k] + sum_k reserve_up[p,k] - invest + divest
      <= existing/unitsize * availability
reusing maxFlow's invest/divest (p, d) terms once per row, RHS densified
(greenfield 0, availability default 1). capacity_max_coeff stays a
per-arc cap. Neg-cap (p, d) rows get only the flipped >= twin
(maxOutputSum_negCap). Online units additionally get
maxOutputSum_online_<kind>: sum_k v_flow <= v_online * availability.
Registered in autoscale CONSTRAINT_FAMILIES; dynamic names pinned.

Goldens NOT regenerated: coal_chp, coal_chp_extraction,
aggregate_outputs_network_coal_wind_chp, test_a_lot,
test_a_lot_but_not_multi_year, 2_day_stochastic_dispatch differ.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
…=0 is a zero cap

* maxFlow / maxFlow_negCap: capped arcs (direct + indirect outputs) now read
  v_flow - M*Σinvest + M*Σdivest <= M*existing/us, M = capacity_max_coeff x
  availability per (p,source,sink,d,t) — the coefficient and availability
  used to multiply only the existing part. RHS construction moved to
  _max_flow_rhs (capped / loose / uncapped arc classes); loose indirect
  input rows unchanged.
* maxFlow_back (2-way 1-var reverse) now carries the invest/divest terms.
* maxOutputSum: invest/divest scaled by availability.
* maxFlow_online: capacity_max_coeff applied; restricted to capped arcs
  (no longer caps indirect fuel inputs at v_online).
* ramp_*_constraint: RHS = ramp*60*step*coef*(existing + Σinv - Σdiv); the
  side's capacity_max_coeff and invested units were missing.
* coeff_zero / uncapped edges re-keyed from capacity_max_coeff == 0 to
  conversion_flow_coeff == 0 (rename artefact of v35): native
  _process_source_sink_coeff_zero_lf and the CSV path derive_coeff_zero.
  New FlexData.process_source_sink_uncapped; such edges get the raw
  p_flow_upper (max_flow_for_unconstrained_variables) in maxFlow and are
  dropped from maxFlow_online, ramp and minFlow_minload.
  p_flow_upper_from_source: _out_uncapped special case removed.
* invest_retire_no_limit recognised as a no-limit method
  (INVEST_METHODS_INVEST_NO_LIMIT) in p_entity_max_units and the CSV
  chain params. Root cause of "no investment": max_units stayed empty ->
  maxInvest_var_bound pinned v_invest <= 0.
* tests: test_capacity_coefficients.py (14 cases); obsolete
  capacity_max_coeff=0-is-uncapped test removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
… descriptions

Description-only migration: uncapped edges are conversion_flow_coeff = 0,
capacity_max_coeff = 0 is a zero cap and scales existing + invested -
retired capacity. Regenerated master template, canonical databases and
test fixtures; CHANGELOG entries for the capacity-coefficient fixes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
…/min

Unit capacity is the maximum SUM of outputs; on an input the coefficient
is the largest share of the unit's full-load fuel that the input may
supply. Rename the input-side parameters accordingly:

  unit__inputNode.capacity_max_coeff -> input_share_max
  unit__inputNode.capacity_min_coeff -> input_share_min

unit__outputNode keeps capacity_max_coeff / capacity_min_coeff.

* db_migration v71: renames the two definitions (values follow by id,
  unchanged), new descriptions; idempotent.
* Engine readers (_derived_params._arc_max_capacity_coef_lf, the
  _direct_params source-side reader, input_derivation CSV specs and the
  CSV-chain p_flow_max reader) read the new names; PARAMETER_TYPES carries
  old + new names for the migration window.
* Regenerated master template, canonical databases and test fixtures.
  The old-FlexTool importer stays on its frozen v56 template (migration
  carries it forward).
* tests/test_v71_migration.py: values move, output side untouched,
  idempotent re-run.

No change in model behaviour in this commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
The branch carries exactly one schema step: the unit__inputNode rename
(capacity_max_coeff -> input_share_max, capacity_min_coeff ->
input_share_min, values moved, new descriptions) now lives in the v70
migration next to the description updates, and FLEXTOOL_DB_VERSION
stays 70.  The rename is idempotent: a definition is renamed only when
the old one exists and the new one does not; otherwise only the
description is refreshed.

Master template, canonical databases and test fixtures regenerated from
their v69 state with the documented commands; all verify commands pass.
tests/test_v71_migration.py -> tests/test_v70_migration.py (v69 seed).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
Unit capacity is the maximum SUM of outputs (maxOutputSum).  Input arcs of
indirect units now carry

  in_s <= input_share_max_s * L_s * availability * capacity
  L_s  = (slope*W + section) * delay_factor / src_div_s

W = most output-side fuel energy per unit of capacity under sum<=1 and
out_k <= capacity_max_coeff_k (greedy by descending conversion_flow_coeff;
replaces fuel_width = sum conv_k*capcoef_k, which ignored the sum cap).
section only for min_load_efficiency units; delay_factor / src_div as
before (min(src_conv,1) for delayed units, src_conv<=0 -> 1).  Inputs with
conversion_flow_coeff = 0 get no limit (keep the unconstrained safeguard).

* New FlexData.p_indirect_input_cap (p,source,sink,d,t) = share * L
  (_derived_params.p_indirect_input_cap_from_source); p_flow_upper's
  indirect input branch = cap * capacity count.
* model._max_flow_rhs: input arcs of units WITHOUT invest/divest vars in
  the solve -> per-element v_flow upper bound (polar-high Param bound), no
  maxFlow row; units WITH them keep the row
  v_flow - M*sum(invest) + M*sum(divest) <= M*existing/us,
  M = cap * availability (the invest term used multiplier 1).
  maxFlow_negCap still covers neg-capacity rows of every arc.
* Direct units: an AUTHORED input_share_max caps the single flow
  (arc factor min(capacity_max_coeff_out, share)); sink-less units keep
  input_share_max as their capacity fraction (unchanged numbers).
* Source-side ramp: indirect input arcs scale with p_indirect_input_cap,
  other arcs with input_share_max.
* Synthetic sub-solves (rolling rolls, nested per-period solves) skipped
  apply_derived_b/d: wire_arc_capacity_params now sets p_arc_max_cap_coef,
  process_source_sink_uncapped and p_indirect_input_cap there too (rolls
  previously ignored output capacity_max_coeff and the conversion=0
  uncapped edges).  CSV-chain derive_p_flow_max mirrors the native formula.
* Warm: new per-arc params are force-cold on any diff (bounds are not
  updated by WarmProblem).  Region filter filters the new param.
* Autoscale Layer 2: per-element bounds feed the bound statistics and are
  rescaled via Var.scale_bounds (scalar path byte-identical).
* Tests: test_input_share.py (W greedy, single fuel conv 1.5/0.5, two fuels
  0.6, share 0.3 bound path + col bounds, share 0.3 invest row path,
  multi-period invest_period, direct unit, rolling), updated
  test_indirect_fuel_arc_bound / test_emission_maxFlow_indirect.

Requires polar-high per-element variable bounds (feat/var-param-bounds).
Goldens NOT regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
…rve floors

Minimum load applies once per unit, on the sum of its output measure (the
conversion_flow_coeff != 0 outputs of an indirect unit, the single arc of a
direct unit): minFlow_minload_<kind> is one row per (p, d, t) with no
coefficient. Input arcs of indirect units lost their per-input floors,
which forced an expensive second input to burn, kept a unit offline when an
input could not supply, stopped heat-pump-like units (efficiency > 1) from
running and forced electricity out of an extraction CHP in pure-heat mode.

unit__outputNode.capacity_min_coeff becomes an optional per-output floor of
multi-output units (minFlow_output_floor_<kind>); it no longer scales the
unit floor.

unit__inputNode.input_share_min: homogeneous mixing row minInputShare
(conv_s * in_s >= share_s * sum conv * in) for indirect units with >= 2
inputs; applies with or without online variables.

Downward reserve (and upward reserve at a unit's input node) can no longer
take a unit below its floor: reserve terms on minFlow_minload /
minFlow_output_floor, plus minFlow_reserve (per unit) and
minFlow_reserve_arc (per arc) zero floors. Source-side reserve of a direct
unit is converted by 1/slope (reserveBalance V1 convention; tripwire test).
Connections are excluded.

Validators (once per scenario, after derive_process_method) for input
shares and output floors; build-time guards in the row builders. New params
listed as warm-start deferred; new constraint families registered for
autoscale. polar-high >= 3.8.0 (per-element variable bounds).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
vq_reserve (r, ud, g, d, t; bounded 0..1) was multiplied by the group's
reservation in the reserve balances and the penalty. A dynamic / n-1 group
of an active reserve without its own reservation had a zero (or 1 MW)
shortfall and no penalty: a hard requirement the new downward-reserve
floors could make infeasible.

reserve_shortfall_scale(d) is now the multiplier everywhere (all balance
rows, objective penalty, output reconstruction in calc_slacks):
reservation (timeseries; the same Param when no dynamic / n-1 group exists,
so timeseries LPs are byte-identical) + sum p_flow_upper * unitsize *
increase_reserve_ratio (dynamic) + sum p_flow_upper * unitsize *
large_failure_ratio (n-1, the same sum over failing processes as the V1
RHS). p_flow_upper carries the max reachable capacity incl. investment;
unlimited investment uses max_flow_for_unconstrained_variables. Densified
over reserve__upDown__group x dt with left joins.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
The (single, version-agnostic) input-share step now also:
* renames unit__inputNode.capacity_min_coeff -> input_share_min with
  default 0 and a new description, deleting every old value (listed);
* sets unit__outputNode.capacity_min_coeff default 1 -> 0 (per-output
  floor), deletes values outside [0, 1] (incl. maps with a bad element) and
  the v36 backfill leftovers (capacity_min_coeff == capacity_max_coeff
  != 1), listing them, and lists units whose kept values change meaning;
* drops the word "fuel" from the input_share_max description.
Idempotent: deletions and notices run only in the pre-step (rename) case.

Generated files restored to their v69 state from main and regenerated with
sync_master_json_template, test_fixtures migrate-all, canonical_databases
migrate-all and generate_canonical; all verify commands pass. The stored
input_share_min 0.0 in howto_hydro_reservoir_with_pump is deleted by the
step.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
reference.md (min_load, capacity_min_coeff, input_share_min/max, the
coefficient table, reserve provision and shortfall), how_to.md (extraction
CHP modes, min-load text), CHANGELOG Unreleased (behaviour changes,
migration deletions, reserve fixes, known limitations) and a docs/dev note
with the formulation and follow-ups.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
Indirect units with conversion_method = min_load_efficiency burnt
slope x output only; the section x online x unitsize term of the
efficiency curve (flextool.mod conversion_indirect) was missing, so fuel,
commodity cost and CO2 were understated (coal_chp at full load: 611.1 MW
instead of 888.9 MW).  Add the term (linear and integer online) to the
right-hand side of conversion_indirect.  That is the only place it
belongs: an indirect unit's input is its own v_flow, so node balance,
commodity cost, CO2 and reported inputs follow.  For delayed units the
section sits at the sink-side time with that time's online count.

test_sum_cap_online_variant now asserts the full-load fuel 800 / 0.9.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
…nits

process_delayed__duration came from a map-only direct-params reader: a
constant `delay` produced no rows, the unit's inputs were anti-joined out
of the undelayed conversion and never put back, so its output was forced
to zero with no message.  In rolling (synthetic-solve) sub-solves the
shift table and weights came from text CSV seeds while the durations were
Float64, and the build crashed on the td join.

Build dtt__delay_duration, p_process_delay_weight and
process_delayed__duration together from one read of unit/connection
`delay` (apply_delay_params), on the full and the synthetic path; the
CSV-seed loader normalises td to Float64 and derives the durations from
the weight keys.  _delay.check_delay_tables raises DelayDataError when a
delayed indirect unit lacks delayed arcs, durations, shift rows or
weights, or when the td dtypes differ.

Docs: unit `delay` is a constant or a map of delays (schema float|map),
not a period value.  Delayed connections ignore their delay (not in
process_indirect; same gap as the .mod) — documented in the dev notes,
unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
…oeff

The input-arc limit L_s used src_div = min(src_conv, 1) for delayed
units, on the stale belief that the delayed conversion term ignores the
input conversion_flow_coeff.  It does apply it (_delay.delayed_input_expr),
so with conv 2 and input_share_max 0.6 a delayed unit ran at full output
(west 1000) instead of 600.  Divide by src_conv for delayed inputs too, in
both producers (_derived_params._indirect_input_cap_lf and the CSV-chain
p_flow_max in _emit_arc_unions), and fix the stale model.py comment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
…of-outputs cap; degenerate alt optimum)

Other scenarios touched by earlier golden regenerations on this branch are
restored to main's versions (values unchanged vs main).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
… + engine)

A reserve group without an authored penalty_reserve had no shortfall
penalty: the loader read explicit values only and add_objective_terms
returned None, so vq_reserve was free and the requirement optional.

Root cause: the GMPL model defaulted it to 5000 (reserveParam_defaults)
and v18 set the same schema default, but the polars port read
parameter_explicit only; the v56 wrong-default cleanup then cleared the
schema default to match the engine instead of fixing the engine.

- engine: PENALTY_RESERVE_DEFAULT = 5000; the loader densifies the
  penalty over every reserve__upDown__group entity (left join, default
  fill); _reserve.reserve_penalty densifies over reserve_upDown_group for
  the objective and the output reconstruction (read_parameters), so a
  group with a requirement but no authored penalty is priced at 5000.
- schema: the v70 step restores the default (idempotent) with a clearer
  description; master template, canonical DBs and fixtures regenerated.
- tests: default applied, authored value wins, densification; v70
  migration test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
…ription)

docs/reference.md claimed connection `delay` works with
transfer_method = no_losses_no_variable_cost. No connection delays its
flow: the delayed input term exists only in conversion_indirect, which
connections never enter. Probe (network chain, west_east, delay 3 h):
for unidirectional, regular and no_losses_no_variable_cost the flow is
not time-shifted, but the connection moves from the efficiency arcs to
noEff (losses dropped; regular also becomes one-way), lowering the
objective. The reference, the how-to and the v70 description of
connection.delay now say it is not applied and should be left empty.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
docs/dev/unit_floors_and_reserves.md follow-ups (existing order kept):
connection delay item now records that the delay drops the connection's
losses; new items for dynamic / n-1 reserves activating only via a sibling
non-zero reservation, and for the n-1 requirement summing all failing
processes (the .mod had one constraint per failing process; the shortfall
scale must become a max when split). CHANGELOG known limitations updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
…c delay pass

The synthetic-solve path now rebuilds the delay tables
(apply_delay_params, 8902930), which reads unit/connection `delay` via
InputSource.parameter.  The A.20 _StubReader only implemented
entities(), so test_synthetic_solve_skips_per_solve_overrides failed
with AttributeError.  The engine call is correct: skipping it would
reintroduce the rolling delayed-unit td-join crash, and parameter() is a
required InputSource protocol method.  The stub now implements it
(unknown class -> KeyError, like the real readers), and the test
records apply_delay_params and asserts it runs on the synthetic path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
…he connection

Connection delay is not implemented (the delayed input term exists only
in conversion_indirect, which connections never enter), but a connection
`delay` still marked the connection delayed: it became fork_yes in the
process-method classifier, its arcs moved from process_source_sink_eff to
_noEff (efficiency losses dropped) and a `regular` connection became
one-way (network chain, west_east, 3 h: objective 585.7M -> 507.5M).

Every reader of `delay` now reads unit.delay only: the input_derivation
delay tables (_specs) and process-method classifier, and the engine's
_classify_process_method, _delay_distributions_from_source (delay
weights, shift table, durations) and _projection_params.process_delayed.
A connection with a delay is modelled exactly as without it, and
_validators.validate_connection_delay warns once per connection with a
non-zero delay. Unit delays are unchanged.

Test: test_connection_delay_ignored.py (regular, unidirectional,
no_losses_no_variable_cost constant 3 h, regular map) - same objective,
flows and arc sets as without the delay, no delay-set rows, one warning.
test_unit_delay::test_delayed_connection_builds now pins that the
connection gets no delay tables. Reference, how-to, dev notes, CHANGELOG
and the connection.delay description (v70 step, schema, canonical DBs,
fixtures) say the delay is ignored with a warning.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
…s-join order)

Warm rolling reuse pushes the new p_inflow into nodeBalance_eq as a
positional vector built from compute_nodeBalance_dt. The constraint rows
are in `over` sorted by its columns (add_cstr determinism wrapper), and
the vector silently relied on the lazy cross join nodeBalance x dt
coming out left-major. polars 2.0 no longer guarantees that: with a
small left side it emits right-major order ((heat,t1),(west,t1),...), so
every other (n, t) inflow landed on the wrong node. In
test_delayed_input_share_rolling_csv_chain roll 1/2 (warm-reused) west
demand became 0 in every other hour and the unit stopped there
(objective 1.007e9 vs the correct 1.577e9) - CI-only, polars 2.0.0.

- _warm: sort new_over by its columns before building the vector and
  keep the left join's order explicitly (maintain_order="left").
- _pdt_join: all six lazy cross-join helpers go through _cross(...) with
  maintain_order="left_right", restoring left-major (sorted) output on
  polars 2 - also keeps the determinism wrapper's already-sorted fast
  path for the big (entity, d, t) grids.
- test_warm_rhs_alignment: both fail on the old code under polars 2.

Works on polars 1.40 and 2.0 (maintain_order exists in both).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
CHANGELOG: this branch's Unreleased section kept above the 4.0.5 release
section. pyproject version 4.0.5; polar-high>=3.8.0 kept. The warm-start
RHS-order fix (_warm.py / _pdt_join.py) was already on this branch
(3fb9988) and merged identically.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
- polars>=1.40,<3 and pandas>=2.2.3,<4 (next major capped; the pandas
  floor is spinedb-api's own requirement).
- spinedb-api>=0.36.5,<0.37: 0.x whose minors break the API (0.33, 0.34,
  0.36); 0.36.5 added the parameter_group table FlexTool uses.
- highspy>=1.14.0,<1.15 (was exactly 1.14.0): patch releases allowed.
  env_check's older-Windows heal (pip install highspy==1.13.1) is
  unaffected - pip only warns about the conflicting requirement (verified).
- polar-high stays uncapped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
.github/workflows/canary.yml (weekly + manual) lifts the pyproject caps
via .github/scripts/dependency_caps.py, installs the newest versions
(eager upgrade), reports installed versions against the caps, runs the
ci.yml full-suite pytest command, and opens/updates a single
"canary: dependency drift" issue on test failure or cap drift; a clean
run closes it. CONTRIBUTING documents the caps policy and the canary.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
highs.opt.template carried GLPK-era write_solution_to_file /
solution_file=flextool.sol / write_solution_style=2, so every solve wrote
a file nothing reads and HiGHS warned "The cost row for the Glpsol
solution file is reported last". Remove the lines from the template and
tests/highs.opt, and drop these keys from the highs.opt layer of the
effective-options resolver so projects seeded from the old template stop
writing it too (silently for the exact old lines; one-time warning for
hand-edited values). A solution file requested via solver_arguments/CLI
is still honoured, so _restore_space_mangled_names stays.

The opt-in native parity sweep used flextool.sol as its "reference
exists" marker; it now checks output_raw/v_obj parquets.

Tests: legacy highs.opt solve writes no .sol and no Glpsol warning;
explicitly requested solution file is written and a unit named
"coal chp" keeps its space in v_flow outputs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cai1ke9MzbEMpY1jfScB6q
@jkiviluo jkiviluo changed the title broadening the inflow limit to the capacity times the number of output flows Indirect-unit capacity, input shares, per-unit min load and reserve fixes Oct 11, 2026
@jkiviluo
jkiviluo merged commit 9323e29 into main Oct 11, 2026
10 checks passed
jkiviluo added a commit that referenced this pull request Oct 12, 2026
Integrate main's indirect-unit capacity / input shares / per-unit min load
/ reserve fixes (#346) and the 4.0.5 release into feat/stochastics-reserve-v74.

Schema version collision resolved: base was v69; main added one migration
(v70); this branch added six (to v75). Both defined a different v70. Kept
main's v70 (capacity-coefficient / input-share migration) at slot 70 and
RENUMBERED the branch's six migrations to 71-76 (FLEXTOOL_DB_VERSION=76);
the migrations are disjoint and order-insensitive. Renamed the six migration
test files test_v70..v75 -> test_v71..v76. Canonical DBs + fixtures
regenerated to a consistent v76 (sync_master_json_template / test_fixtures /
canonical_databases / generate_canonical); the stochastics 2day retag
survived migrate-all.

Engine files model.py (+1543 from main), _derived_params, read_parameters,
drop_levels auto-merged. Three genuine code conflicts resolved deliberately:
- p_min_load: composed structurally — main's per-unit min-load semantics
  live in the minFlow_minload constraint (sum over a unit's outputs), our
  (d,t) map-reading capability is the producer shape; a (p,d,t) p_min_load
  flows through the per-unit constraint unchanged (both sides' tests pass).
- calc_slacks reserve-slack penalty: combined main's shortfall_scale/reindex
  with our Option-A pdt_branch_weight; shortfall_scale subsumes the old
  reservation factor (single-apply, no double-count), matching the LP.
- input.py synthetic-solve wiring: additive union (MINLOAD-1 + DELAY-1 +
  CAPCOEF-1). CHANGELOG: both release-note sets.

Full suite green (4145 passed). Merge performed on scratch branch
integrate/main-4.0.5-stochastics; feat/stochastics-reserve-v74 untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
@jkiviluo
jkiviluo deleted the Indirect_loose_fixing branch October 20, 2026 17:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Indirect_loose for input is constraining outputs when unit has more than one of them

2 participants