Balance internal commodity nodes (heat/steam networks) via first-class balance groups#2289
Balance internal commodity nodes (heat/steam networks) via first-class balance groups#2289Flix6x wants to merge 13 commits into
Conversation
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
|
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 |
Documentation build overview
50 files changed ·
|
…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
|
|
||
| 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. |
There was a problem hiding this comment.
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.
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>
|
Thanks — addressed:
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>
There was a problem hiding this comment.
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_groupstodevice_schedulerand implement per-timestep node balance constraints. - Teach
StorageSchedulerto 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 |
| # 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 | ||
| ) |
| :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). |
| 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. |
| **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>
|
Addressed the four review findings in c0eefc5:
Tests: |
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
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_schedulergains abalance_groupsargument: 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 ondev/simulate-factory).Missing consumption price). Electricity still requires a price.couplinggroup — so per-commodity balance groups are disjoint and no overlapping group tricks are needed.documentation/changelog.rsttut_converters), cross-referenced fromfeatures/scheduling.rst.Example flex-model
A converter is one flex-model entry per commodity port, tied by a shared
couplingname; a commodity with no price in the flex-context becomes an internal node. A CHP (gas → steam + electricity) feeding an internal steam node:Each kW of gas produces 0.5 kW steam + 0.3 kW electricity.
steamhas 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 → internalheat, steamer → internalsteam, CHP → steam + grid electricity) is in the new docs section and intest_factory_chp_dispatch_through_storage_scheduler.Converter shorthand — how it would map (follow-up, not in this PR)
A first-class
convertershorthand 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
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