Skip to content

Balance internal commodity nodes (heat/steam networks) via first-class balance groups - #2289

Merged
Flix6x merged 283 commits into
mainfrom
feat/internal-commodity-balance
Aug 4, 2026
Merged

Balance internal commodity nodes (heat/steam networks) via first-class balance groups#2289
Flix6x merged 283 commits into
mainfrom
feat/internal-commodity-balance

Conversation

@Flix6x

@Flix6x Flix6x commented Jul 11, 2026

Copy link
Copy Markdown
Member

Description

Stacked on #2218. Completes the missing piece of the CHP/factory work: scheduling a multi-commodity factory end-to-end through StorageScheduler.compute().

  • device_scheduler gains a balance_groups argument: each group lists the devices of an internal commodity node (a heat or steam network without a grid connection) whose stock-side flows must sum to zero at every time step. This replaces the earlier workaround of overlapping stock groups with a min=max=0 "reference device" (which never worked automatically from a flex-model — see the investigation notes on dev/simulate-factory).
  • The StorageScheduler derives balance groups from the flex-config: a non-electricity commodity without energy prices in the flex-context is treated as an internal node (previously this raised Missing consumption price). Electricity still requires a price.
  • Converters are described in the flex-model as one entry per commodity port, tied together by a coupling group — so per-commodity balance groups are disjoint and no overlapping group tricks are needed.
  • Added changelog item in documentation/changelog.rst
  • Docs: a worked converter + internal-node example in the multi-commodity tutorial (tut_converters), cross-referenced from features/scheduling.rst.

Example flex-model

A converter is one flex-model entry per commodity port, tied by a shared coupling name; a commodity with no price in the flex-context becomes an internal node. A CHP (gas → steam + electricity) feeding an internal steam node:

"flex-model": [
  {"sensor": 6, "commodity": "gas",         "coupling": "chp", "coupling-coefficient": 1.0, "consumption-capacity": "20 kW"},
  {"sensor": 7, "commodity": "steam",       "coupling": "chp", "coupling-coefficient": 0.5, "production-capacity": "10 kW"},
  {"sensor": 8, "commodity": "electricity", "coupling": "chp", "coupling-coefficient": 0.3, "production-capacity": "6 kW"}
]
"flex-context": [
  {"commodity": "electricity", "consumption-price": "50 EUR/MWh", "production-price": "50 EUR/MWh"},
  {"commodity": "gas",         "consumption-price": "20 EUR/MWh", "production-price": "20 EUR/MWh"},
  {"commodity": "steam",       "inflexible-consumption": [{"sensor": 10}]}
]

Each kW of gas produces 0.5 kW steam + 0.3 kW electricity, so 20 kW of gas caps steam at 10 kW and electricity at 6 kW. Each port gives exactly one directional capacity, and that is what marks its direction — the opposite direction defaults to zero, so it need not be written out. steam has no price → internal node: its devices (the CHP/steamer producing, the fixed demand consuming) balance each other every step. The full factory (e-heater + boiler → internal heat, steamer → internal steam, CHP → steam + grid electricity) is in the new docs section and in test_factory_chp_dispatch_through_storage_scheduler.

Converter shorthand — how it would map (follow-up, not in this PR)

A first-class converter shorthand would expand into the per-port + coupling form above, e.g.:

{"converter": "chp",
 "gas":         {"sensor": 6, "power-capacity": "20 kW"},
 "steam":       {"sensor": 7, "coupling-coefficient": 0.5},
 "electricity": {"sensor": 8, "coupling-coefficient": 0.3}}

→ expands to the three coupled entries: the first-listed port (gas) is the import-only coupling reference at coefficient 1.0; the remaining ports (steam, electricity) are export-only at their coefficients. This is noted as a possible future improvement to reduce verbosity; the explicit per-port form is what this PR ships.

Both scheduler backends

#2364 landed a second, Pyomo-free backend that builds the HiGHS model directly, and made it the default (FLEXMEASURES_LP_SOLVER = "highspy"). The node balance is modelled in both:

  • Pyomo pathnode_balance_rule, unchanged from the original commits here.
  • Direct HiGHS path — one equality row per node per time step, sum_d(ems_power[d, j]) == 0.

