Skip to content

feat: unit-committed affine coupling groups (on/off + min-load + no-load base) - #2336

Open
Flix6x wants to merge 256 commits into
mainfrom
feat/unit-committed-coupling
Open

feat: unit-committed affine coupling groups (on/off + min-load + no-load base)#2336
Flix6x wants to merge 256 commits into
mainfrom
feat/unit-committed-coupling

Conversation

@Flix6x

@Flix6x Flix6x commented Jul 22, 2026

Copy link
Copy Markdown
Member

Extends the coupling primitive (#2218) with unit commitment: a coupling group can now have an on/off binary, a minimum level when on, and a per-port no-load base gated by that binary.

What it does

When a coupling group is unit-committed, the solver introduces a per-timestep binary u_g[j] ∈ {0,1} and a marginal level alpha_g[j] ≥ 0, with:

  • min_g * u_g[j] <= alpha_g[j] <= max_g * u_g[j]alpha is 0 when off, and at least the minimum level when on;
  • for every port d in the group: P[d,j] == coeff_d * alpha_g[j] + base_d * u_g[j],

where coeff_d is the existing signed coupling coefficient and base_d is the new signed per-port no-load base. This makes the affine cogeneration/CHP model exact, including no-load fuel: in an unprofitable step the whole unit idles (all ports exactly 0, no no-load fuel burned), and when running it stays at or above the minimum level.

Purely proportional (non-UC) coupling is unchanged: the problem stays an LP unless some group is unit-committed (a group becomes unit-committed only when coupling-min is set or a non-zero coupling-base is given). Minimum up/down times are out of scope.

Schema

  • coupling-base (per port): a signed power quantity whose direction follows the port's flow direction (like coupling-coefficient); default 0.
  • coupling-min (on the group's reference port, the port with |coefficient| = 1): a power quantity that sets the group minimum and enables unit commitment. max_g comes from the reference port's power-capacity.

Both fields are documented in metadata.py, added to the storage flex-model schemas, and reflected in openapi-specs.json. They are carried through DeviceInventory (coupling_uc, coupling_bases) and storage.py to device_scheduler.

Tests

  • A factory-style, hand-computed 1-step unit-committed cogeneration dispatch (driving electrical output, affine gas/heat with no-load bases): verifies full idle in an unprofitable step, running at max (≥ Pmin) in a profitable step, and the exact objective.
  • Plumbing tests that a unit-committed flex-model resolves to the expected signed bases and (min, max) bounds in MW, and that a group without min/base stays purely proportional.
  • The existing proportional coupling tests still pass unchanged.

Stacked on #2218.

🤖 Generated with Claude Code

Flix6x and others added 30 commits December 8, 2025 15: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: 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>
Flix6x and others added 28 commits June 12, 2026 11:28
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…oving old flex-context fields into the electricity commodity context

Signed-off-by: F.N. Claessen <claessen@seita.nl>
…d field)

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

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

Signed-off-by: F.N. Claessen <claessen@seita.nl>
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…DEL, which are named commodity in scheduling.rst

Signed-off-by: F.N. Claessen <claessen@seita.nl>
…nstraint analysis #2072, CI/agents #2275)

Conflict resolution policy:
- Schema layer: main's design wins wholesale (commodities list nesting,
  commodity default 'electricity', uniqueness + shared-currency validators).
- Engine: main's #1946 multi-commodity device_scheduler plus this branch's
  coupling_groups primitive (coupling_alpha variables and per-device
  flow-coupling constraints), keeping the branch's group_to_devices mapping
  which supports overlapping stock-group membership.
- storage.py: main's _prepare/_build_soc_schedule structure wins, with the
  branch's coupling-group construction re-added; main's strict price-presence
  validation is restored (the branch had it commented out).
- Tests: kept both sides' distinct tests (branch: CHP coupling + factory
  dispatch; main: gas-only flex-model, per-commodity inflexible sensors,
  directional-capacity fallback).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B
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
Adapt the CHP converter-port concept to the device inventory:
FlexDevice entries carry their coupling name and signed coupling coefficient,
and DeviceInventory.coupling_groups replaces Scheduler._build_coupling_groups
as the single source of truth for the solver's coupling groups,
keyed by canonical device indices.

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

# Conflicts:
#	flexmeasures/data/models/planning/linear_optimization.py
#	flexmeasures/data/models/planning/tests/test_storage.py
…ters

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
Resolve conflicts from PR #2276 (intermediate power constraints via
flex-model 'group' field) against the CHP converter-coupling work.

All conflicts were additive (both sides added distinct helpers/methods/
docstring notes in the same regions); kept both sides:
- devices.py: DeviceRole docstring (converter ports + GROUP), the
  _is_zero_capacity/_resolve_coupling_coefficient helpers alongside the
  group-key helpers, and coupling_groups alongside group_to_devices.
- storage.py: coupling_groups wiring alongside group entry classification
  and validation.

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>
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>
…nk 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>
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>
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>
…oad base)

Extend the coupling primitive (#2218) so a converter (e.g. a cogeneration
unit) can be modelled with an on/off binary, a minimum level when on, and a
per-port no-load base gated by that binary.

When a coupling group is unit-committed, the solver adds a per-timestep binary
u_g[j] and a marginal level alpha_g[j] >= 0 with
  min_g * u_g <= alpha_g <= max_g * u_g
and, for every port d,
  P[d,j] == coeff_d * alpha_g[j] + base_d * u_g[j].
This turns the LP into a MILP only when a group is unit-committed; purely
proportional coupling is unchanged.

Schema: add a per-port `coupling-base` (signed power quantity, sign follows the
port's flow direction; default 0) and a `coupling-min` power quantity on the
group's reference port (|coeff| == 1). Presence of `coupling-min` (or any
non-zero `coupling-base`) marks the group unit-committed; max_g comes from the
reference port's `power-capacity`. Both are carried through DeviceInventory
(coupling_uc, coupling_bases) and storage.py to device_scheduler.

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>
Base automatically changed from feat/chp to main August 3, 2026 23:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants