Skip to content

Combined Heat and Power (CHP) - #2218

Merged
Flix6x merged 259 commits into
mainfrom
feat/chp
Aug 3, 2026
Merged

Combined Heat and Power (CHP)#2218
Flix6x merged 259 commits into
mainfrom
feat/chp

Conversation

@Flix6x

@Flix6x Flix6x commented Jun 2, 2026

Copy link
Copy Markdown
Member

Description

Adds hard flow coupling between devices, so a single physical unit whose ports move in fixed proportion — a CHP, a heat pump, any converter — can be scheduled as one thing.

  • Support coupling constraints in the device_scheduler to model devices such as CHP
  • Model the coupling in the direct HiGHS backend too, so it holds under the default solver
  • Express converters in the flex-model (coupling / coupling-coefficient), with schema support
  • Added changelog item in documentation/changelog.rst

The constraint

device_scheduler gains a coupling_groups argument. Each entry maps a group name to a list of (device_index, coefficient) tuples. One free decision variable alpha is introduced per group per time step, and every member device is pinned to it:

P[d, j] == coeff_d * alpha[group, j]

so the ports cannot move independently — only the unit's overall level can. Sign convention: positive coefficient for input ports (consuming, positive ems_power), negative for output ports (producing, negative ems_power).

A CHP burning gas to make heat and power:

coupling_groups={"chp": [(0, 1.0), (1, -0.5), (2, -0.3)]}

Each unit of gas in yields 0.5 heat and 0.3 power out. In the flex-model that is one entry per commodity port, tied by a shared coupling name and a coupling-coefficient.

Coupling is a hard constraint on the feasible region, not a preference: it is the converter's physics, and it travels with the device wherever it is scheduled.

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"). Coupling is modelled in both:

  • Pyomo pathcoupling_alpha variable plus flow_coupling_rule.
  • Direct HiGHS path — one free column per group per time step, and one equality row per coupled device.

This is not optional. device_scheduler forwards its arguments to the direct backend by name, so an unimplemented parameter raises NotImplementedError there rather than being silently dropped — and a silently dropped coupling_groups means uncoupled devices, a schedule that looks plausible and is wrong. A signature-coverage test fails the moment the two drift apart.

The solver-agnostic half of the argument handling lives in scheduling_problem.py (also from #2364): the coupling_device_specs collection is built once there, as a field on SchedulingProblem, and both backends read it from the same place.

Testing

pytest -k test_chp_coupling
pytest -k test_factory_chp_dispatch

Plus a chp_coupling_groups scenario in test_highspy_equivalence.py, running a three-port CHP through both backends and comparing schedules and costs. It was verified to fail with the coupling rows disabled, so it cannot pass vacuously — worth stating because test_commitments.py and test_storage.py do not use the app_with_each_solver fixture and therefore only ever run under the configured default solver.

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

The red check is DCO, and it is not a sign-off problem — the app reports it could not evaluate: this PR has 257 commits, GitHub's REST API caps at 250, and the app's GraphQL fallback did not complete. Re-running it is not exposed over the API. It needs a UI re-run or a maintainer override.

Further improvements

Related items

Flix6x and others added 30 commits January 23, 2026 12:54
Signed-off-by: F.N. Claessen <felix@seita.nl>
Signed-off-by: F.N. Claessen <felix@seita.nl>
Signed-off-by: F.N. Claessen <felix@seita.nl>
Signed-off-by: F.N. Claessen <felix@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>
…and map them to the respective group id

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>
Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
…chemas

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…n-gas-and-electricity

# 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>
Infer a coupled (commodity-converting) device's flow direction from which
directional capacity is given, defaulting the unspecified opposite direction
to zero, mirroring how a missing directional site capacity defaults to zero.
An input port now needs only a consumption-capacity, an output port only a
production-capacity; setting the opposite direction to a fixed 0 remains valid
for back-compat. Only genuinely ambiguous cases (both directions flow, or
neither) are rejected.

Also move the #2218 coupling changelog entry onto this branch.

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

Flix6x commented Jul 19, 2026

Copy link
Copy Markdown
Member Author

Addressed the review comment on coupled converter port directions: a coupled device no longer needs the opposite direction set to a fixed 0. The flow direction is now smart-defaulted the same way site capacities are — an input port gives only a consumption-capacity (production defaults to 0), an output port gives only a production-capacity (consumption defaults to 0). Setting the opposite direction to a fixed 0 explicitly still works (back-compat), and only genuinely ambiguous flex-models are rejected: both directions flowing (both non-zero) or neither given.

Changes:

  • _resolve_coupling_coefficient (devices.py) infers the sign from which directional capacity flows, treating an explicit zero on the opposite side as a legacy marker.
  • The validate_coupling_direction_is_unambiguous validator (storage.py) now requires exactly one active direction rather than exactly one fixed zero.
  • Updated the COUPLING_COEFFICIENT metadata docs (regenerated OpenAPI specs) to drop the "must set … to a fixed 0" requirement and explain the defaulting, mirroring the site-capacity defaulting.
  • Added/adjusted tests (schema validation + a direct _resolve_coupling_coefficient direction test); the existing CHP factory/coupling tests that use explicit zeros still pass.

Brings in #2295 (sensor/group-scoped commitments), #2358 (inflexible-consumption
/ inflexible-production replacing inflexible-device-sensors), #2374 (inflexible
devices as assets), #2278 (operation-mode power bands) and #2306 (rate limiting).