Groups with no devices are dropped during preparation, so they contribute no row at all, matching the Pyomo path's Constraint.Skip. That detail matters on the direct path, which drops rows whose bounds no finite value can satisfy and skips free rows outright: an empty group had to produce no row rather than a degenerate one.

The balance_group_specs collection lives in scheduling_problem.py (the solver-agnostic module from #2364), as a field on SchedulingProblem, so both backends read it from one place.

An internal_commodity_balance scenario in test_highspy_equivalence.py runs a heat node through both backends.

Caveat worth recording. The first version of that scenario was worthless: both heat devices had a free flow band and no cost incentive, so the optimum was zero flow whether or not the balance was enforced — it passed with the balance rows disabled. Pinning the consumer's draw with derivative equals makes the balance the only reason the producer runs, and the scenario now fails correctly when the rows are removed. Any future scenario here needs the same check: a balance over devices that have no reason to move is satisfied trivially, and asserts nothing.

CI: 12/13 checks pass, tests on Python 3.10/3.11/3.12, DCO signed off.

Further improvements

  • balance_groups is the fifth device-grouping parameter on device_scheduler, after stock_groups, ems_constraint_groups, the commitment device_group and Combined Heat and Power (CHP) #2218's coupling_groups. All five are ultimately a set of devices plus a kind of constraint over it, each with its own shape and its own spec-collection block. scheduling_problem.py now owns every one of those collection steps, which makes it the natural place to converge them on one membership concept with a menu of constraint kinds. Engine-side counterpart of the "converge site capacity and group capacity?" question in the flex-config placement draft.
  • Balance vs coupling should stay distinct, and be documented as such: coupling (Combined Heat and Power (CHP) #2218) fixes members in fixed proportion to a common level; a balance group constrains their sum to zero. Both read as "devices that move together", so the difference is worth spelling out.

How to test

pytest flexmeasures/data/models/planning/tests/test_commitments.py -k factory_chp_dispatch
pytest flexmeasures/data/models/planning/tests/test_storage.py -k factory_chp_dispatch_through_storage_scheduler

The engine-level factory test is parametrized to run in both modes (reference-device stock groups and balance groups) and must produce identical dispatch. The new StorageScheduler test drives the full factory (CHP + gas boiler + e-heater + steamer meeting a fixed 15 kW steam demand from an inflexible sensor) purely from a flex-model and flex-context.

Related Items

Stacked on #2218; continues the work explored on dev/simulate-factory (#2113-era factory simulation).

🤖 Generated with Claude Code

https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B

Ahmad-Wahid and others added 30 commits February 10, 2026 13:00
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
…multi-feed-stock

# Conflicts:
#	flexmeasures/data/models/planning/tests/test_commitments.py
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Flix6x added a commit that referenced this pull request Aug 3, 2026
The dispatch to the direct HiGHS backend listed its keyword arguments by
hand. That is a trap for the branches currently adding scheduling
parameters: whoever adds the next one (coupling_groups in #2218,
balance_groups in #2289) works on the Pyomo model further down the file,
and a parameter missing from the dispatch list would not fail. It would
simply never reach the backend, producing a schedule computed as if the
constraint had never been requested -- and since this PR makes "highspy"
the default solver, that would be silently wrong.

Forward by name instead, mapping device_scheduler's signature onto the
backend's. An argument the backend does not model raises NotImplementedError
naming it, but only when the caller actually set it, so leaving a future
parameter at its default stays free. The signature comparison is cached on
the two function objects (~70 us once per process, 2 us per call after).

Also record, in the highspy module docstring, that the deliberate omission
of ems_flow_commitment_equalities stops being harmless once #2355 gives
those rows bounds, and that #2380 routes unscoped flex-context commitments
through grouped_commitment_equalities (which this backend does build).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>
@Flix6x

Flix6x commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Direct HiGHS backend support — done in this branch

(Updated: this note originally described what #2364 would require of this branch. That work has since landed here, so it now records what is in rather than what is needed.)

#2364 is merged, and feat/chp has been updated for it (see the corresponding note on #2218). 1db34e3ca merges the updated feat/chp into this branch and does the same one level up.

balance_group_specs moved to the shared module. It is now a field on the SchedulingProblem dataclass produced by prepare_scheduling_problem(), so both backends read it from one place. node_balance_rule in linear_optimization.py is unchanged.

balance_groups is modelled in the direct backend — one equality per node per time step:

sum_d(ems_power[d, j]) == 0

Groups with no devices are dropped during preparation, so they contribute no row, which matches the Pyomo path's Constraint.Skip for that case. That mattered: the direct backend drops rows whose bounds no finite value can satisfy and skips free rows outright, so an empty group had to produce no row rather than a degenerate one.

Equivalence coverage, and a caveat worth recording. An internal_commodity_balance scenario runs a heat node with a producer and a consumer through both backends.

The first version of that scenario was worthless, and only a mutation test caught it: both heat devices had a free flow band and no cost incentive, so the optimum was zero flow whether or not the balance was enforced — it passed with the balance rows disabled. Pinning the consumer's draw with derivative equals makes the balance the only reason the producer runs, and the scenario now fails correctly when the rows are removed.

Worth remembering for any future scenario here: a balance constraint over devices that have no reason to move is satisfied trivially, so the test asserts nothing. Check that a new scenario fails when you disable the thing it is meant to cover.

CI is green: 12/13 checks pass, tests on Python 3.10/3.11/3.12, with DCO now signed off.

Flix6x added a commit that referenced this pull request Aug 3, 2026
…n) (#2364)

* feat: direct HiGHS (highspy) implementation of the device scheduler

Add flexmeasures/data/models/planning/highspy_optimization.py, which builds
the device scheduler's LP/MILP directly with the HiGHS Python API (highspy),
bypassing Pyomo's model construction and solution-ingestion overhead. The
Pyomo implementation (linear_optimization.device_scheduler) remains the
semantic reference; the module docstring prominently documents that the two
models must be kept in sync, as well as the (verified) deviations:

- ems_flow_commitment_equalities are not built: on the Pyomo path they are
  bound-less, i.e. free rows without any effect on the solution
- rows no finite assignment can satisfy (bounds involving +/-inf quantities)
  are skipped, mirroring HiGHS rejecting such rows when appsi adds them
- solver results and model are lightweight shims exposing the attributes
  callers consume (termination_condition/status strings, commitment_costs,
  commodity_costs, costs, and indexed variable views)

The model is built with vectorized numpy arrays (addVars/addRows/
changeColsCost/changeColsIntegrality), constructing and solving typical
battery problems in milliseconds, where the Pyomo layer needs seconds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD
Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: expose the direct HiGHS backend as solver choice "highspy"

When FLEXMEASURES_LP_SOLVER is set to "highspy", device_scheduler delegates
to device_scheduler_highspy with the same inputs and the same return
contract. All other solver names keep using the Pyomo path unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD
Signed-off-by: F.N. Claessen <claessen@seita.nl>

* tests: prove equivalence of the highspy and Pyomo scheduler backends

- Add "highspy" to the app_with_each_solver fixture params, so tests using
  it also run against the direct backend.
- Add test_highspy_equivalence.py, running representative scenarios (battery
  with prices; soc targets incl. storage efficiency and stock delta; site
  capacity with breach and peak prices; two devices with a StockCommitment;
  an infeasible case) through both appsi_highs and highspy, asserting
  near-identical schedules and costs, and matching termination handling.
- Make test case 2 of test_multiple_devices_simultaneous_scheduler assert
  solver-independent properties (aggregate schedule, total costs, total unmet
  demand): the problem has multiple optima, and only the site-level schedule
  is unique, while the per-device slot allocation is an arbitrary tie-break
  that depends on the solver backend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD
Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: make the direct HiGHS backend the default LP solver

Flip the FLEXMEASURES_LP_SOLVER default from "appsi_highs" to "highspy"
and update the configuration, installation and deployment docs accordingly.
Any Pyomo-based solver remains available as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD
Signed-off-by: F.N. Claessen <claessen@seita.nl>

* docs: point the changelog entry at the actual PR number

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD
Signed-off-by: F.N. Claessen <claessen@seita.nl>

* perf: vectorize the subcommitment conversion

Hoist convert_commitments_to_subcommitments to module level (it is
solver-agnostic and closure-free) and share it between both scheduler
backends, removing the duplicated copy from the highspy module.

Splitting a commitment into per-group subcommitments now uses a single
groupby pass (in order of first appearance, like pd.unique) instead of
filtering the DataFrame once per group, and the price non-uniqueness
checks are vectorized across all groups. This removes a cost that scaled
quadratically with the number of time steps (each time step often forms
its own group), benefiting both backends: a 2-device, 192-step benchmark
drops from 0.47s to 0.28s via appsi_highs and from 0.37s to 0.12s via
highspy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD
Signed-off-by: F.N. Claessen <claessen@seita.nl>

* review: address Copilot comments on solver output and solver docs

- Do not disable HiGHS output unconditionally in the direct backend;
  only force output_flag false when LOGGING_LEVEL is "INFO", mirroring
  exactly how the Pyomo path builds its solver options profile, so
  verbose logging modes can still see solver output. Operator-configured
  FLEXMEASURES_LP_SOLVER_OPTIONS are still applied last and can override.
- Clarify in the configuration docs that a separate solver installation
  is only needed for external solvers such as cbc; both HiGHS-based
  choices (highspy and appsi_highs) rely on the bundled highspy package.
- Remove the now-inconsistent "pip install highspy" instruction from the
  deployment docs, which already state that highspy ships with
  FlexMeasures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD
Signed-off-by: F.N. Claessen <claessen@seita.nl>

* review: remove dead commodity_devices lookup from the direct backend

The commodity -> device indices lookup was only consumed by the Pyomo
path's ems_flow_commitment_equalities, which the direct backend
deliberately does not build (they are free rows). A breadcrumb comment
keeps pointing readers at that documented skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD
Signed-off-by: F.N. Claessen <claessen@seita.nl>

* Forward device_scheduler arguments to the highspy backend by name

The dispatch to the direct HiGHS backend listed its keyword arguments by
hand. That is a trap for the branches currently adding scheduling
parameters: whoever adds the next one (coupling_groups in #2218,
balance_groups in #2289) works on the Pyomo model further down the file,
and a parameter missing from the dispatch list would not fail. It would
simply never reach the backend, producing a schedule computed as if the
constraint had never been requested -- and since this PR makes "highspy"
the default solver, that would be silently wrong.

Forward by name instead, mapping device_scheduler's signature onto the
backend's. An argument the backend does not model raises NotImplementedError
naming it, but only when the caller actually set it, so leaving a future
parameter at its default stays free. The signature comparison is cached on
the two function objects (~70 us once per process, 2 us per call after).

Also record, in the highspy module docstring, that the deliberate omission
of ems_flow_commitment_equalities stops being harmless once #2355 gives
those rows bounds, and that #2380 routes unscoped flex-context commitments
through grouped_commitment_equalities (which this backend does build).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>

* refactor: share the scheduler's solver-agnostic input handling

The two backends build the same model in two representations, so the model
construction is necessarily written twice. Everything around it was too:
199 lines were byte-identical between linear_optimization.py and
highspy_optimization.py -- argument normalisation, stock groups and their
validation, legacy commitment conversion, the sub-commitment split,
device_group_lookup, the convex-curve check, the Big-Ms, band validation,
the HiGHS option profile, and the cost/schedule assembly. None of it has a
solver in it, and keeping it twice meant the two paths could drift apart on
input handling, which the equivalence tests are not aimed at.

Move it to a new scheduling_problem module: prepare_scheduling_problem()
returns a SchedulingProblem that both backends unpack, plus solver_options()
and the result-assembly helpers. Duplication between the backends drops from
199 to 40 lines, and those 40 are the shared signature and the call itself.

Two deliberate changes while moving:

- commodity_devices becomes a cached_property. It is a per-row scan that
  only the Pyomo ems_flow_commitment_equalities needs, so computing it
  eagerly would put a real cost on the fast path. Pyomo pays what it did
  before; the direct backend pays nothing until it needs it (see #2355).
- initial_stock_of() casts its index to int, as the direct backend already
  did. The Pyomo version raised TypeError on the numpy float device indices
  a commitment's "device" column can carry.

The empty-commitments case also stops raising on pd.concat([]), which
previously made the Pyomo path crash where the direct path coped.

Verified: 265 passed, 3 xfailed across the planning suite under all three
solver parameters, plus the scheduling-job and API schedule tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>

* perf: build device_group_lookup from column arrays

Sub-commitments are usually one row each (every time step tends to form its
own commitment group), so this loop slices a fresh two-column DataFrame,
runs dropna() on it and calls iterrows() once per time step. The pandas
per-call overhead, not the work, dominated: profiling a 4-device x 192-step
problem showed 192 dropna() calls accounting for ~50 ms of a ~135 ms
prepare_scheduling_problem, against a model build measured in single-digit
milliseconds.

Read the two columns as arrays once and loop over them instead, replacing
dropna() with an explicit missing-value check that also handles the
collection-valued "device" entries pd.isna would answer element-wise.

The loop itself goes from 49 ms to 1.0 ms (device+device_group) and 65 ms to
0.8 ms (device only) at 192 sub-commitments; prepare_scheduling_problem as a
whole drops from 135 ms to 22 ms.

Equivalence was checked against the previous implementation over device-only
and grouped frames, NaN/None/pd.NA in either column, list/tuple/ndarray
device entries, mixed group key types, stock-scoped and empty frames: same
groups, same members, same insertion order. Reading the column array yields
numpy scalars where iterrows() yielded Python floats on mixed-dtype frames;
the two are interchangeable as set members and dict keys (equal hash and
equality) and both survive the int() casts applied downstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>

* Build the EMS-level flow commitment rows in the direct HiGHS backend

This backend skipped ems_flow_commitment_equalities because the Pyomo path
built those rows without bounds, so they could never bind and HiGHS dropped
them anyway. PR #2355 gives them the same one-sided bounds that
grouped_commitment_equalities uses, so they bind now, and skipping them here
would silently ignore an EMS-level commitment under this backend -- which
this PR makes the default.

The row is the grouped one with a different summation set, so rather than
write it twice, the existing loop's body is extracted into _active_rows (the
commitment's active time steps and bounds) and _add_commitment_rows (bind a
commitment to the summed flow or stock of a set of devices). The EMS-level
loop then sums over every device, or over the commitment's commodity's
devices, and mirrors the Pyomo path in skipping commitments that name a
device group -- those are already bound per group, and binding them twice
over-constrains the problem.

Two equivalence scenarios cover this, so it runs under both backends:
ems_level_flow_commitment (names no device, binds all devices) and
ems_level_commodity_commitment (binds only its commodity's devices, which
also exercises the commodity_devices lookup). Both were checked to fail with
the new rows disabled, so they cannot pass vacuously.

Also drops the now-inaccurate deviation note from the module docstring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>

* review: name the solver-results shim after Pyomo's own class

"Stanza" was an unhelpful coinage. The attribute this shim stands in for holds
a pyomo.opt.results.solver.SolverInformation, so name it _SolverInformation
and say where the name comes from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>

* style: reflow docstrings and comments to break only after punctuation

CLAUDE.md asks that docstrings and comments break lines only after punctuation,
never mid-phrase, with max-line-length 160 and E501 ignored, so that review
comments and text search stay stable. Copilot flagged nine places in this PR
where I had not followed it, and it was right.

Reflowed the docstrings and comments this PR adds, plus the ones it moved into
the new scheduling_problem module. Doing that here rather than in the refactor
commit keeps that commit verifiable as a verbatim move.

Text only. No behaviour, names or logic changed; 290 passed, 3 xfailed across
the planning suite under all three solvers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>

---------

Signed-off-by: F.N. Claessen <claessen@seita.nl>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Flix6x and others added 2 commits August 3, 2026 17:13
…upling

main gained a second scheduler backend (#2364) that builds the HiGHS model
directly and is now the default, plus a scheduling_problem module holding the
input handling both backends share. That conflicts with this branch in exactly
one place, and leaves it one thing to implement.

The conflict is mechanical: the stock-group block this branch rewrote for
multi-group membership was moved, unchanged, into prepare_scheduling_problem().
Both of this branch's additions to that block go there now -- the overlapping
group_to_devices membership, and the coupling_device_specs collection, which
becomes a field on SchedulingProblem so both backends read it from one place.

The implementation is coupling itself. device_scheduler forwards its arguments
to the direct backend by name, so coupling_groups would have raised
NotImplementedError there rather than being silently dropped; it now models the
constraint instead. That is one free variable per group per time step (the
group's common normalised flow) and one equality per coupled device,
ems_power[d, j] - coeff * alpha[g, j] == 0.

Adds a chp_coupling_groups equivalence scenario, so a three-port CHP is
compared across both backends. Checked that it fails with the new rows
disabled, so it cannot pass vacuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Same shape as the merge one level down: the input handling moved into
prepare_scheduling_problem() on main (#2364), so this branch's
balance_group_specs collection moves there too, as a field on
SchedulingProblem that both backends read.

The direct backend now models the node balance rather than raising
NotImplementedError for balance_groups: one equality per node per time step,
sum_d(ems_power[d, j]) == 0. Groups with no devices are dropped during
preparation, so they add no row, matching the Pyomo path's Constraint.Skip.

Adds an internal_commodity_balance equivalence scenario. The first version of
it passed with the balance rows disabled -- both heat devices had a free flow
band and no cost incentive, so the optimum was zero flow either way and the
constraint never bound. Pinning the consumer's draw with "derivative equals"
makes the balance the only reason the producer runs, and the scenario now
fails as it should when the rows are removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Flix6x and others added 2 commits August 3, 2026 18:03
CLAUDE.md asks that docstrings and comments break lines only after punctuation,
never mid-phrase, so review comments and text search stay stable. Copilot
flagged ten places in this PR where the coupling work did not follow it.

Reflowed the direction-inference docstrings in devices.py and storage.py, the
"->" bullet list, and the four coupling schema tests, whose docstrings also
repeated the test name in their first line where a sentence belongs.

Text only. 446 passed, 3 xfailed across the planning and scheduling-schema
suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>
The four StorageFlexModelSchema(start=datetime(2026, 6, 1)) calls built naive
datetimes, against the repo's timezone-awareness convention, and one of them
predates this PR. The file already imports pytz.

Also reflows the _validate_coupling_name docstring, missed in the previous pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Base automatically changed from feat/chp to main August 3, 2026 23:19
Flix6x and others added 5 commits August 4, 2026 01:28
Internal commodity nodes are the point of this PR, and their labels are open --
"steam", "heat", whatever the site calls the node. But DBStorageFlexModelSchema
pinned commodity to OneOf(["electricity", "gas"]), while the API-facing schema
only required a non-empty string. So a converter feeding an internal node could
be scheduled and not stored: triggering worked, persisting the same flex-model
on an asset was rejected.

Found by validating the tutorial's own CHP example against the schema, which
failed on its steam port.

Drops the enumeration and applies the same non-empty check both schemas now
share, so a blank commodity is still rejected. Also removes ALLOWED_COMMODITIES,
which nothing referenced and which duplicated the restriction being lifted.

Simplifies the tutorial example while there: one directional capacity per port
rather than a power-capacity plus an explicit zero, now that the direction is
inferred from whichever capacity is given (#2218). Its magnitudes were also
misleading -- 20 kW of gas caps steam at 10 kW and electricity at 6 kW, where
both were written as 1 MW.

Tests: the tutorial example validates and each port's coupling direction
resolves; an internal commodity is accepted; a blank one is rejected on both
schemas. Checked that the first two fail when the OneOf is put back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>
The multi-commodity tutorial still pointed readers at
inflexible-device-sensors, which #2358 deprecated in favour of
inflexible-consumption / inflexible-production. An internal node's fixed demand
consumes, so inflexible-consumption is the field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>

@Flix6x Flix6x left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please also check the tests against the new mutation-test policy.

Comment thread .git-exclude Outdated
Comment thread documentation/tut/multi-commodity.rst Outdated
Comment thread documentation/tut/multi-commodity.rst Outdated
Comment thread documentation/tut/multi-commodity.rst Outdated
# A non-electricity commodity without energy prices is treated as an
# internal node (e.g. a heat or steam network without a grid
# connection): its devices must balance each other at every time
# step, and it needs no commitments or EMS-level capacity constraints.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that we support group-level commitments, it seems like a small step to support internal-node commitments, too. This might be used to model wear and tear of an internal system, for instance, steam pipe maintainance.

Not sure if there are internal nodes where such costs are actually significant to take into account when scheduling, though.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went to build this, and it does not work in the obvious form — worth writing down before anyone tries again.

A commitment binds quantity + deviations − sum(ems_power over its devices). An internal node's devices are pinned by node_balance_rule to sum_d(ems_power[d, j]) == 0. So a commitment scoped to a node's own device set has a summation term that is identically zero: its deviations are forced to −quantity regardless of the schedule, giving a constant cost that cannot influence any decision.

Checked rather than reasoned about. A heat node with a free producer and a consumer pinned at −0.4, with a flow commitment over both node devices:

wear price 99   term=optimal   costs=0.000   producer=[0.4, 0.4, 0.4, 0.4]
wear price 0    term=optimal   costs=0.000   producer=[0.4, 0.4, 0.4, 0.4]

Identical schedule and identical cost at wear prices of 99 and 0. The commitment binds nothing — exactly the vacuous-test shape #2384 is about, except here it would have been a vacuous feature.

What wear and tear actually needs is throughput, not net flow. The cost of running steam through a pipe scales with how much goes through it, and the node's net is zero by construction precisely because everything produced is consumed. The quantity to price is the one-sided sum — total production into the node, or equivalently total consumption from it.

Two ways to get there:

  1. Available today, no new feature. Scope a commitment to the producing devices of the node via the existing sensors scope. Their summed flow is the node's throughput, and it is a genuine decision variable. This works now and needs nothing from us.
  2. A one-sided or throughput commitment kind, which would be a real addition to the commitments framework rather than a scope change — the deviation variables currently model a signed two-sided band, and throughput is |flow|.

Given (1) covers the steam-pipe case, I would not build (2) on the strength of a hypothetical. You flagged your own uncertainty about whether such costs matter to scheduling; I would leave it until a scenario needs it, and this note stands as the reason the naive version was not shipped.

Happy to add a short docs note under the internal-nodes section saying "to price throughput, scope a commitment to the producing side" if that is useful.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, please add that note.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added, in the internal-nodes section of the tutorial:

Pricing what flows through a node. An internal node's flows sum to zero by construction, so a commitment scoped to the node itself would bind nothing: there is no net flow to price.
To put a cost on throughput — pipe wear, say, or a conversion levy — scope the commitment to the devices producing into the node, whose summed flow is what actually passes through it.

Placed with the internal-node explanation rather than under commitments, since that is where a reader meets the zero-sum property and is most likely to reach for the thing that does not work.

Comment thread flexmeasures/data/models/planning/storage.py Outdated
Comment thread flexmeasures/data/models/planning/storage.py Outdated
Flix6x and others added 2 commits August 4, 2026 11:01
…-node log

Addresses part of the review:

- Removes .git-exclude, which held a lone "conftest.py" and reached this branch
  through the 2026-08-02 merge from feat/chp. It is on no other branch and has
  no business in the tree.
- Applies both tutorial suggestions verbatim, and replaces the dangling "This
  is how a whole factory is scheduled" with what it referred to (internal nodes
  and coupled converters together).
- Points the new cross-reference at flex_models_and_schedulers. My first
  attempt invented a label that does not exist, which would have failed the
  docs build.
- Drops the internal-node message from info to debug. Treating an unpriced
  commodity as an internal node is the normal path, not an event worth a line
  per schedule.

Still outstanding from the same review, and deliberately not rushed here:
reflowing this PR's docstrings and comments (the checker from #2386 reports 52
mid-phrase breaks in lines this PR adds), and re-checking the tests against the
mutation-test policy from #2384.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Breaks every line after punctuation, never mid-phrase, per the convention in
.github/instructions/docstrings.instructions.md.

Done by hand across all 48 prose sites the #2386 checker reported in lines this
PR adds, in linear_optimization.py, storage.py, highspy_optimization.py,
schemas/scheduling/__init__.py and the three test modules.

An automated re-wrap was tried first and reverted. It broke two files outright,
collapsing a closing docstring quote onto the following code line, and where it
did parse it left lines dangling on "In other words," and "To add storage to a
node," -- which satisfies "ends in punctuation" while splitting the clause the
rule exists to protect. The check generalises; the fix does not.

Four hits remain and are deliberate: the ASCII topology diagram in
test_factory_chp_dispatch_through_storage_scheduler, which sits in a literal
block and is not prose.

488 passed, 3 xfailed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>
@Flix6x

Flix6x commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

Mutation-test audit (per #2384)

Every test this PR adds, checked by breaking what it covers and confirming it goes red. Three mutations, each reverted after.

1. Node balance disabled (node_balance_rule skipped in Pyomo, balance rows skipped in the direct backend):

FAILED test_factory_chp_dispatch[True]
FAILED test_factory_chp_dispatch_through_storage_scheduler

test_factory_chp_dispatch[False] correctly survives — that is the stock-groups mode, which does not use balance groups. test_commodity_flex_context_internal_node_flag also survives, being a schema test.

2. Internal-node detection disabled (is_internal_node forced to False):

FAILED test_commodity_flex_context_internal_node_flag[context_input0-True]
FAILED test_factory_chp_dispatch_through_storage_scheduler

The engine-level test survives, as it passes balance groups directly rather than deriving them from a flex-context.

3. Coupling disabled (coupling_device_specs skipped in both backends):

FAILED test_factory_chp_dispatch[False]
FAILED test_factory_chp_dispatch[True]
FAILED test_factory_chp_dispatch_through_storage_scheduler

This one was specifically to check that [False] and the dispatch assertions bind at all — mutation 1 leaves [False] passing by design, so on its own it says nothing about whether that parametrization asserts anything. It does.

Result: no vacuous tests. Each of the four fails under at least one mutation, and each survival above is explained by what the test is scoped to rather than by the test being empty.

Already checked earlier in the same way: scenario_internal_commodity_balance (which was vacuous when first written, and was fixed), test_db_flex_model_accepts_an_internal_commodity and test_tutorial_chp_example_validates.

The guidance was a pair of loose paragraphs inside the group-constraints
discussion, with no heading and no label, so nothing could link to it -- the
multi-commodity tutorial had to point at the whole flex-models chapter instead.

Promotes it to its own subsection with a label, and points the tutorial's
cross-reference at it.

Adds the part that was missing rather than merely unlabelled: when to use a
flex-context entry and when to use a flex-model entry. Site base load is a
property of the connection; a device sitting under a particular inverter,
feeder or commodity is a property of the device. Both net the same fixed power
into the grid connection, so the choice is about where it belongs, which was
nowhere stated.

Checked that every cross-reference in the two touched files resolves to a
label that exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Flix6x added a commit that referenced this pull request Aug 4, 2026
A file can enter a branch without anyone deciding it should. It has happened
twice recently: .git-exclude reached #2289 through a merge and survived until
review, and a `git add -A` swept scheduling_problem.py into a commit that
imported nothing from it.

Both were additions. Modified files are nearly always deliberate; a brand-new
file nobody mentioned is the one worth a second look. So the hook lists what a
push adds relative to the merge-base, and names the ones that look unintended:
dotfiles, .orig/.rej/.bak debris, scratch-looking names, build artifacts, and
anything outside the usual directories.

Advisory, exits 0. It cannot know intent -- it can only ask whether the
addition was meant, which is a question an agent can answer and a gate cannot.

The base is the tracked upstream when there is one, else origin/main, and it
prints which base it used, so a wrong guess is visible rather than silent.
That matters here: a stacked branch's base changes when its parent merges.

Self-tested both directions. Against a synthetic commit adding .git-exclude,
scratch/local_probe.py and a legitimate module, it flags the first two and
leaves the module and its own files alone; with only a legitimate addition it
lists it and flags nothing.


Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ

Signed-off-by: F.N. Claessen <claessen@seita.nl>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@Flix6x Flix6x self-assigned this Aug 4, 2026
@Flix6x Flix6x added this to the 1.0.0 milestone Aug 4, 2026
The closing paragraph read as a summary of a whole-factory example, but no such
example is worked through on the page, and no two industrial sites look alike.
It now says what it is actually there to say: coupled converters and internal
nodes compose, so chaining them describes a site, and getting there needs no
new fields -- only more entries of the kinds already shown.

Also adds the throughput note. An internal node's flows sum to zero by
construction, so a commitment scoped to the node binds nothing; to price pipe
wear or a conversion levy, scope it to the devices producing into the node,
whose summed flow is what passes through it. That was verified on the PR: a
commitment over a node's own devices gives an identical schedule and cost at
wear prices of 99 and 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>
@Flix6x
Flix6x merged commit 907f43b into main Aug 4, 2026
12 of 13 checks passed
@Flix6x
Flix6x deleted the feat/internal-commodity-balance branch August 4, 2026 10:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants