From 79d45fdaa4fbe09e71aca8a2abac9ca2b0eff56f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 10 Jul 2026 18:52:53 +0200 Subject: [PATCH 1/3] 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 Claude-Session: https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B --- documentation/changelog.rst | 2 + documentation/features/scheduling.rst | 4 + .../models/planning/linear_optimization.py | 43 +++++ flexmeasures/data/models/planning/storage.py | 26 ++- .../models/planning/tests/test_commitments.py | 52 ++++-- .../models/planning/tests/test_storage.py | 168 ++++++++++++++++++ 6 files changed, 279 insertions(+), 16 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 7f020f3647..ca98590183 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -19,6 +19,8 @@ New features * Let storage scheduling infer missing ``power-capacity`` from directional device capacities before falling back to site capacity, and default the missing opposite capacity to zero when only a non-zero ``consumption-capacity`` or ``production-capacity`` is configured [see `PR #2222 `_] * Support multiple feeders to a shared storage [see `PR #2001 `_ ] * The flex-context can now define multiple commodities, each specifying their own prices and grid capacities [see `PR #1946 `_, `PR #2172 `_, `PR #2235 `_ and `PR #2271 `_] +* Support commodity-converting devices, such as a CHP, e-boiler or heat pump, by describing each of the device's commodity ports as a flex-model entry sharing one ``coupling`` group with fixed flow ratios (``coupling-coefficient``) [see `PR #2218 `_] +* Commodities without energy prices in the flex-context (e.g. a heat or steam network without a grid connection) are scheduled as internal nodes whose devices must balance each other at every time step [see `PR #2218 `_] * CLI support for adding/editing account attributes [see `PR #2242 `_] * Extended ``GET /api/v3_0/jobs/`` with a ``result`` field containing ``unresolved`` and ``resolved`` soft state-of-charge constraint analysis (``soc-minima``/``soc-maxima`` violations or satisfied constraints, keyed by asset ID) for scheduling jobs; both arrays are empty when no SoC constraints were defined [see `PR #2072 `_] diff --git a/documentation/features/scheduling.rst b/documentation/features/scheduling.rst index b3acbeefdd..fbe1857225 100644 --- a/documentation/features/scheduling.rst +++ b/documentation/features/scheduling.rst @@ -41,6 +41,10 @@ The ``flex-context`` is independent of the type of flexible device that is optim With the flexibility context, we aim to describe the system in which the flexible assets operate, such as its physical and contractual limitations. For multi-commodity scheduling problems, the flex-context can be defined separately per commodity (e.g. electricity and gas). See :ref:`tut_multi_commodity` for a hands-on example. +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. + Fields can have fixed values, but some fields can also point to sensors, so they will always represent the dynamics of the asset's environment (as long as that sensor has current data). The full list of flex-context fields follows below. For more details on the possible formats for field values, see :ref:`variable_quantities`. diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index cff76d9416..36c021cec6 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -43,6 +43,7 @@ def device_scheduler( # noqa C901 initial_stock: float | list[float] = 0, stock_groups: dict[int, list[int]] | None = None, coupling_groups: dict[str, list[tuple[int, float]]] | None = None, + balance_groups: dict[str, list[int]] | None = None, ems_constraint_groups: list[list[int]] | None = None, ) -> tuple[list[pd.Series], float, SolverResults, ConcreteModel]: """This generic device scheduler is able to handle an EMS with multiple devices, @@ -91,6 +92,15 @@ def device_scheduler( # noqa C901 coupling_groups={"chp": [(0, 1.0), (1, -0.5), (2, -0.3)]} + :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). + Potentially deprecated arguments: commitment_quantities: amounts of flow specified in commitments (both previously ordered and newly requested) - e.g. in MW or boxes/h @@ -172,6 +182,13 @@ def device_scheduler( # noqa C901 for d_idx, coeff in members: coupling_device_specs.append((g_idx, d_idx, coeff)) + # Collect the device lists of the balance groups (internal commodity nodes). + balance_group_specs: list[list[int]] = [] + if balance_groups: + balance_group_specs = [ + list(devices) for devices in balance_groups.values() if devices + ] + # Move commitments from old structure to new if commitments is None: commitments = [] @@ -857,6 +874,32 @@ def flow_coupling_rule(m, c, j): model.coupling_device_range, model.j, rule=flow_coupling_rule ) + if balance_group_specs: + model.balance_group_range = RangeSet(0, len(balance_group_specs) - 1) + + def node_balance_rule(m, b, j): + """Balance the stock-side flows of an internal commodity node at every time step. + + Everything produced into the node must be consumed from it within the same + time step. Flows are converted to the stock side using each device's + derivative efficiencies, and any predefined stock delta counts as well. + """ + return ( + 0, + sum( + m.device_power_down[d, j] + / m.device_derivative_down_efficiency[d, j] + + m.device_power_up[d, j] * m.device_derivative_up_efficiency[d, j] + + m.stock_delta[d, j] + for d in balance_group_specs[b] + ), + 0, + ) + + model.node_balance_constraints = Constraint( + model.balance_group_range, model.j, rule=node_balance_rule + ) + # Add objective def cost_function(m): costs = 0 diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 94e3ccdb60..89459468a6 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -220,6 +220,11 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 # of each device model. Devices sharing the same coupling name form a group. self.coupling_groups = self._build_coupling_groups(device_models) + # Balance groups for internal commodity nodes (commodities without energy + # prices, i.e. without a grid connection) are derived further below, once + # the devices of each commodity are enumerated. + self.balance_groups: dict[str, list[int]] = {} + # List the asset(s) and sensor(s) being scheduled if self.asset is not None: if not isinstance(self.flex_model, list): @@ -409,9 +414,23 @@ def device_list_series( production_price = consumption_price if consumption_price is None: - raise ValueError( - f"Missing consumption price for commodity '{commodity}'." - ) + if commodity == "electricity": + # Electricity is assumed to be grid-connected, so a missing + # price is treated as a configuration error rather than as + # an internal node. + raise ValueError( + f"Missing consumption price for commodity '{commodity}'." + ) + # A non-electricity commodity without energy prices is treated as an + # internal node (e.g. a heat or steam network without a grid + # connection): its devices must balance each other at every time + # step, and it needs no commitments or EMS-level capacity constraints. + current_app.logger.info( + f"Commodity '{commodity}' has no energy prices; treating it as an " + f"internal node whose devices (indices {devices}) balance each other." + ) + self.balance_groups[commodity] = list(devices) + continue # Energy prices for this commodity. up_deviation_prices = get_continuous_series_sensor_or_quantity( @@ -2650,6 +2669,7 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: initial_stock=initial_stock, stock_groups=self.stock_groups, coupling_groups=self.coupling_groups if self.coupling_groups else None, + balance_groups=getattr(self, "balance_groups", None) or None, ) if "infeasible" in (tc := scheduler_results.solver.termination_condition): raise InfeasibleProblemException(tc) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 5eb8620fc3..353c465f33 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -1833,9 +1833,15 @@ def _flow_df(**kwargs) -> pd.DataFrame: def _run_factory_scenario( gas_price: float, elec_price: float, + use_balance_groups: bool = False, ) -> tuple: """Run the simplified factory scenario and return the 7 device schedules. + With ``use_balance_groups=False``, the heat and steam nodes are balanced via + shared stock groups whose first ("reference") device carries min=max=0 stock + bounds. With ``use_balance_groups=True``, the same nodes are expressed directly + as ``balance_groups``, needing neither stock groups nor reference-device bounds. + Devices ~~~~~~~ d=0 e-heater electricity → heat coupling (ems_power ≥ 0, i.e. consumes electricity) @@ -1886,11 +1892,14 @@ def _df(**kwargs) -> pd.DataFrame: defaults.update(kwargs) return pd.DataFrame(defaults, index=index) + # With balance groups, no reference device needs min=max=0 stock bounds. + node_bounds = {} if use_balance_groups else {"min": 0.0, "max": 0.0} + device_constraints = [ # d=0 e-heater: heat-node reference device. The min=max=0 forces the heat # node to balance at every step (zero-capacity flow node), making # the per-step dispatch deterministic despite flat prices. - _df(min=0.0, max=0.0, **{"derivative max": HEATER_POWER_MAX}), + _df(**node_bounds, **{"derivative max": HEATER_POWER_MAX}), # d=1 gas boiler: up to 100 kW gas → 100 kW heat (efficiency 1 for clean maths in test) _df(**{"derivative max": BOILER_GAS_MAX, "commodity": "gas"}), # d=2 steamer: can only produce steam (negative ems_power). @@ -1908,8 +1917,7 @@ def _df(**kwargs) -> pd.DataFrame: # d=4 CHP heat output: positive ems_power adds heat to the steam node. # The min=max=0 forces the steam node to balance at every step. _df( - min=0.0, - max=0.0, + **node_bounds, **{ "derivative min": -CHP_GAS_MAX * ETA_HEAT, "derivative max": 0.0, @@ -1933,11 +1941,18 @@ def _df(**kwargs) -> pd.DataFrame: index=index, ) - # stock group: all heat-buffer devices share the same stock - # (key 0 is an arbitrary group id, not a device index) - heat_group_id = 0 - steam_group_id = 1 - stock_groups = {heat_group_id: [0, 1, 2], steam_group_id: [2, 4, 6]} + # Node membership: the steamer (d=2) converts heat to steam, so it belongs + # to both nodes (its single flow drains heat and feeds steam). + heat_node = [0, 1, 2] + steam_node = [2, 4, 6] + if use_balance_groups: + stock_groups = None + balance_groups = {"heat": heat_node, "steam": steam_node} + else: + # stock group: all heat-buffer devices share the same stock + # (keys 0 and 1 are arbitrary group ids, not device indices) + stock_groups = {0: heat_node, 1: steam_node} + balance_groups = None # CHP coupling: coefficients are signed efficiency fractions. # coeff_heat = -η_heat = -0.5 → P_heat = -0.5 * alpha = -0.5 * P_gas @@ -1979,6 +1994,7 @@ def _df(**kwargs) -> pd.DataFrame: commitments=commitments, stock_groups=stock_groups, coupling_groups=coupling_groups, + balance_groups=balance_groups, ) assert results.solver.termination_condition == "optimal", ( @@ -1988,10 +2004,14 @@ def _df(**kwargs) -> pd.DataFrame: return tuple(schedules) -def test_factory_chp_dispatch(): +@pytest.mark.parametrize("use_balance_groups", [False, True]) +def test_factory_chp_dispatch(use_balance_groups): """Factory: CHP + gas boiler + e-heater competing to meet a fixed steam demand. - The shared heat buffer (modelled via ``stock_groups``) is drained at a + The heat and steam nodes are balanced either via shared stock groups with + a min=max=0 reference device (``use_balance_groups=False``) or via explicit + ``balance_groups`` (``use_balance_groups=True``) — both must yield the same + dispatch. The steam node is drained at a constant rate of 15 kW by the steam demand device. Two price scenarios verify that the optimizer correctly chooses the cheapest heat source. @@ -2042,7 +2062,9 @@ def test_factory_chp_dispatch(): # Scenario A: gas cheaper — CHP at max, gas boiler fills the rest # # ------------------------------------------------------------------ # (e_heater, gas_boiler, steamer, chp_gas, chp_heat, chp_power, demand) = ( - _run_factory_scenario(gas_price=20.0, elec_price=50.0) + _run_factory_scenario( + gas_price=20.0, elec_price=50.0, use_balance_groups=use_balance_groups + ) ) expected_chp_gas = pd.Series(20.0, index=e_heater.index) @@ -2107,7 +2129,9 @@ def test_factory_chp_dispatch(): # Scenario B: electricity cheaper — e-heater meets all demand # # ------------------------------------------------------------------ # (e_heater, gas_boiler, steamer, chp_gas, chp_heat, chp_power, demand) = ( - _run_factory_scenario(gas_price=100.0, elec_price=10.0) + _run_factory_scenario( + gas_price=100.0, elec_price=10.0, use_balance_groups=use_balance_groups + ) ) expected_eheater_b = pd.Series(15.0, index=e_heater.index) @@ -2155,7 +2179,9 @@ def test_factory_chp_dispatch(): # Scenario C: gas slightly cheaper — gas boiler at max, e-heater fills the rest # # --------------------------------------------------------------------------------- # (e_heater, gas_boiler, steamer, chp_gas, chp_heat, chp_power, demand) = ( - _run_factory_scenario(gas_price=50.0, elec_price=55.0) + _run_factory_scenario( + gas_price=50.0, elec_price=55.0, use_balance_groups=use_balance_groups + ) ) expected_chp_gas = pd.Series(0.0, index=e_heater.index) diff --git a/flexmeasures/data/models/planning/tests/test_storage.py b/flexmeasures/data/models/planning/tests/test_storage.py index 30314a3e24..fe9d5dcf40 100644 --- a/flexmeasures/data/models/planning/tests/test_storage.py +++ b/flexmeasures/data/models/planning/tests/test_storage.py @@ -11,6 +11,7 @@ from flexmeasures.data.models.planning import Scheduler from flexmeasures.data.models.planning.storage import StorageScheduler from flexmeasures.data.models.planning.utils import initialize_index +from flexmeasures.data.models.data_sources import DataSource from flexmeasures.data.models.time_series import Sensor, TimedBelief from flexmeasures.data.models.planning.tests.utils import ( check_constraints, @@ -1409,3 +1410,170 @@ def test_storage_scheduler_chp_coupling(app, db): rtol=1e-4, err_msg="Power output must be -3 kW — determined by coupling (-0.3 * alpha)", ) + + +def test_factory_chp_dispatch_through_storage_scheduler(app, db): + """The full factory scenario (CHP + gas boiler + e-heater meeting a fixed steam + demand) scheduled end-to-end through ``StorageScheduler.compute()``. + + Unlike the engine-level ``test_factory_chp_dispatch`` (which passes balance groups + to ``device_scheduler`` directly), this test only supplies a flex-model and a + flex-context. Each converter is described as one device per commodity port, tied + together by a coupling group. The heat and steam commodities have no energy prices + in the flex-context, so the scheduler derives internal-node balance groups for them. + + Topology (flex-model device indices):: + + electricity (grid) --0--> [e-heater] --1--> heat + gas (grid) --2--> [boiler] --3--> heat + heat --4--> [steamer] --5--> steam + gas (grid) --6--> [CHP] --7--> steam + --8--> electricity (grid) + steam --9--> fixed 15 kW demand (inflexible sensor) + + Prices: gas 20 EUR/MWh, electricity 50 EUR/MWh. Marginal cost per kW of steam: + CHP (20·20 − 50·6) / 10 = 10, boiler-via-steamer 20, e-heater-via-steamer 50. + So the CHP runs at maximum (20 kW gas → 10 kW steam + 6 kW power) and the boiler + covers the remaining 5 kW of steam via the steamer; the e-heater stays off. + """ + factory_type = get_or_create_model(GenericAssetType, name="factory") + factory = GenericAsset( + name="Factory (end-to-end CHP dispatch)", generic_asset_type=factory_type + ) + db.session.add(factory) + db.session.flush() + + start = pd.Timestamp("2026-01-01T00:00:00+01:00") + end = pd.Timestamp("2026-01-01T04:00:00+01:00") + resolution = timedelta(hours=1) + + def make_sensor(name: str) -> Sensor: + sensor = Sensor( + name=name, generic_asset=factory, unit="MW", event_resolution=resolution + ) + db.session.add(sensor) + return sensor + + eheater_elec_in = make_sensor("e-heater electricity input") + eheater_heat_out = make_sensor("e-heater heat output") + boiler_gas_in = make_sensor("boiler gas input") + boiler_heat_out = make_sensor("boiler heat output") + steamer_heat_in = make_sensor("steamer heat input") + steamer_steam_out = make_sensor("steamer steam output") + chp_gas_in = make_sensor("CHP gas input") + chp_steam_out = make_sensor("CHP steam output") + chp_power_out = make_sensor("CHP power output") + steam_demand = make_sensor("steam demand") + db.session.flush() + + # A constant 15 kW steam demand, recorded as beliefs. + # By default, power sensors store consumption as negative values + # (get_power_values flips the sign to the scheduler's consumption-positive convention). + index = initialize_index(start, end, resolution) + source = get_or_create_model(DataSource, name="test source", type="forecaster") + db.session.add_all( + TimedBelief( + sensor=steam_demand, + source=source, + event_start=dt, + belief_time=start, + event_value=-15e-3, # 15 kW in MW + ) + for dt in index + ) + db.session.commit() + + def input_port(sensor: Sensor, commodity: str, coupling: str, max_power: str): + return { + "sensor": sensor.id, + "commodity": commodity, + "coupling": coupling, + "coupling-coefficient": 1.0, + "power-capacity": max_power, + "production-capacity": "0 kW", + } + + def output_port( + sensor: Sensor, commodity: str, coupling: str, coefficient: float = 1.0 + ): + return { + "sensor": sensor.id, + "commodity": commodity, + "coupling": coupling, + "coupling-coefficient": coefficient, + "power-capacity": "1 MW", + "consumption-capacity": "0 kW", + } + + flex_model = [ + input_port(eheater_elec_in, "electricity", "eheater", "100 kW"), + output_port(eheater_heat_out, "heat", "eheater"), + input_port(boiler_gas_in, "gas", "boiler", "10 kW"), + output_port(boiler_heat_out, "heat", "boiler"), + input_port(steamer_heat_in, "heat", "steamer", "1 MW"), + output_port(steamer_steam_out, "steam", "steamer"), + input_port(chp_gas_in, "gas", "chp", "20 kW"), + output_port(chp_steam_out, "steam", "chp", coefficient=0.5), + output_port(chp_power_out, "electricity", "chp", coefficient=0.3), + ] + + 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", + }, + { + # No prices: steam is an internal node. Its fixed demand is inflexible. + "commodity": "steam", + "inflexible-device-sensors": [steam_demand.id], + }, + # The heat commodity has no context at all: also an internal node. + ] + + scheduler = StorageScheduler( + asset_or_sensor=factory, + start=start, + end=end, + resolution=resolution, + belief_time=start, + flex_model=flex_model, + flex_context=flex_context, + return_multiple=True, + ) + + results = scheduler.compute(skip_validation=True) + + # The scheduler derived one balance group per priceless commodity: + # heat: e-heater out (1), boiler out (3), steamer in (4) + # steam: steamer out (5), CHP out (7), inflexible demand (9) + assert scheduler.balance_groups == {"heat": [1, 3, 4], "steam": [5, 7, 9]} + + schedules = { + r["sensor"]: r["data"] for r in results if r.get("name") == "storage_schedule" + } + + expected_mw = { + eheater_elec_in: 0.0, + eheater_heat_out: 0.0, + boiler_gas_in: 0.005, + boiler_heat_out: -0.005, + steamer_heat_in: 0.005, + steamer_steam_out: -0.005, + chp_gas_in: 0.020, + chp_steam_out: -0.010, + chp_power_out: -0.006, + } + for sensor, expected_value in expected_mw.items(): + np.testing.assert_allclose( + schedules[sensor], + expected_value, + rtol=1e-4, + atol=1e-9, + err_msg=f"Unexpected schedule for {sensor.name}", + ) From 71ffaa8d82b490fd50c87a913c87067abccdf2b8 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 10 Jul 2026 18:53:48 +0200 Subject: [PATCH 2/3] docs: point balance-groups changelog entry at PR #2279 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B --- documentation/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index ca98590183..58fa0a071b 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -20,7 +20,7 @@ New features * Support multiple feeders to a shared storage [see `PR #2001 `_ ] * The flex-context can now define multiple commodities, each specifying their own prices and grid capacities [see `PR #1946 `_, `PR #2172 `_, `PR #2235 `_ and `PR #2271 `_] * Support commodity-converting devices, such as a CHP, e-boiler or heat pump, by describing each of the device's commodity ports as a flex-model entry sharing one ``coupling`` group with fixed flow ratios (``coupling-coefficient``) [see `PR #2218 `_] -* Commodities without energy prices in the flex-context (e.g. a heat or steam network without a grid connection) are scheduled as internal nodes whose devices must balance each other at every time step [see `PR #2218 `_] +* Commodities without energy prices in the flex-context (e.g. a heat or steam network without a grid connection) are scheduled as internal nodes whose devices must balance each other at every time step [see `PR #2279 `_] * CLI support for adding/editing account attributes [see `PR #2242 `_] * Extended ``GET /api/v3_0/jobs/`` with a ``result`` field containing ``unresolved`` and ``resolved`` soft state-of-charge constraint analysis (``soc-minima``/``soc-maxima`` violations or satisfied constraints, keyed by asset ID) for scheduling jobs; both arrays are empty when no SoC constraints were defined [see `PR #2072 `_] From f3f2b3badf9b8f109b0fe7ce50102b97c102873a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 10 Jul 2026 23:43:25 +0200 Subject: [PATCH 3/3] 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 the Sappi factory scenario, where the heat buffer's soc-usage drain was distorting the steam balance. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B --- .../data/models/planning/linear_optimization.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 36c021cec6..6e734eda48 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -878,21 +878,17 @@ def flow_coupling_rule(m, c, j): model.balance_group_range = RangeSet(0, len(balance_group_specs) - 1) def node_balance_rule(m, b, j): - """Balance the stock-side flows of an internal commodity node at every time step. + """Balance the power flows of an internal commodity node at every time step. Everything produced into the node must be consumed from it within the same - time step. Flows are converted to the stock side using each device's - derivative efficiencies, and any predefined stock delta counts as well. + time step. The balance sums the devices' commodity-side flows (ems_power); + derivative efficiencies and stock deltas describe each device's own + stock-side conversion (e.g. of a shared buffer) and do not enter the + commodity balance. """ return ( 0, - sum( - m.device_power_down[d, j] - / m.device_derivative_down_efficiency[d, j] - + m.device_power_up[d, j] * m.device_derivative_up_efficiency[d, j] - + m.stock_delta[d, j] - for d in balance_group_specs[b] - ), + sum(m.ems_power[d, j] for d in balance_group_specs[b]), 0, )