Conflict resolutions of note:

* schemas/scheduling/storage.py -- main moved GroupReferenceSchema into the new
  schemas/scheduling/groups.py and storage.py now imports it, so the branch's
  local copy is dropped rather than merged. Kept _validate_coupling_name (still
  used by both flex-model schemas) alongside main's new
  validate_inflexible_flex_model_entry.
* linear_optimization.py -- coupling groups (this branch) and operation-mode
  power bands (main) are independent features; both kept.
* devices.py -- FlexDevice gains main's inflexible-device fields next to this
  branch's coupling fields.
* storage.py -- the device_scheduler call passes both coupling_groups and
  device_power_bands.

flexmeasures/data/models/planning + flexmeasures/data/schemas: 579 passed,
3 xfailed. black and flake8 clean.

Note: #2306 imports `limits`, which is resolved via uv.lock but not declared in
pyproject.toml, so existing venvs need `uv sync --all-groups`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEvWAj45zXaod5WjniF81D
Signed-off-by: F.N. Claessen <felix@seita.nl>
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. It added a second scheduler backend building the HiGHS model directly, made it the default (FLEXMEASURES_LP_SOLVER = "highspy"), and moved the solver-agnostic half of device_scheduler into flexmeasures/data/models/planning/scheduling_problem.py.

b6257e7d3 merges main into this branch and settles all three consequences.

1. The conflict was mechanical. The device_to_group / group_to_devices construction this branch rewrote for multi-group membership had moved, unchanged, into prepare_scheduling_problem(). Both of this branch's additions to that block now live there: the overlapping group_to_devices membership (a commodity converter participates in every node it touches), and the coupling_device_specs collection, which became a field on the SchedulingProblem dataclass so both backends read it from one place.

2. coupling_groups is modelled in the direct backend. Arguments reach it by name, so the parameter would have raised NotImplementedError there rather than being silently dropped — coupled devices quietly not coupled would have been much harder to notice, since the CHP tests predate the default flip. The mirror 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

The Pyomo coupling_alpha variable and flow_coupling_rule are unchanged.

3. Equivalence coverage. A chp_coupling_groups scenario in test_highspy_equivalence.py runs a three-port CHP (gas in 1.0, heat out −0.5, power out −0.3) through both backends. It was checked to fail with the new rows disabled, so it cannot pass vacuously — worth stating, because tests/test_commitments.py and tests/test_storage.py do not use the app_with_each_solver fixture and so only ever run under the configured default solver.

CI is green: 12/13 checks pass, tests on Python 3.10/3.11/3.12.

The one red check is DCO, and it is not a sign-off problem — the app reports it could not evaluate, because this PR has 257 commits and GitHub's REST API caps at 250, after which its GraphQL fallback did not complete. Re-running it via the API is not exposed to a user token.

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>
…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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.

Suppressed comments (10)

flexmeasures/data/schemas/tests/test_scheduling.py:1595

  • Docstring wraps mid-phrase across lines. Reflow to avoid breaking in the middle of a sentence.
    """test_uncoupled_device_needs_no_directional_capacities: the coupling-direction check
    only applies to devices that define a `coupling` field."""

flexmeasures/data/schemas/tests/test_scheduling.py:1604

  • Docstring wraps mid-phrase across multiple lines. Please reflow so line breaks occur only after punctuation.
    """test_blank_coupling_name_is_rejected: a provided coupling name must contain at least
    one non-whitespace character, so unrelated devices cannot be silently coupled under an
    empty group key. This holds for both the scheduling and the db-stored schema."""

flexmeasures/data/schemas/tests/test_scheduling.py:1624

  • Docstring wraps mid-phrase across lines. Reflow to avoid mid-sentence line breaks.
    """test_db_flex_model_coupling_round_trips: a db-stored flex-model (validated via
    DBStorageFlexModelSchema, e.g. by patch_asset) accepts `coupling`/`coupling-coefficient`
    and round-trips them."""

flexmeasures/data/schemas/tests/test_scheduling.py:1577

  • Docstring wraps mid-phrase across multiple lines. This repo’s docstring convention is to break lines only after punctuation, so this should be reflowed (or shortened) to avoid mid-sentence line breaks.

This issue also appears in the following locations of the same file:

  • line 1594
  • line 1602
  • line 1622
    """test_coupling_direction_must_be_unambiguous: a device with a `coupling` field must
    have an unambiguous flow direction, inferred from which directional capacity is given
    (the opposite direction defaults to zero), so the sign of its coupling coefficient can
    be inferred."""

