Skip to content

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

Open
Flix6x wants to merge 13 commits into
feat/chpfrom
feat/internal-commodity-balance
Open

Balance internal commodity nodes (heat/steam networks) via first-class balance groups#2289
Flix6x wants to merge 13 commits into
feat/chpfrom
feat/internal-commodity-balance

Conversation

@Flix6x

@Flix6x Flix6x commented Jul 11, 2026

Copy link
Copy Markdown
Member

Description

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

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

Example flex-model

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

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

Each kW of gas produces 0.5 kW steam + 0.3 kW electricity. steam has no price → internal node: its devices (the CHP/steamer producing, the fixed demand consuming) balance each other every step. The full factory (e-heater + boiler → internal heat, steamer → internal steam, CHP → steam + grid electricity) is in the new docs section and in test_factory_chp_dispatch_through_storage_scheduler.

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

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

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

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

How to test

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

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

Related Items

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

🤖 Generated with Claude Code

https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B

Flix6x and others added 3 commits July 10, 2026 18:52
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
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B
…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
@Flix6x

Flix6x commented Jul 11, 2026

Copy link
Copy Markdown
Member Author

Supersedes #2279 (same content; one commit message reworded — the original couldn't be amended in place since force-pushes are blocked by repository rules). The #2272 interaction note from #2279 applies here unchanged.

🤖 Generated with Claude Code

Flix6x and others added 4 commits July 16, 2026 09:37
…feat/internal-commodity-balance

Balance groups now derive from the inventory's canonical commodity enumeration:
the internal-node detection runs in the loop over inventory.commodity_to_devices,
so each balance group lists the commodity's flexible and inflexible devices by
their canonical solver 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>
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>
…nto-icb

# Conflicts:
#	documentation/changelog.rst
#	flexmeasures/data/models/planning/tests/test_storage.py
…efaults

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
Comment thread documentation/features/scheduling.rst Outdated

A commodity that defines no energy prices in the flex-context (e.g. a heat or steam network without a grid connection) is treated as an internal node:
its devices must balance each other at every time step, so everything produced into the node is consumed from it within the same time step.
Devices that convert between commodities (such as a CHP unit, gas boiler or electric heater) are described in the flex-model, one entry per commodity port, tied together by a ``coupling`` group.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This should cross-reference a new docs section (tutorial?) with an example flex-model. Let's have the example flex-model make an appearance in the PR description, too.

Also let the converter shorthand in the PR description get an example of how it would map.

Flix6x and others added 2 commits July 18, 2026 11:25
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>
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>
@Flix6x

Flix6x commented Jul 18, 2026

Copy link
Copy Markdown
Member Author

Thanks — addressed:

  • Docs example: the converter/coupling paragraph in features/scheduling.rst now cross-references a new "Converters between commodities" section in the multi-commodity tutorial (tut_converters), with a worked CHP example flex-model (one entry per commodity port tied by a coupling group) and how an unpriced commodity becomes an internal balance node.
  • PR description: now shows the example flex-model + flex-context, and the proposed converter shorthand with how it maps to the per-port + coupling form.
  • CI: fixed the failing pre-commit Check (flake8 F811 duplicate GenericAsset import + black formatting in test_storage.py).

The DCO check is red on the pre-existing commits; since force-pushes are blocked by the repo rules I have not rewritten history — it likely needs a maintainer override (the newly added commits are signed off).

feat/chp now carries origin/main's PR #2276 (intermediate power
constraints via the flex-model 'group' field).

Single conflict in storage.py, additive: kept both the balance_groups
initialization (internal-commodity-balance) and the group-entry
classification/validation block (from the feat/chp main-merge).

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>

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

This PR extends multi-commodity scheduling to support internal commodity nodes (e.g. heat/steam networks without a grid connection) by introducing first-class balance groups that enforce per-timestep flow balance, and wires this through StorageScheduler.compute() so factory-like setups can be scheduled end-to-end from flex-config.

Changes:

  • Add balance_groups to device_scheduler and implement per-timestep node balance constraints.
  • Teach StorageScheduler to derive balance groups for commodities treated as internal nodes (no energy prices), and pass them through to the optimizer.
  • Add end-to-end factory/CHP scheduling tests plus documentation and changelog updates explaining converter (coupling) modeling and internal nodes.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
flexmeasures/data/schemas/scheduling/init.py Adds a prices_are_defaulted marker intended to distinguish defaulted vs user-specified prices for internal-node detection
flexmeasures/data/models/planning/storage.py Derives internal-node balance groups for commodities without user-given prices and passes them to device_scheduler
flexmeasures/data/models/planning/linear_optimization.py Adds balance_groups parameter and enforces per-timestep node balance constraints
flexmeasures/data/models/planning/tests/test_commitments.py Parametrizes factory scenario to validate both legacy stock-group balancing and new balance-group balancing yield identical dispatch
flexmeasures/data/models/planning/tests/test_storage.py Adds end-to-end factory dispatch test through StorageScheduler.compute() driven by flex-model/flex-context
documentation/tut/multi-commodity.rst Documents converter modeling via per-port entries coupled by coupling/coupling-coefficient and introduces internal nodes
documentation/features/scheduling.rst Cross-references the converter/internal-node concepts from the scheduling feature docs
documentation/changelog.rst Adds changelog entries for coupled converters and internal-node scheduling behavior

Comment on lines +532 to +537
# Record durably whether any price field was user-given, so the scheduler
# can distinguish a smart-defaulted zero price from a deliberate one
# (e.g. to treat a priceless commodity as an internal node).
data["prices_are_defaulted"] = not (
has_consumption_price or has_production_price
)
Comment on lines +96 to +103
:param balance_groups: Flow-balance constraints for internal commodity nodes (e.g. a heat or steam network
without a grid connection). Each entry maps a node name to a list of device indices
whose stock-side flows must balance at every time step:
``sum_d(P_up[d, j] * eff_up[d, j] + P_down[d, j] / eff_down[d, j] + stock_delta[d, j]) == 0``.
In other words, everything produced into the node is consumed from it within the
same time step; the node itself stores nothing. To add storage to a node, include
a storage device in the group (its flow absorbs the imbalance and its stock is
bounded by its own device constraints).
Comment thread documentation/features/scheduling.rst Outdated
Comment on lines +44 to +45
A commodity that defines no energy prices in the flex-context (e.g. a heat or steam network without a grid connection) is treated as an internal node:
its devices must balance each other at every time step, so everything produced into the node is consumed from it within the same time step.
Comment thread documentation/tut/multi-commodity.rst Outdated
Comment on lines +283 to +285
**Internal nodes.** A commodity that lists no energy price in the ``flex-context`` (e.g. a steam or heat network with no grid connection) is treated as an **internal node**: its devices must balance each other at every time step, so everything converters produce into the node is consumed from it within the same step.
Give such a commodity only an ``inflexible-device-sensors`` entry (its fixed demand), or omit it from the ``flex-context`` entirely.

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

Flix6x commented Jul 19, 2026

Copy link
Copy Markdown
Member Author

Addressed the four review findings in c0eefc5:

  1. Capacity-only commodity misclassified as internal node (schemas/scheduling/__init__.py, planning/storage.py). A commodity declaring a grid connection via capacity fields only (site-power/consumption/production-capacity) but no explicit price was flagged as having defaulted prices, so StorageScheduler treated it as an internal node — skipping EMS constraints and forcing a per-step balance. The internal-node determination now also considers the capacity fields: a commodity is an internal node only when the user gave neither prices nor any capacity/grid-connection signal (not any_given). The durable flag was renamed prices_are_defaultedis_internal_node to match its true meaning. Added test_commodity_flex_context_internal_node_flag asserting a commodity with a capacity field but no price is NOT an internal node.

  2. Docstring mismatch (planning/linear_optimization.py). The balance_groups doc claimed a stock-side P_up/eff/stock_delta balance; the implementation enforces the commodity-side sum_d(ems_power[d, j]) == 0. Docstring corrected.

  3. Docs (features/scheduling.rst). Clarified that only non-electricity commodities without prices become internal nodes; electricity is always assumed grid-connected, so missing electricity prices raise an error.

  4. Docs (tut/multi-commodity.rst). Added the same electricity caveat.

Tests: test_commitments.py + test_scheduling.py — 148 passed (incl. the new internal-node test). No OpenAPI spec change (the flag is a post-load data key, not a declared field).

Flix6x and others added 2 commits July 19, 2026 17:45
Pick up the coupling review fixes (coupling in DBStorageFlexModelSchema +
UI_FLEX_MODEL_SCHEMA, blank-name rejection). This keeps the UI/DB flex-model
schema parity test green now that the base carries coupling fields.

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>
# Conflicts:
#	documentation/changelog.rst
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