flexmeasures/data/schemas/scheduling/storage.py:664

  • This docstring contains several mid-phrase line breaks (e.g. lines ending with “a” / “be”). The project’s docstring convention is to wrap only after punctuation; please reflow the docstring accordingly.
        """A coupled device must have an inferable flow direction.

        The flow direction is inferred from which directional capacity is given:
        a device with (only) a consumption-capacity is an input (consuming) device,
        and a device with (only) a production-capacity is an output (producing)

flexmeasures/data/models/planning/devices.py:232

  • This docstring’s bullet list uses line endings like “->” and breaks lines mid-phrase, which violates the repo’s docstring line-break convention (wrap only after punctuation). Reflowing the bullets into full sentences makes it consistent and easier to grep/review.
    """Resolve a coupled device's internal signed coupling coefficient.

    Coupling coefficients in flex-models are user-facing positive magnitudes.
    The internal sign is inferred from which directional capacity allows flow
    (mirroring how a missing directional site/device capacity defaults to zero):

    - only a (non-zero) ``consumption_capacity`` flows -> input device ->
      internally positive coefficient
    - only a (non-zero) ``production_capacity`` flows -> output device ->
      internally negative coefficient

    The unspecified direction is assumed to be zero, so the user no longer needs
    to set the opposite direction to a fixed 0 (though doing so still works).
    """

flexmeasures/data/models/planning/scheduling_problem.py:319

  • These comments include mid-phrase line breaks and an em-dash line ending, which conflicts with the repo’s comment wrapping convention (break only after punctuation). Reflow into fewer, sentence-complete lines to keep search/review stable.
    # Group keys are namespaced strings: a declared stock group's key (a state-of-charge sensor id)
    # could otherwise collide with the device index of an ungrouped device,
    # silently merging that device into the stock group.
    #
    # A device may belong to more than one stock group —
    # a commodity converter (e.g. a steamer bridging a heat node and a steam node) participates in every node it touches,
    # so ``group_to_devices`` keeps the full (possibly overlapping) membership.
    # ``device_to_group`` records only the primary group (first assignment wins),
    # used where a single owning group is needed (per-device stock bounds).

flexmeasures/data/models/planning/scheduling_problem.py:371

  • coupling_groups is converted into (group_index, device_index, coefficient) triples without validating device indices or coefficients. In particular, an out-of-range device index can corrupt the direct HiGHS model (column indices are computed arithmetically), potentially producing incorrect schedules or hard-to-diagnose solver errors. Please validate indices and require finite, non-zero coefficients during problem preparation.
    # Collect (group_index, device_index, coefficient) triples for coupling constraints.
    # Each device in each group will be constrained: P[d, j] == coeff * alpha[group, j],
    # where alpha is a free variable representing the common normalised flow.
    coupling_device_specs: list[tuple[int, int, float]] = []
    if coupling_groups:
        for g_idx, (_group_name, members) in enumerate(coupling_groups.items()):
            for d_idx, coeff in members:
                coupling_device_specs.append((g_idx, d_idx, coeff))

flexmeasures/data/schemas/scheduling/storage.py:914

  • DBStorageFlexModelSchema validates that coupling is non-blank, but it does not enforce the same “unambiguous flow direction” rule as StorageFlexModelSchema. This allows persisting flex-model entries with coupling but ambiguous/missing directional capacities, which can later lead to incorrect sign inference (or a late scheduling-time validation failure). Consider applying the same schema-level validation here, so invalid converter-port definitions are rejected at persist time.
    @validates("coupling")
    def validate_coupling(self, coupling: str | None, **kwargs):
        _validate_coupling_name(coupling)

    @validates_schema
    def forbid_time_series_specs(self, data: dict, **kwargs):
        """Do not allow time series specs for the flex-model fields saved in the db."""

flexmeasures/ui/static/openapi-specs.json:7164

  • The OpenAPI schema documents coupling-coefficient with minimum: 0.0, but the actual Marshmallow validation is strictly positive (min_inclusive=False) and tests assert that 0 is rejected. This makes the generated API docs inaccurate; consider reflecting the exclusive lower bound in the schema output.
            "type": "number",
            "default": 1.0,
            "minimum": 0.0,
            "description": "Positive coupling magnitude for this device within its coupling group.\nThe scheduler couples the power flows of all devices in the group: each device's power is its coupling coefficient times the group's common flow level.\nThe flow direction of each device is inferred from which directional capacity is set: a device given only a <code>production-capacity</code> is an output (producing) device, and a device given only a <code>consumption-capacity</code> is an input (consuming) device.\nThe unspecified direction is assumed to be zero (mirroring how a missing directional site capacity defaults to zero), so there is no need to set the opposite direction to a fixed 0 (though setting it explicitly still works).\nFor example, a CHP unit with 50% thermal and 30% electrical efficiency uses a gas input device (coefficient 1), a heat output device (coefficient 0.5) and an electricity output device (coefficient 0.3).\nDefaults to 1.\n",
            "example": 0.5

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.

Suppressed comments (8)

flexmeasures/data/schemas/tests/test_scheduling.py:1596

  • This test constructs a datetime without tzinfo. Use a timezone-aware datetime (e.g. pytz.UTC) for consistency and to avoid accidentally relying on naive datetime behavior.
    schema = StorageFlexModelSchema(start=datetime(2026, 6, 1), sensor=None)

flexmeasures/data/schemas/tests/test_scheduling.py:1613

  • This test uses a naive datetime for start. Use a timezone-aware datetime (this file already imports pytz).
        StorageFlexModelSchema(start=datetime(2026, 6, 1), sensor=None).load(

flexmeasures/data/schemas/scheduling/storage.py:68

  • Docstring wraps mid-phrase (lines don’t end with punctuation), which violates the project’s docstring/comment line-break convention and makes text search/review comments unstable.
    """Reject blank/whitespace-only coupling names.

    A blank coupling name would become a coupling-group key, silently coupling
    unrelated devices under an empty group. When provided, the name must contain
    at least one non-whitespace character.

flexmeasures/data/schemas/scheduling/storage.py:663

  • This docstring wraps mid-sentence and has lines not ending with punctuation (e.g. lines ending with "active"/"works)"), which violates the repo’s line-break-after-punctuation rule for docstrings/comments.
        The unspecified direction is assumed to be zero, mirroring how a missing directional site capacity defaults to zero,
        so the user does not need to set the opposite direction to a fixed 0 (though doing so still works).

        The direction is ambiguous only when both directions are active
        (each side either flows itself, or is marked active by a fixed zero on the opposite side),

flexmeasures/data/models/planning/scheduling_problem.py:319

  • Comment block wraps mid-phrase and includes a line ending with an em dash ("—"), which violates the repo’s line-break-after-punctuation rule for comments/docstrings.
    # Group keys are namespaced strings: a declared stock group's key (a state-of-charge sensor id)
    # could otherwise collide with the device index of an ungrouped device,
    # silently merging that device into the stock group.
    #
    # A device may belong to more than one stock group —

flexmeasures/data/models/planning/devices.py:230

  • Docstring wraps mid-sentence (line ends with "flow"), violating the repo’s rule that docstring/comment lines only break after punctuation.
    Coupling coefficients in flex-models are user-facing positive magnitudes.
    The internal sign is inferred from which directional capacity allows flow
    (mirroring how a missing directional site/device capacity defaults to zero):

flexmeasures/data/schemas/tests/test_scheduling.py:1579

  • This test constructs a datetime without tzinfo. Project convention is to use timezone-aware datetimes throughout (and this file already uses pytz.UTC elsewhere).

This issue also appears in the following locations of the same file:

  • line 1596
  • line 1613
    schema = StorageFlexModelSchema(start=datetime(2026, 6, 1), sensor=None)

flexmeasures/data/schemas/scheduling/storage.py:913

  • DBStorageFlexModelSchema allows persisting coupling/coupling-coefficient without enforcing an unambiguous flow direction (directional capacities). That means an invalid coupled device can be stored in the DB and later be interpreted with an arbitrary sign in _resolve_coupling_coefficient, potentially producing wrong schedules or unexpected infeasibility at scheduling time.
    @validates("coupling")
    def validate_coupling(self, coupling: str | None, **kwargs):
        _validate_coupling_name(coupling)

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>
@Flix6x
Flix6x requested a review from Copilot August 3, 2026 22:11
Flix6x added a commit that referenced this pull request Aug 3, 2026
The docstring convention -- break lines only after punctuation -- has been
missed in three PRs in a row (#2364, #2218, #2385), each time caught by a
reviewer rather than by the author. #2384 documented the rule harder, and the
very next PR broke it, so documentation is evidently not the missing piece.

Adds .claude/hooks/reflow_check.py and wires it as a PreToolUse hook on
`git commit`, next to the existing pre-commit and worktree-guard hooks.

Two deliberate choices:

It is an agent hook, not a pre-commit hook. The misses have been an agent's,
and contributors should not pay a false-positive tax for that. It is also the
only form that works: the check is a heuristic, and a heuristic gate that a
human hits on embedded OpenAPI YAML gets disabled, whereas an agent can read
"line 2028 may break mid-phrase", look, and judge.

It is advisory and never blocks. It exits 0 always, and only inspects lines
being added, so a legacy file is not a wall -- the repo has ~4350 candidate
hits, and reflowing unrelated prose is not a thing a commit should do.

Suppressing non-prose (bullets, RST directives, doctests, embedded YAML and
JSON, shell continuations) cuts the noise substantially where docstrings carry
API specs: sensors.py 494 hits to 59, assets.py 385 to 15. Files that are
genuinely prose-heavy stay high (storage.py 199 to 173), which is the honest
answer rather than a tuned one.

Self-tested both directions: a docstring broken mid-phrase is reported, a
correctly reflowed one is silent.

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.

Suppressed comments (4)

flexmeasures/data/models/planning/scheduling_problem.py:315

  • This inline comment breaks mid-phrase using an em dash at end-of-line. Repo docstring/comment conventions require each physical line to end with a comma/semicolon/colon/period to keep search/review stable.
    # A device may belong to more than one stock group —
    # a commodity converter (e.g. a steamer bridging a heat node and a steam node) participates in every node it touches,

flexmeasures/ui/static/openapi-specs.json:7163

  • The OpenAPI schema for coupling-coefficient declares minimum: 0.0, but the Marshmallow validation in StorageFlexModelSchema/DBStorageFlexModelSchema requires the value to be strictly positive (min_inclusive=False). This mismatch can mislead API clients into sending 0 (which the server will reject).
          "coupling-coefficient": {
            "type": "number",
            "default": 1.0,
            "minimum": 0.0,
            "description": "Positive coupling magnitude for this device within its coupling group.\nThe scheduler couples the power flows of all devices in the group: each device's power is its coupling coefficient times the group's common flow level.\nThe flow direction of each device is inferred from which directional capacity is set: a device given only a <code>production-capacity</code> is an output (producing) device, and a device given only a <code>consumption-capacity</code> is an input (consuming) device.\nThe unspecified direction is assumed to be zero (mirroring how a missing directional site capacity defaults to zero), so there is no need to set the opposite direction to a fixed 0 (though setting it explicitly still works).\nFor example, a CHP unit with 50% thermal and 30% electrical efficiency uses a gas input device (coefficient 1), a heat output device (coefficient 0.5) and an electricity output device (coefficient 0.3).\nDefaults to 1.\n",

flexmeasures/data/schemas/scheduling/storage.py:662

  • Docstring line breaks here violate the repo convention to break lines only after punctuation. Line 659 ends mid-sentence and the next lines continue the clause, which makes docstrings harder to search and quote reliably.
        The direction is ambiguous only when both directions are active
        (each side either flows itself, or is marked active by a fixed zero on the opposite side),
        or when neither is (both missing).
        Such flex-models are rejected.

flexmeasures/data/models/planning/devices.py:223

  • This docstring wraps a sentence mid-phrase (line 222), which violates the repo convention to break lines only after punctuation.
    Coupling coefficients in flex-models are user-facing positive magnitudes.
    The internal sign is inferred from which directional capacity allows flow
    (mirroring how a missing directional site/device capacity defaults to zero):

Flix6x added a commit that referenced this pull request Aug 3, 2026
The docstring convention -- break lines only after punctuation -- has been
missed in three PRs in a row (#2364, #2218, #2385), each time caught by a
reviewer rather than by the author. #2384 documented the rule harder, and the
very next PR broke it, so documentation is evidently not the missing piece.

Adds .claude/hooks/reflow_check.py and wires it as a PreToolUse hook on
`git commit`, next to the existing pre-commit and worktree-guard hooks.

Two deliberate choices:

It is an agent hook, not a pre-commit hook. The misses have been an agent's,
and contributors should not pay a false-positive tax for that. It is also the
only form that works: the check is a heuristic, and a heuristic gate that a
human hits on embedded OpenAPI YAML gets disabled, whereas an agent can read
"line 2028 may break mid-phrase", look, and judge.

It is advisory and never blocks. It exits 0 always, and only inspects lines
being added, so a legacy file is not a wall -- the repo has ~4350 candidate
hits, and reflowing unrelated prose is not a thing a commit should do.

Suppressing non-prose (bullets, RST directives, doctests, embedded YAML and
JSON, shell continuations) cuts the noise substantially where docstrings carry
API specs: sensors.py 494 hits to 59, assets.py 385 to 15. Files that are
genuinely prose-heavy stay high (storage.py 199 to 173), which is the honest
answer rather than a tuned one.

Self-tested both directions: a docstring broken mid-phrase is reported, a
correctly reflowed one is silent.


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 merged commit 39ed39e into main Aug 3, 2026
13 of 14 checks passed
@Flix6x
Flix6x deleted the feat/chp branch August 3, 2026 23:19
Flix6x added a commit that referenced this pull request Aug 3, 2026
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>
Flix6x added a commit that referenced this pull request Aug 4, 2026
…s balance groups (#2289)

* feat: add gas-price field to the Flex-context schema

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* apply black

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* feat: add a test case for two flexible devices with commodity

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* use expected datatypes

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* feat: split commitments per commodity

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* feat: split commitments per commodity

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* Revert "use expected datatypes"

This reverts commit b22c6d7.

* feat: add a test case for different commodities

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* fix: do not produce gas

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: add stock-id field in Storage and DB flex model schemas

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* feat: build stock groups

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* feat: get stock groups

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* feat: add a test case for multi feed stock

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* feat: create a flow commitment for prefering to charge sooner devices

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* add soc constraints for boiler

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* add some assert statments

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* update and add new assertions with clear explanation

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* update the docstring

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* feat: add support for shared storage

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* remove the breakpoint

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* feat: update the test case for two devices with shared stock

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* feat: add assertions with clear reasons

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* refactor: move tiny-price-slope decleration out of the for loop

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* Revert "refactor: move tiny-price-slope decleration out of the for loop"

This reverts commit 2becd02.

* refactor: move tiny-price-slope decleration out of the for loop

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* fix: add data_key attr

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* add missing commodity description and it's field in ui flexmodel schema

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* fix: add missing gas-price field in UI Flexcontext schema

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* Add support for multi-device charging of shared storage

Introduce stock_groups mapping to link multiple devices to a shared SOC.

Aggregate stock delta across devices sharing the same battery.

Update stock change calculation to use combined device flows.

Add device-to-group and group-to-devices lookup for efficient shared stock computation.

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* fix: wrong timezone; the test relied on the preference to charge sooner and discharge later, rather than on the EPEX price transition, as the inline test documentation advertised

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: move preference to charge sooner and discharge later into a StockCommitment to prefer being full

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: test case no longer relies on arbitrage opportunity coming from artificial price slope

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: check for optimal schedule

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: prefer a full storage earlier over later

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* docs: update commitment name and inline comments

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* docs: touch up test explanation

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: update test case given preference for a full battery

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* delete: clean up comment

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: model the preference to curtail later within the same StockCommitment, using a tiny price slope to prefer a fuller SoC sooner rather than later, by lowering penalties later

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: reduce tiny price slope

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* docs: delete duplicate changelog entry

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* docs: fix broken link

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* Revert "fix: reduce tiny price slope"

This reverts commit bf16e63.

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: soc unit conversion

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: adapt test to check for 1 hour of free energy at 15-min scheduling resolution

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* style: black

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: check curtailment preference per distinct device

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: set tight tolerance for HiGHS solver

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* refactor: merge if-blocks

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: update test_two_flexible_assets_with_commodity

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: check curtailment preference per distinct device

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: set tight tolerance for HiGHS solver

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* refactor: merge if-blocks

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: use iloc

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: diminish tiny price slope by number of planning steps

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* refactor: always diminish tiny price slope by number of planning steps, such that its relative weight does not grow with the number of steps

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* chore: increment StorageScheduler version

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: sum all devices soc contribution, and use individual device efficiencies

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* update test case for multi feed stock

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* expect to charge the battery early to see the effect of fully discharge

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* fix: update the assert statements according to the scheduler results

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* fix: use approximation to compare battery and heat pump costs

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* update the assert statements with prefered to charge battery sooner than later

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* dev: first step in resolving merge conflicts

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* chore: code annotation

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: not all flex-models have sensors

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: update the expected ev and battery costs

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* fix: add device id to get costs for the given device

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* fix: static method has no self

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* delete: remove inapplicable fields for stock model

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: fix interpretation of test results

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: move initialization of ems_constraints

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: resolve merge conflicts on _build_soc_schedule, copied from Ahmad

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: remove redundant code block

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* dev: use "state-of-charge" key instead of "sensor" key for stock models

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: skip StockCommitment for device models that outsource their stock model to a separately modeled device

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: old flex models that describe a device that serves both as a feeder and stock are both categorized as device models and stock models

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: model stock devices using the state-of-charge field instead of the sensor field

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: identify asset to merge with db flex-model

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: validation

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: flex-model setup in test

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: create stock group

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* use soc-sensor in case of missing power sensor and also correct stock groups

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* fix: create stock model for a model which has itself stock

* update the assert statements

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* remove stock-id field

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* fix: correct the stock groups

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* refactor: remove unneccessary test function

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* fix: shared soc-gain, soc-usage, soc-minima and soc-maxima

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: shared StockCommitment for preferring a full SoC

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* dev: todo

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* dev: add "test" test case

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: rewrite test to not rely on multi-commodity

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: rewrite test to prove that only when both HPs share a single commitment does the optimizer treat their stocks as a combined resource

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: do not coerce device_group into a time series

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* docs: changelog entry

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* docs: pretty_print is not specifically for FlowCommitments

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* docs: add (back) inline dev notes

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: strengthen asserts

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: add check for exact electricity costs expected

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: sum over electricity costs; and move to StockCommitment to model preference for full soc sooner

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: replace vague assert with explicit asserts

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* docs: lose confusing comment

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: backwards compatibility in case no device is specified;

The two changes together are the backwards-compatible default that was missing: when no device is specified (device=None), to_frame() must emit NaN in the device_group column (not crash), so the optimizer routes the commitment through ems_flow_commitment_equalities exactly as it did before device_group was introduced.

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: when device is present but device_group is absent, fall back to the pre-existing behaviour — each device is its own group

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* dev: update expectations of how costs are shared between EV and battery

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix commodity-level commitments by grouping devices and aligning device series with scheduler index

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* fix commodity-level commitments by grouping devices and aligning device series with scheduler index

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* update the test cases for net commodity consumption and production

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* dev: Support commodity-specific prices and site capacities in storage scheduler

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* dev: Add commodity-specific flex-context schema

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* dev: Add dynamic commodity prices and split flex-context settings to capacity scheduling test

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* feat: create a shared schema for flex-context and commodity-context

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* update the test case to have inflexible-devices-sensors for each commodity-flex-context

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* refactor: loop over flex-context fields and choose all fields except 'gas-price' for electricity as commodity

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* fix: add inflexible-device-sensors to the gas commodity model

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* fix: use net energy costs instead of individual device costs

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* fix: comment out the buggy lines

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* fix: remove self

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* feat: coupling groups for CHP

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: test factory model

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: merge conflicts

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* refactor: make variables for gas boiler and e-heater capacities

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* docs: clarify e-heater efficiency assumption

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: add scenario with merit order: gas boiler ≪ e-heater ≪ CHP

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: support flex-model coupling constraint in StorageScheduler

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* docs: clarify calculation of coupling coefficients

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: invert interpretation of coefficients to better match thermal and electrical efficiencies

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: support multiple inputs to coupling point

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: stop collapsing the heat buffer and steam node in the factory test

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* tests/planning: align storage CHP coupling test with current coefficient validation

Context:\n- test_storage_scheduler_chp_coupling failed because positive coefficients summed to 1.5 while current scheduler validation requires 1.0\n\nChange:\n- adjusted the storage CHP test coefficients and expectations to satisfy current validation semantics\n- kept the test focused on verifying coupled gas/heat/power behavior

* planning/coupling: infer internal sign from directional capacities

Context:\n- Coupling coefficients in flex-models were user-facing signed values, which was error-prone and not user-friendly\n\nChange:\n- treat flex-model coupling-coefficient as a positive magnitude\n- infer internal sign from capacities (consumption-capacity=0 -> output/negative, production-capacity=0 -> input/positive)\n- remove strict positive-sum validation in scheduler coupling-group construction\n- update storage CHP coupling test and schema/openapi documentation to reflect positive-only coefficient input

* tests/planning: clarify signed internal CHP coefficients in storage docstring

Context:\n- The storage CHP test docstring should distinguish user-facing positive flex-model coefficients from the signed internal coefficients\n\nChange:\n- documented that the flex-model uses positive magnitudes\n- explicitly stated the intended internal coefficients: 1.0, -0.5, -0.3

* fix: update the test cases according device level costs

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* docs/scheduling: add COMMODITY and GAS_PRICE metadata field documentation
Context:
- Test test_all_metadata_fields_are_documented was failing because these fields were not documented
- Part of multi-commodity feature development
Change:
- Added ``commodity`` field to the storage flex-model table
- Added ``gas-price`` field to the flex-context table

* fix: add device-model in groups if it's missing

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* fix: restore SOC constraints and state-of-charge handling broken by multi-feed-stock refactor

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* fix: fall back to deprecated price fields

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: typo

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: store commitment costs on job meta

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* refactor: clarify which job is which

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: update test expectation: the battery could save more?

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* dev: add todo

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: price window should match scheduling window

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: comment out unreasoned check

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: update test expectation; apparently the battery could save more?

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* dev: add todo

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* chore: flake8

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* dev: exclude commodities field from flex-context schema referencing a dedicated issue

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: only save commitment costs on job if we have a job to save it on

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: inflexible devices are electricity devices by default

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* delete: no more need for backwards-compatibility of the temporary gas-price field (only used during development)

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* chore: black

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* chore: black

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: optional dict key

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: keep ems-constraints and fix the test cases (#2233)

* fix: keep ems-constraints and fix the test cases

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* Update flexmeasures/data/models/planning/storage.py

Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com>
Signed-off-by: Ahmad Wahid <59763365+Ahmad-Wahid@users.noreply.github.com>

* fix: update the comment and raise value error if ems_constraints_group is not passed

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

---------

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad Wahid <59763365+Ahmad-Wahid@users.noreply.github.com>
Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com>

* fix: keep ems-constraints and fix the test cases (#2233)

* fix: keep ems-constraints and fix the test cases

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

* Update flexmeasures/data/models/planning/storage.py

Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com>
Signed-off-by: Ahmad Wahid <59763365+Ahmad-Wahid@users.noreply.github.com>

* fix: update the comment and raise value error if ems_constraints_group is not passed

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>

---------

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: Ahmad Wahid <59763365+Ahmad-Wahid@users.noreply.github.com>
Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com>
Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: only raise in case of multiple EMS constraint DataFrames

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* chore: the wait for marshmallow-code/apispec#999 is over

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: allow any commodity, with electricity and gas serving as examples

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* delete: remove unreleased flex-context field for gas price

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: add all relaxation fields to the list of fields to ignore when moving old flex-context fields into the electricity commodity context

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* delete: gas_price is no longer a field (remove reference to unreleased field)

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* delete: just treat the whole old flex-context as the electricity flex-context

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* chore: update openapi-specs.json

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: list the commodity field first rather than last

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: commodity is a field in both flex-model and flex-context

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: flex-model commodity can also be more than just electricity and gas

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* docs: remove mention of gas-price field

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* docs: adjust scheduling section for multi-commodity

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* docs: adjust field descriptions for multi-commodity

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* docs: add type annotation

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: keep track of inflexible device sensors per commodity, too

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* refactor: place all group args at the end

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* dev: add todos for checking prices

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* fix: test should exclude COMMODITY_FLEX_CONTEXT and COMMODITY_FLEX_MODEL, which are named commodity in scheduling.rst

Signed-off-by: F.N. Claessen <claessen@seita.nl>

* feat: require an unambiguous flow direction for coupled devices

The sign of a coupling coefficient is inferred from directional capacities,
and a device with both directions open (or blocked) was silently treated as
an input. Reject such flex-models with a validation error instead.

Also promote the coupling field descriptions to MetaData constants and
document both fields in the storage flex-model table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B

* feat: balance internal commodity nodes with first-class balance groups

Adds a balance_groups argument to device_scheduler: each group lists the
devices of an internal commodity node (e.g. a heat or steam network without
a grid connection) whose stock-side flows must sum to zero at every time
step. This replaces the reference-device min=max=0 stock-group workaround
used by the factory scenario, which is now tested in both modes.

The StorageScheduler derives balance groups from the flex-config: a
non-electricity commodity without energy prices becomes an internal node
(previously this raised 'Missing consumption price'). Together with
coupling groups (one flex-model entry per converter port), this makes the
factory scenario (CHP + gas boiler + e-heater meeting a fixed steam demand)
schedulable end-to-end through StorageScheduler.compute().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B

* docs: point balance-groups changelog entry at PR #2279

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B

* fix: balance internal commodity nodes on power flows, not stock-side terms

A device can sit in both a commodity balance group (via its commodity) and
a shared-stock group (via its state-of-charge sensor), e.g. a steamer that
discharges a heat buffer to produce steam. Its derivative efficiencies and
stock delta (e.g. the buffer's soc-usage losses assigned to it) describe
the stock-side conversion and must not leak into the commodity balance:
what crosses the node is the device's power flow (ems_power).

Found while running a realistic factory scenario, where the heat buffer's
soc-usage drain was distorting the steam balance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B

* docs: point the balance-groups changelog entry at PR #2289

PR #2279 was closed in favour of #2289, which carries the feature now.

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

* fix: preserve overlapping stock-group membership for commodity converters

Merging main's group-indexed stock recursion (#2282/#2325) rebuilt
group_to_devices from a single-valued device_to_group (last assignment
wins), which cannot represent a converter device that belongs to more
than one stock group (e.g. a steamer bridging a heat node and a steam
node). That silently dropped such devices from all but their last group,
orphaning coupled outputs (the CHP dispatched to zero in the cheap-gas
merit-order scenario).

Keep main's namespaced group keys (the stock-id/device-index collision
fix) but build group_to_devices directly from the declared stock groups,
preserving full overlapping membership; device_to_group still records a
single primary group for per-device stock bounds.

Restores test_factory_chp_dispatch (all three merit-order scenarios) on
top of the new recursive stock formulation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B

* fix: keep internal-node detection working alongside smart commodity defaults

After merging main's #2272 smart commodity-context defaults, a price-free
commodity context (e.g. a bare {"commodity": "steam"}) gets a
smart-defaulted zero consumption-price, which defeated #2289's
priceless-commodity internal-node detection: the steam balance group
vanished and the CHP dispatched incorrectly through the StorageScheduler.

Record durably on each commodity context whether any price field was
user-given (prices_are_defaulted) and treat a context whose prices were
all defaulted (and which uses no price sensors) as an internal node.
Ports the combo-branch fix upstream now that #2272 has landed on main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B

* style: fix flake8 F811 and black formatting in test_storage.py

Remove a duplicate `GenericAsset` import (F811, a merge artifact) and add
the two blank lines black expects before
test_off_tick_soc_relaxation_covers_all_devices_of_a_shared_stock. Fixes the
failing pre-commit Check on this PR.

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

* docs: add a converter + internal-node example flex-model

Address review: the converter/coupling paragraph in scheduling.rst now
cross-references a worked example. Adds a "Converters between commodities"
section to the multi-commodity tutorial showing a CHP described as one
entry per commodity port tied by a coupling group, plus how an unpriced
commodity becomes an internal balance node.

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

* style: drop duplicate GenericAsset import in test_storage.py (F811)

The main-merge re-introduced a duplicate `GenericAsset` import (already
imported alongside GenericAssetType); flake8 F811. Remove it.

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

* fix(schema): support coupling in db-stored flex-models and reject blank coupling names

Address two review findings on the CHP coupling work:

1. `DBStorageFlexModelSchema` (used to validate persisted flex-models, e.g.
   in patch_asset) did not declare `coupling`/`coupling-coefficient`, so
   storing a db flex-model containing `coupling` failed with an unknown-field
   ValidationError. Add both fields to that schema, mirroring how they are
   declared on `StorageFlexModelSchema`.

2. A provided `coupling` name could be an empty/whitespace-only string, which
   would become a coupling-group key and silently couple unrelated devices
   under an empty group. Reject blank/whitespace-only names via a shared
   `_validate_coupling_name` helper wired into a `@validates("coupling")` on
   both schemas.

Add tests: a db-stored flex-model with `coupling` validates and round-trips,
and blank `coupling` values (on both schemas) raise a ValidationError.

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

* fix(scheduling): treat capacity-only commodity as grid-connected, not internal node

Address four review findings:

1. A commodity that declares a grid connection via capacity fields only
   (site-power/consumption/production-capacity) but no explicit price was
   wrongly flagged as having defaulted prices, so the scheduler misclassified
   it as an internal node (EMS constraints skipped, per-step balance forced).
   The internal-node determination now considers capacity fields too: a
   commodity is an internal node only when the user gave neither prices nor any
   capacity/grid-connection signal. Renamed the durable flag from
   prices_are_defaulted to is_internal_node accordingly, and added a test.
2. Fixed the balance_groups docstring in linear_optimization.py to describe the
   actual commodity-side sum(ems_power)==0 balance (not a stock-side flow one).
3. + 4. Docs (scheduling.rst, multi-commodity.rst): clarified that electricity
   is always assumed grid-connected, so missing electricity prices raise an
   error rather than turning electricity into an internal node.

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

* fix(ui): add coupling fields to UI_FLEX_MODEL_SCHEMA

DBStorageFlexModelSchema gained coupling/coupling-coefficient, but the UI
flex-model schema did not, breaking test_ui_flexmodel_schema (which enforces
parity between the two). Add the matching UI entries.

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

* feat(scheduling): smart-default coupled device flow direction

Infer a coupled (commodity-converting) device's flow direction from which
directional capacity is given, defaulting the unspecified opposite direction
to zero, mirroring how a missing directional site capacity defaults to zero.
An input port now needs only a consumption-capacity, an output port only a
production-capacity; setting the opposite direction to a fixed 0 remains valid
for back-compat. Only genuinely ambiguous cases (both directions flow, or
neither) are rejected.

Also move the #2218 coupling changelog entry onto this branch.

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

* style: reflow coupling docstrings to break only after punctuation

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>

* review: use timezone-aware datetimes in the coupling schema tests

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>

* Accept arbitrary commodities in a stored flex-model

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>

* docs: use inflexible-consumption in the internal-node guidance

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>

* review: drop a stray file, clarify the tutorial, quieten the internal-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>

* review: reflow this PR's docstrings and comments

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>

* docs: give inflexible devices in the flex-model their own section

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>

* docs: hint at larger sites rather than implying we walked through one

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>

---------

Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Signed-off-by: Ahmad Wahid <59763365+Ahmad-Wahid@users.noreply.github.com>
Signed-off-by: F.N. Claessen <felix@seita.nl>
Co-authored-by: Ahmad-Wahid <ahmedwahid16101@gmail.com>
Co-authored-by: Ahmad Wahid <59763365+Ahmad-Wahid@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@nhoening nhoening mentioned this pull request Aug 6, 2026
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