From 683fd578de2f589fbd34a5883d8f9e57e1361114 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 29 May 2026 13:34:13 +0200 Subject: [PATCH 01/26] feat: coupling groups for CHP Signed-off-by: F.N. Claessen --- .../models/planning/linear_optimization.py | 42 ++++++ .../models/planning/tests/test_commitments.py | 142 ++++++++++++++++++ 2 files changed, 184 insertions(+) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 1a3359ae5c..922935d9e1 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -42,6 +42,7 @@ def device_scheduler( # noqa C901 commitments: list[pd.DataFrame] | list[Commitment] | None = None, initial_stock: float | list[float] = 0, stock_groups: dict[int, list[int]] | None = None, + coupling_groups: dict[str, list[tuple[int, float]]] | None = None, ) -> tuple[list[pd.Series], float, SolverResults, ConcreteModel]: """This generic device scheduler is able to handle an EMS with multiple devices, with various types of constraints on the EMS level and on the device level, @@ -72,6 +73,14 @@ def device_scheduler( # noqa C901 device: 0 (corresponds to device d; if not set, commitment is on an EMS level) :param initial_stock: initial stock for each device. Use a list with the same number of devices as device_constraints, or use a single value to set the initial stock to be the same for all devices. + :param coupling_groups: Hard flow-coupling constraints between devices. Each entry maps a group name to a list of + ``(device_index, coefficient)`` tuples. For every pair of devices in a group the constraint + ``coeff_ref * P[d_ref, j] == coeff_i * P[d_i, j]`` is enforced for every time step ``j``, + using the first member of the list as the reference device. + Example — a CHP with gas input (d=0, coeff 1.0), heat output (d=1, coeff −0.5) and + power output (d=2, coeff −0.3):: + + coupling_groups={"chp": [(0, 1.0), (1, -0.5), (2, -0.3)]} Potentially deprecated arguments: commitment_quantities: amounts of flow specified in commitments (both previously ordered and newly requested) @@ -117,6 +126,18 @@ def device_scheduler( # noqa C901 for d in range(len(device_constraints)): device_to_group[d] = d + # Build flat list of pairwise flow-coupling constraints from coupling_groups. + # Each entry is (d_ref, coeff_ref, d_i, coeff_i) and enforces: + # coeff_ref * ems_power[d_ref, j] == coeff_i * ems_power[d_i, j] + coupling_pairs: list[tuple[int, float, int, float]] = [] + if coupling_groups: + for _group_name, members in coupling_groups.items(): + if len(members) < 2: + continue + d_ref, coeff_ref = members[0] + for d_i, coeff_i in members[1:]: + coupling_pairs.append((d_ref, coeff_ref, d_i, coeff_i)) + # Move commitments from old structure to new if commitments is None: commitments = [] @@ -738,6 +759,27 @@ def device_derivative_equalities(m, d, j): model.d, model.j, rule=device_derivative_equalities ) + if coupling_pairs: + + def flow_coupling_rule(m, p, j): + """Enforce a fixed ratio between the flows of two coupled devices. + + For coupling pair ``p`` at time ``j`` the constraint is: + coeff_ref * ems_power[d_ref, j] == coeff_i * ems_power[d_i, j] + which is stored as the Pyomo equality (0, lhs - rhs, 0). + """ + d_ref, coeff_ref, d_i, coeff_i = coupling_pairs[p] + return ( + 0, + coeff_ref * m.ems_power[d_ref, j] - coeff_i * m.ems_power[d_i, j], + 0, + ) + + model.coupling_pair = RangeSet(0, len(coupling_pairs) - 1) + model.flow_coupling_constraints = Constraint( + model.coupling_pair, model.j, rule=flow_coupling_rule + ) + # Add objective def cost_function(m): costs = 0 diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 0c0ff32ec2..0c1d020b49 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -1543,3 +1543,145 @@ def test_simulation_with_dynamic_consumption_capacity(app, db): assert heater_schedule.loc["2026-04-07T08:00:00+00:00"] == pytest.approx( 80.0 ), "Electric heater should have one expected partial 80 kW dispatch step before the first cheap-electricity window." + + +def test_chp_coupling(): + """Test that coupling_groups enforces fixed flow ratios between CHP devices. + + Models a Combined Heat and Power unit with three flow devices: + + - d=0 gas input: can only consume gas (derivative_min=0) + - d=1 heat output: can only produce heat (derivative_max=0) + - d=2 power output: can only produce electricity (derivative_max=0) + + The coupling group ``"chp"`` is specified with coefficients + ``[(0, 1.0), (1, -0.5), (2, -0.3)]``. This generates two hard equality + constraints for every time step ``j``: + + 1.0 * P_gas[j] == -0.5 * P_heat[j] → P_gas == -0.5 * P_heat + 1.0 * P_gas[j] == -0.3 * P_power[j] → P_gas == -0.3 * P_power + + Heat production is forced to exactly 10 kW via ``derivative equals = -10`` + on device 1. Substituting into the coupling constraints gives the expected + solution: + + P_gas = 5 kW (gas consumed) + P_heat = -10 kW (heat produced, forced) + P_power = -50/3 ≈ -16.67 kW (electricity produced) + + Note: the coefficients above do not represent a physically realisable CHP + (total output exceeds input). They are chosen to exercise the constraint + arithmetic with non-trivial numbers that are easy to verify by hand. + """ + start = pd.Timestamp("2026-01-01T00:00+01:00") + end = pd.Timestamp("2026-01-01T04:00+01:00") + resolution = pd.Timedelta("1h") + index = initialize_index(start=start, end=end, resolution=resolution) + + # d=0: gas input — can only consume (derivative_min=0), capacity 100 kW. + # NaN stock bounds mean no cumulative-stock constraint (pure flow device). + gas_constraints = pd.DataFrame( + { + "min": np.nan, + "max": np.nan, + "equals": np.nan, + "derivative min": 0.0, + "derivative max": 100.0, + "derivative equals": np.nan, + "derivative down efficiency": 1.0, + "derivative up efficiency": 1.0, + }, + index=index, + ) + + # d=1: heat output — can only produce (derivative_max=0). + # Forced to exactly -10 kW via derivative equals. + heat_constraints = pd.DataFrame( + { + "min": np.nan, + "max": np.nan, + "equals": np.nan, + "derivative min": -100.0, + "derivative max": 0.0, + "derivative equals": -10.0, + "derivative down efficiency": 1.0, + "derivative up efficiency": 1.0, + }, + index=index, + ) + + # d=2: power output — can only produce (derivative_max=0), capacity 100 kW. + # Flow is free; the coupling constraint will determine its value. + power_constraints = pd.DataFrame( + { + "min": np.nan, + "max": np.nan, + "equals": np.nan, + "derivative min": -100.0, + "derivative max": 0.0, + "derivative equals": np.nan, + "derivative down efficiency": 1.0, + "derivative up efficiency": 1.0, + }, + index=index, + ) + + ems_constraints = pd.DataFrame( + {"derivative min": -200.0, "derivative max": 200.0}, + index=index, + ) + + # Coupling group: one reference device (gas, coeff 1.0) and two coupled + # devices (heat with coeff -0.5, power with coeff -0.3). + coupling_groups = {"chp": [(0, 1.0), (1, -0.5), (2, -0.3)]} + + # Gas-price commitment gives the objective a finite value and models the + # cost of consuming gas. With quantity=0 and both prices set the + # commitment acts as a two-sided soft equality: any upward deviation + # (gas consumption) incurs a cost of 1 EUR/kW. + gas_price_commitment = FlowCommitment( + name="gas cost", + index=index, + quantity=pd.Series(0.0, index=index), + upwards_deviation_price=pd.Series(1.0, index=index), + downwards_deviation_price=pd.Series(0.0, index=index), + device=pd.Series(0, index=index), + ) + + schedules, planned_costs, results, model = device_scheduler( + device_constraints=[gas_constraints, heat_constraints, power_constraints], + ems_constraints=ems_constraints, + commitments=[gas_price_commitment], + coupling_groups=coupling_groups, + ) + + assert ( + results.solver.termination_condition == "optimal" + ), "Solver did not find an optimal solution." + + # Heat is fixed to -10 kW by derivative_equals. + pd.testing.assert_series_equal( + schedules[1], + pd.Series(-10.0, index=index), + check_names=False, + rtol=1e-4, + obj="heat output forced to -10 kW by derivative_equals", + ) + + # Coupling: 1.0 * P_gas == -0.5 * P_heat → P_gas = -0.5 * (-10) = 5 kW + pd.testing.assert_series_equal( + schedules[0], + pd.Series(5.0, index=index), + check_names=False, + rtol=1e-4, + obj="gas consumption determined by coupling (5 kW from 10 kW heat at coeff -0.5)", + ) + + # Coupling: 1.0 * P_gas == -0.3 * P_power → P_power = -5 / 0.3 = -50/3 kW + pd.testing.assert_series_equal( + schedules[2], + pd.Series(-50.0 / 3.0, index=index), + check_names=False, + rtol=1e-4, + obj="power output determined by coupling (-50/3 kW from 5 kW gas at coeff -0.3)", + ) From 391a5d7e80d00ac6fec381fe015bdd603b510d55 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 29 May 2026 14:29:18 +0200 Subject: [PATCH 02/26] feat: test factory model Signed-off-by: F.N. Claessen --- .../models/planning/tests/test_commitments.py | 271 +++++++++++++++++- 1 file changed, 267 insertions(+), 4 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 0c1d020b49..fed1444f00 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -1555,14 +1555,14 @@ def test_chp_coupling(): - d=2 power output: can only produce electricity (derivative_max=0) The coupling group ``"chp"`` is specified with coefficients - ``[(0, 1.0), (1, -0.5), (2, -0.3)]``. This generates two hard equality + ``[(0, 1.0), (1, -0.5), (2, -0.3)]``. This generates two hard equality constraints for every time step ``j``: 1.0 * P_gas[j] == -0.5 * P_heat[j] → P_gas == -0.5 * P_heat 1.0 * P_gas[j] == -0.3 * P_power[j] → P_gas == -0.3 * P_power Heat production is forced to exactly 10 kW via ``derivative equals = -10`` - on device 1. Substituting into the coupling constraints gives the expected + on device 1. Substituting into the coupling constraints gives the expected solution: P_gas = 5 kW (gas consumed) @@ -1570,7 +1570,7 @@ def test_chp_coupling(): P_power = -50/3 ≈ -16.67 kW (electricity produced) Note: the coefficients above do not represent a physically realisable CHP - (total output exceeds input). They are chosen to exercise the constraint + (total output exceeds input). They are chosen to exercise the constraint arithmetic with non-trivial numbers that are easy to verify by hand. """ start = pd.Timestamp("2026-01-01T00:00+01:00") @@ -1636,7 +1636,7 @@ def test_chp_coupling(): coupling_groups = {"chp": [(0, 1.0), (1, -0.5), (2, -0.3)]} # Gas-price commitment gives the objective a finite value and models the - # cost of consuming gas. With quantity=0 and both prices set the + # cost of consuming gas. With quantity=0 and both prices set the # commitment acts as a two-sided soft equality: any upward deviation # (gas consumption) incurs a cost of 1 EUR/kW. gas_price_commitment = FlowCommitment( @@ -1685,3 +1685,266 @@ def test_chp_coupling(): rtol=1e-4, obj="power output determined by coupling (-50/3 kW from 5 kW gas at coeff -0.3)", ) + + +def _run_factory_scenario( + gas_price: float, + elec_price: float, +) -> tuple: + """Run the simplified factory scenario and return the 6 device schedules. + + Layout + ------ + The model collapses the heat buffer (T) and steam node (P) into a single + shared heat buffer whose SoC is tracked by ``stock_groups``. The steam + demand is an exogenous drain modelled as ``stock_delta = -steam_demand`` on + the demand device (d=5). + + Devices + ~~~~~~~ + d=0 e-heater electricity → heat buffer (ems_power ≥ 0) + d=1 gas boiler gas → heat buffer (ems_power ≥ 0) + d=2 CHP gas input consumes gas (ems_power ≥ 0, coupling ref) + d=3 CHP heat out heat → heat buffer (ems_power ≥ 0, coupling member) + d=4 CHP power out produces electricity (ems_power ≤ 0, coupling member) + d=5 steam demand fixed drain, no flow (ems_power = 0, stock_delta = -15) + + CHP coupling coefficients + ~~~~~~~~~~~~~~~~~~~~~~~~~ + The coupling constraint is built from the general pairwise equality:: + + coeff_ref * P[d_ref] == coeff_i * P[d_i] + + Choosing d_ref = 2 (gas input, coeff 1.0) and thermal efficiency η_heat = 0.5, + power efficiency η_power = 0.3:: + + P_heat = η_heat * P_gas = 0.5 * P_gas + P_power = -η_power * P_gas = -0.3 * P_gas + + Solving for the coupling coefficients:: + + 1.0 * P_gas = coeff_heat * P_heat → coeff_heat = 1/η_heat = 2.0 + 1.0 * P_gas = coeff_power * P_power → coeff_power = -1/η_power = -10/3 + + Note: both d=2 and d=3 have *positive* ems_power (they both "consume" from + their respective commodity nodes, which causes the stock accumulation formula + to add a positive contribution to the heat buffer for d=3). d=4 has + *negative* ems_power (it produces electricity), so coeff_power is negative. + """ + ETA_HEAT = 0.5 # fraction of CHP gas input that becomes heat + ETA_POWER = 0.3 # fraction of CHP gas input that becomes power + STEAM_DEMAND = 15.0 # kW, constant heat drain representing steam production + CHP_GAS_MAX = 20.0 # kW, maximum gas input to CHP + + start = pd.Timestamp("2026-01-01T00:00+01:00") + end = pd.Timestamp("2026-01-01T04:00+01:00") + resolution = pd.Timedelta("1h") + index = initialize_index(start=start, end=end, resolution=resolution) + + def _df(**kwargs) -> pd.DataFrame: + """Build a device-constraints DataFrame with defaults for unused columns.""" + defaults = { + "min": np.nan, + "max": np.nan, + "equals": np.nan, + "derivative min": 0.0, + "derivative max": 0.0, + "derivative equals": np.nan, + "derivative down efficiency": 1.0, + "derivative up efficiency": 1.0, + "stock delta": 0.0, + } + defaults.update(kwargs) + return pd.DataFrame(defaults, index=index) + + device_constraints = [ + # d=0 e-heater: heat-node reference device. 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": 100.0}), + # d=1 gas boiler: up to 100 kW gas → 100 kW heat (efficiency 1 for clean maths) + _df(**{"derivative max": 100.0}), + # d=2 CHP gas input: up to CHP_GAS_MAX kW gas + _df(**{"derivative max": CHP_GAS_MAX}), + # d=3 CHP heat output: positive ems_power adds heat to the buffer + _df(**{"derivative max": CHP_GAS_MAX * ETA_HEAT}), + # d=4 CHP power output: negative ems_power only (production) + _df(**{"derivative min": -CHP_GAS_MAX * ETA_POWER, "derivative max": 0.0}), + # d=5 steam demand: zero flow, constant stock drain of STEAM_DEMAND kW + _df(**{"stock delta": -STEAM_DEMAND}), + ] + + ems_constraints = pd.DataFrame( + {"derivative min": -300.0, "derivative max": 300.0}, + 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 + stock_groups = {heat_group_id: [0, 1, 3, 5]} + + # CHP coupling: gas_in (d=2) is the reference device + # coeff_heat = 1/η_heat = 2.0 → P_heat = 0.5 * P_gas + # coeff_power = -1/η_power = -10/3 → P_power = -0.3 * P_gas + coupling_groups = { + "chp": [ + (2, 1.0), + (3, 1.0 / ETA_HEAT), # = 2.0 + (4, -1.0 / ETA_POWER), # = -10/3 + ] + } + + # --- energy-price commitments ------------------------------------------- + # Gas price applies to gas boiler (d=1) and CHP gas input (d=2). + # Electricity price applies to e-heater (d=0) and CHP power output (d=4). + # Using both upwards and downwards prices makes each commitment a two-sided + # soft equality (quantity = 0): + # • upward deviation = consuming more than 0 → positive cost + # • downward deviation = producing (negative flow) → negative cost (revenue) + gas_p = pd.Series(gas_price, index=index) + elec_p = pd.Series(elec_price, index=index) + + commitments = [] + for d, price in [(1, gas_p), (2, gas_p), (0, elec_p), (4, elec_p)]: + commitments.append( + FlowCommitment( + name="gas cost" if d in (1, 2) else "electricity cost", + index=index, + quantity=pd.Series(0.0, index=index), + upwards_deviation_price=price, + downwards_deviation_price=price, + device=pd.Series(d, index=index), + ) + ) + + schedules, _costs, results, _model = device_scheduler( + device_constraints=device_constraints, + ems_constraints=ems_constraints, + commitments=commitments, + stock_groups=stock_groups, + coupling_groups=coupling_groups, + ) + + assert results.solver.termination_condition == "optimal", ( + f"Solver did not find an optimal solution " + f"(gas_price={gas_price}, elec_price={elec_price})" + ) + return tuple(schedules) + + +def test_factory_chp_dispatch(): + """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 + constant rate of 15 kW by the steam demand device. Two price scenarios + verify that the optimizer correctly chooses the cheapest heat source. + + Scenario A — gas cheaper than electricity + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + Prices: gas = 20 EUR/kW, electricity = 50 EUR/kW. + + Effective cost per kW of heat delivered: + - CHP: gas_cost − power_revenue = (20·20 − 50·6) / 10 = 10 EUR/kW + - gas boiler: 20 EUR/kW (efficiency = 1) + - e-heater: 50 EUR/kW + + Merit order: CHP ≪ gas boiler ≪ e-heater. + + With CHP at maximum (20 kW gas → 10 kW heat + 6 kW power): + - remaining heat demand = 15 − 10 = 5 kW → gas boiler + - e-heater not needed + + Scenario B — electricity cheaper than gas + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + Prices: gas = 100 EUR/kW, electricity = 10 EUR/kW. + + Effective cost per kW of heat: + - CHP: (100·20 − 10·6) / 10 = 194 EUR/kW + - gas boiler: 100 EUR/kW + - e-heater: 10 EUR/kW + + Merit order: e-heater ≪ gas boiler ≪ CHP. + + All 15 kW steam demand is met by the e-heater; CHP and gas boiler are off. + """ + # ------------------------------------------------------------------ # + # Scenario A: gas cheaper — CHP at max, gas boiler fills the rest # + # ------------------------------------------------------------------ # + (e_heater, gas_boiler, chp_gas, chp_heat, chp_power, _demand) = ( + _run_factory_scenario(gas_price=20.0, elec_price=50.0) + ) + + expected_chp_gas = pd.Series(20.0, index=e_heater.index) + expected_chp_heat = pd.Series(10.0, index=e_heater.index) # 0.5 * 20 + expected_chp_power = pd.Series(-6.0, index=e_heater.index) # -0.3 * 20 + expected_boiler = pd.Series(5.0, index=e_heater.index) # fills 15-10 kW gap + expected_eheater = pd.Series(0.0, index=e_heater.index) + + pd.testing.assert_series_equal( + chp_gas, + expected_chp_gas, + check_names=False, + rtol=1e-4, + obj="Scenario A: CHP gas input at maximum (20 kW)", + ) + pd.testing.assert_series_equal( + chp_heat, + expected_chp_heat, + check_names=False, + rtol=1e-4, + obj="Scenario A: CHP heat output = 0.5 × gas input (10 kW)", + ) + pd.testing.assert_series_equal( + chp_power, + expected_chp_power, + check_names=False, + rtol=1e-4, + obj="Scenario A: CHP power output = −0.3 × gas input (−6 kW)", + ) + pd.testing.assert_series_equal( + gas_boiler, + expected_boiler, + check_names=False, + rtol=1e-4, + obj="Scenario A: gas boiler fills remaining 5 kW heat demand", + ) + pd.testing.assert_series_equal( + e_heater, + expected_eheater, + check_names=False, + atol=1e-4, + obj="Scenario A: e-heater not used (gas is cheapest)", + ) + + # ------------------------------------------------------------------ # + # Scenario B: electricity cheaper — e-heater meets all demand # + # ------------------------------------------------------------------ # + (e_heater, gas_boiler, chp_gas, chp_heat, chp_power, _demand) = ( + _run_factory_scenario(gas_price=100.0, elec_price=10.0) + ) + + expected_eheater_b = pd.Series(15.0, index=e_heater.index) + expected_zero = pd.Series(0.0, index=e_heater.index) + + pd.testing.assert_series_equal( + e_heater, + expected_eheater_b, + check_names=False, + rtol=1e-4, + obj="Scenario B: e-heater meets all 15 kW steam demand", + ) + pd.testing.assert_series_equal( + chp_gas, + expected_zero, + check_names=False, + atol=1e-4, + obj="Scenario B: CHP not used (electricity is cheapest)", + ) + pd.testing.assert_series_equal( + gas_boiler, + expected_zero, + check_names=False, + atol=1e-4, + obj="Scenario B: gas boiler not used (electricity is cheapest)", + ) From a7bbe45a158199e14d4d2db212acb925c8541216 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 3 Jun 2026 12:08:25 +0200 Subject: [PATCH 03/26] refactor: make variables for gas boiler and e-heater capacities Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/tests/test_commitments.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index fed1444f00..2003bea719 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -1735,6 +1735,8 @@ def _run_factory_scenario( ETA_POWER = 0.3 # fraction of CHP gas input that becomes power STEAM_DEMAND = 15.0 # kW, constant heat drain representing steam production CHP_GAS_MAX = 20.0 # kW, maximum gas input to CHP + BOILER_GAS_MAX = 100.0 # kW, maximum gas input to gas boiler + HEATER_POWER_MAX = 100.0 # kW, maximum electricity input to e-heater start = pd.Timestamp("2026-01-01T00:00+01:00") end = pd.Timestamp("2026-01-01T04:00+01:00") @@ -1761,9 +1763,9 @@ def _df(**kwargs) -> pd.DataFrame: # d=0 e-heater: heat-node reference device. 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": 100.0}), + _df(min=0.0, max=0.0, **{"derivative max": HEATER_POWER_MAX}), # d=1 gas boiler: up to 100 kW gas → 100 kW heat (efficiency 1 for clean maths) - _df(**{"derivative max": 100.0}), + _df(**{"derivative max": BOILER_GAS_MAX}), # d=2 CHP gas input: up to CHP_GAS_MAX kW gas _df(**{"derivative max": CHP_GAS_MAX}), # d=3 CHP heat output: positive ems_power adds heat to the buffer From 9d090bba34d4a8ce6b811c17f88a2a59010c9c46 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 3 Jun 2026 12:08:40 +0200 Subject: [PATCH 04/26] docs: clarify e-heater efficiency assumption Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/tests/test_commitments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 2003bea719..db9b1d4859 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -1849,7 +1849,7 @@ def test_factory_chp_dispatch(): Effective cost per kW of heat delivered: - CHP: gas_cost − power_revenue = (20·20 − 50·6) / 10 = 10 EUR/kW - gas boiler: 20 EUR/kW (efficiency = 1) - - e-heater: 50 EUR/kW + - e-heater: 50 EUR/kW (efficiency = 1) Merit order: CHP ≪ gas boiler ≪ e-heater. From 3af4c52bb978389fc872e0d406d8651d9bb93db5 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 3 Jun 2026 12:14:42 +0200 Subject: [PATCH 05/26] =?UTF-8?q?feat:=20add=20scenario=20with=20merit=20o?= =?UTF-8?q?rder:=20gas=20boiler=20=E2=89=AA=20e-heater=20=E2=89=AA=20CHP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: F.N. Claessen --- .../models/planning/tests/test_commitments.py | 70 ++++++++++++++++++- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index db9b1d4859..e498466a42 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -1735,7 +1735,7 @@ def _run_factory_scenario( ETA_POWER = 0.3 # fraction of CHP gas input that becomes power STEAM_DEMAND = 15.0 # kW, constant heat drain representing steam production CHP_GAS_MAX = 20.0 # kW, maximum gas input to CHP - BOILER_GAS_MAX = 100.0 # kW, maximum gas input to gas boiler + BOILER_GAS_MAX = 10.0 # kW, maximum gas input to gas boiler HEATER_POWER_MAX = 100.0 # kW, maximum electricity input to e-heater start = pd.Timestamp("2026-01-01T00:00+01:00") @@ -1869,9 +1869,24 @@ def test_factory_chp_dispatch(): Merit order: e-heater ≪ gas boiler ≪ CHP. All 15 kW steam demand is met by the e-heater; CHP and gas boiler are off. + + Scenario C — gas slightly cheaper + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + Prices: gas = 50 EUR/kW, electricity = 55 EUR/kW. + + Effective cost per kW of heat delivered: + - CHP: gas_cost − power_revenue = (50·20 − 55·6) / 10 = 67 EUR/kW + - gas boiler: 50 EUR/kW + - e-heater: 55 EUR/kW + + Merit order: gas boiler ≪ e-heater ≪ CHP. + + With gas boiler at maximum (10 kW gas → 10 kW heat): + - remaining heat demand = 15 − 10 = 5 kW → e-heater + - CHP not needed """ # ------------------------------------------------------------------ # - # Scenario A: gas cheaper — CHP at max, gas boiler fills the rest # + # Scenario A: gas cheaper — CHP at max, gas boiler fills the rest # # ------------------------------------------------------------------ # (e_heater, gas_boiler, chp_gas, chp_heat, chp_power, _demand) = ( _run_factory_scenario(gas_price=20.0, elec_price=50.0) @@ -1920,7 +1935,7 @@ def test_factory_chp_dispatch(): ) # ------------------------------------------------------------------ # - # Scenario B: electricity cheaper — e-heater meets all demand # + # Scenario B: electricity cheaper — e-heater meets all demand # # ------------------------------------------------------------------ # (e_heater, gas_boiler, chp_gas, chp_heat, chp_power, _demand) = ( _run_factory_scenario(gas_price=100.0, elec_price=10.0) @@ -1950,3 +1965,52 @@ def test_factory_chp_dispatch(): atol=1e-4, obj="Scenario B: gas boiler not used (electricity is cheapest)", ) + + # --------------------------------------------------------------------------------- # + # Scenario C: gas slightly cheaper — gas boiler at max, e-heater fills the rest # + # --------------------------------------------------------------------------------- # + (e_heater, gas_boiler, chp_gas, chp_heat, chp_power, _demand) = ( + _run_factory_scenario(gas_price=50.0, elec_price=55.0) + ) + + expected_chp_gas = pd.Series(0.0, index=e_heater.index) + expected_chp_heat = pd.Series(0.0, index=e_heater.index) + expected_chp_power = pd.Series(0.0, index=e_heater.index) + expected_boiler = pd.Series(10.0, index=e_heater.index) + expected_eheater = pd.Series(5.0, index=e_heater.index) # fills 15-10 kW gap + + pd.testing.assert_series_equal( + chp_gas, + expected_chp_gas, + check_names=False, + rtol=1e-4, + obj="Scenario C: CHP not used", + ) + pd.testing.assert_series_equal( + chp_heat, + expected_chp_heat, + check_names=False, + rtol=1e-4, + obj="Scenario C: CHP not used", + ) + pd.testing.assert_series_equal( + chp_power, + expected_chp_power, + check_names=False, + rtol=1e-4, + obj="Scenario C: CHP not used", + ) + pd.testing.assert_series_equal( + gas_boiler, + expected_boiler, + check_names=False, + rtol=1e-4, + obj="Scenario C: gas boiler at maximum (10 kW)", + ) + pd.testing.assert_series_equal( + e_heater, + expected_eheater, + check_names=False, + atol=1e-4, + obj="Scenario C: e-heater fills remaining 5 kW heat demand", + ) From e4f8fc97e11206efc9a4410198a1ff3377ec6097 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 3 Jun 2026 12:53:10 +0200 Subject: [PATCH 06/26] feat: support flex-model coupling constraint in StorageScheduler Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/__init__.py | 27 +++ flexmeasures/data/models/planning/storage.py | 5 + .../models/planning/tests/test_storage.py | 173 ++++++++++++++++++ .../data/schemas/scheduling/storage.py | 25 +++ 4 files changed, 230 insertions(+) diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py index 398e0fab25..0c2ffe37df 100644 --- a/flexmeasures/data/models/planning/__init__.py +++ b/flexmeasures/data/models/planning/__init__.py @@ -102,6 +102,33 @@ def _build_stock_groups(flex_model: list[dict]) -> dict: return dict(groups) + @staticmethod + def _build_coupling_groups( + flex_model: list[dict], + ) -> dict[str, list[tuple[int, float]]]: + """Build coupling groups from the 'coupling' and 'coupling_coefficient' fields + of each device model. + + Devices that share the same coupling name form a coupling group. Within each + group the first device encountered acts as the reference device (coefficient + assigned as given) and the remaining devices are linked to it via a pairwise + hard equality constraint enforced by ``device_scheduler``. + + :param flex_model: List of deserialized device flex-model dicts. + :returns: Mapping from coupling-group name to a list of + ``(device_index, coefficient)`` tuples suitable for passing to + ``device_scheduler(coupling_groups=...)``. Returns an empty dict + when no device defines a ``coupling`` field. + """ + groups: dict[str, list[tuple[int, float]]] = defaultdict(list) + for d, fm in enumerate(flex_model): + coupling_name = fm.get("coupling") + if coupling_name is None: + continue + coefficient = fm.get("coupling_coefficient", 1.0) + groups[coupling_name].append((d, coefficient)) + return dict(groups) + def __init__( self, sensor: Sensor | None = None, # deprecated diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index e130adfb10..470d8e0374 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -215,6 +215,10 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 # This ensures the mapping aligns with the device indices self.stock_groups = self._build_stock_groups(device_models) + # Build coupling_groups from the 'coupling' and 'coupling_coefficient' fields + # of each device model. Devices sharing the same coupling name form a group. + self.coupling_groups = self._build_coupling_groups(device_models) + # List the asset(s) and sensor(s) being scheduled if self.asset is not None: if not isinstance(self.flex_model, list): @@ -2084,6 +2088,7 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: commitments=commitments, initial_stock=initial_stock, stock_groups=self.stock_groups, + coupling_groups=self.coupling_groups if self.coupling_groups else None, ) if "infeasible" in (tc := scheduler_results.solver.termination_condition): raise InfeasibleProblemException(tc) diff --git a/flexmeasures/data/models/planning/tests/test_storage.py b/flexmeasures/data/models/planning/tests/test_storage.py index 22de722b59..97d89be159 100644 --- a/flexmeasures/data/models/planning/tests/test_storage.py +++ b/flexmeasures/data/models/planning/tests/test_storage.py @@ -6,6 +6,7 @@ import numpy as np import pandas as pd +from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType from flexmeasures.data.models.planning import Scheduler from flexmeasures.data.models.planning.storage import StorageScheduler from flexmeasures.data.models.planning.utils import initialize_index @@ -15,6 +16,7 @@ get_sensors_from_db, series_to_ts_specs, ) +from flexmeasures.data.services.utils import get_or_create_model def test_battery_solver_multi_commitment(add_battery_assets, db): @@ -579,3 +581,174 @@ def test_resolve_soc_at_start_from_percent_sensor_uses_device_sensor_fallback( ) == 2.5 ) + + +def test_storage_scheduler_chp_coupling(app, db): + """Test that the StorageScheduler enforces CHP coupling constraints between devices. + + Models a Combined Heat and Power unit with three flow sensors: + + - d=0 gas input: CHP gas consumption (positive ems_power, coeff 1.0) + - d=1 heat output: CHP heat → heat buffer (positive ems_power, coeff 2.0) + - d=2 power output: CHP electricity production (negative ems_power, coeff −10/3) + + The coupling group ``"chp"`` enforces:: + + 1.0 * P_gas == 2.0 * P_heat → P_heat = 0.5 * P_gas (η_heat = 0.5) + 1.0 * P_gas == −10/3 * P_power → P_power = −0.3 * P_gas (η_power = 0.3) + + The heat output is forced to exactly 5 kW per step by combining: + - ``production-capacity: "0 kW"`` (hard lower bound: derivative_min = 0) + - ``consumption-capacity: "5 kW"`` (hard upper bound: derivative_max = 0.005 MW) + - ``soc-targets`` requiring 20 kWh at the end of the 4-hour window + + With soc_at_start = 0 and max 5 kW over 4 × 1-hour steps the only feasible + solution is P_heat = 5 kW every step. The coupling constraints then fix:: + + P_gas = 2.0 × 5 kW = 10 kW + P_power = −0.3 × 10 kW = −3 kW + """ + # ---- asset type + asset + chp_type = get_or_create_model(GenericAssetType, name="chp-plant") + chp = GenericAsset(name="CHP plant (coupling test)", generic_asset_type=chp_type) + db.session.add(chp) + db.session.flush() + + # ---- schedule window + start = pd.Timestamp("2026-01-01T00:00:00+01:00") + end = pd.Timestamp("2026-01-01T04:00:00+01:00") + resolution = timedelta(hours=1) + + # CHP efficiencies (same values as the factory scenario in test_commitments.py) + ETA_HEAT = 0.5 # fraction of gas input that becomes heat + ETA_POWER = 0.3 # fraction of gas input that becomes electricity + + # ---- sensors + gas_input_sensor = Sensor( + name="CHP gas input (coupling test)", + generic_asset=chp, + unit="MW", + event_resolution=resolution, + ) + heat_output_sensor = Sensor( + name="CHP heat output (coupling test)", + generic_asset=chp, + unit="MW", + event_resolution=resolution, + ) + power_output_sensor = Sensor( + name="CHP power output (coupling test)", + generic_asset=chp, + unit="MW", + event_resolution=resolution, + ) + db.session.add_all([gas_input_sensor, heat_output_sensor, power_output_sensor]) + db.session.flush() + + # ---- flex model + # The "coupling-coefficient" for each device determines its ratio in the + # hard-equality coupling constraint enforced by device_scheduler. + flex_model = [ + { + # d=0: gas input — pure flow device (no SoC), can only consume gas. + "sensor": gas_input_sensor.id, + "power-capacity": "20 kW", + "production-capacity": "0 kW", # derivative_min = 0 + "coupling": "chp", + "coupling-coefficient": 1.0, # reference device + }, + { + # d=1: heat output — tracks heat-buffer SoC, positive ems_power = heat + # added to buffer. The SoC target forces P_heat = 5 kW per step. + "sensor": heat_output_sensor.id, + "soc-at-start": "0 MWh", + "soc-min": "0 MWh", + "soc-max": "0.02 MWh", # 20 kWh — matches the SoC target + "soc-targets": [ + { + # Single target at the schedule end: cumulative heat = 20 kWh. + # With max 5 kW and 4 × 1 h steps the only feasible solution + # is 5 kW every step. + "start": "2026-01-01T04:00:00+01:00", + "duration": "PT1H", + "value": "0.02 MWh", + } + ], + "power-capacity": "5 kW", + "consumption-capacity": "5 kW", + "production-capacity": "0 kW", # can only add heat, not extract + "prefer-charging-sooner": True, + "coupling": "chp", + "coupling-coefficient": 1.0 / ETA_HEAT, # = 2.0 + }, + { + # d=2: power output — pure flow device (no SoC), can only produce + # electricity (negative ems_power). + "sensor": power_output_sensor.id, + "power-capacity": "6 kW", + "consumption-capacity": "0 kW", # derivative_max = 0 + "coupling": "chp", + "coupling-coefficient": -1.0 / ETA_POWER, # = -10/3 + }, + ] + + flex_context = { + "consumption-price": "50 EUR/MWh", + "production-price": "50 EUR/MWh", + "site-power-capacity": "1 MW", # large enough to avoid EMS constraints + } + + scheduler = StorageScheduler( + asset_or_sensor=chp, + start=start, + end=end, + resolution=resolution, + flex_model=flex_model, + flex_context=flex_context, + return_multiple=True, + ) + + results = scheduler.compute(skip_validation=True) + + # ---- extract storage schedules per sensor + storage_schedules = { + r["sensor"]: r["data"] for r in results if r.get("name") == "storage_schedule" + } + + assert gas_input_sensor in storage_schedules, "Gas input schedule missing" + assert heat_output_sensor in storage_schedules, "Heat output schedule missing" + assert power_output_sensor in storage_schedules, "Power output schedule missing" + + gas_schedule = storage_schedules[gas_input_sensor] + heat_schedule = storage_schedules[heat_output_sensor] + power_schedule = storage_schedules[power_output_sensor] + + # The SoC target of 20 kWh is met after 4 × 1-hour steps at 5 kW. + # The schedule index runs from ``start`` to ``end`` inclusive (5 time slots), + # so the last slot has no binding SoC constraint and the CHP is idle there. + # All assertions therefore apply to the first four active slots only. + active_steps = slice(None, -1) # exclude the final trailing idle slot + + # Heat output is forced to exactly 5 kW per step by the SoC target. + np.testing.assert_allclose( + heat_schedule.iloc[active_steps], + 0.005, # 5 kW expressed in MW + rtol=1e-4, + err_msg="Heat output should be exactly 5 kW per step (forced by SoC target)", + ) + + # Coupling: 1.0 * P_gas == 2.0 * P_heat → P_gas = 2.0 * 5 kW = 10 kW + np.testing.assert_allclose( + gas_schedule.iloc[active_steps], + 0.010, # 10 kW expressed in MW + rtol=1e-4, + err_msg="Gas input must be 10 kW — determined by coupling (2× heat output)", + ) + + # Coupling: 1.0 * P_gas == −10/3 * P_power → P_power = −0.3 * 10 kW = −3 kW + np.testing.assert_allclose( + power_schedule.iloc[active_steps], + -0.003, # −3 kW expressed in MW + rtol=1e-4, + err_msg="Power output must be −3 kW — determined by coupling (−0.3× gas input)", + ) diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index a7189f2c18..8dcbf81cc9 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -255,6 +255,31 @@ class StorageFlexModelSchema(Schema): metadata=dict(description="Commodity label for this device/asset."), ) + coupling = fields.Str( + data_key="coupling", + required=False, + load_default=None, + metadata=dict( + description="Name of the coupling group this device belongs to. " + "Devices sharing the same coupling name are constrained to have " + "proportionally related flows via a hard equality constraint. " + "Use together with 'coupling-coefficient' to set the ratio.", + example="chp", + ), + ) + coupling_coefficient = fields.Float( + data_key="coupling-coefficient", + required=False, + load_default=1.0, + metadata=dict( + description="Coefficient for this device within its coupling group. " + "For each pair of devices in the group the constraint " + "coeff_ref * P[d_ref] == coeff_i * P[d_i] is enforced at every time step, " + "where d_ref is the first device listed in the group. Defaults to 1.0.", + example=1.0, + ), + ) + def __init__( self, start: datetime, From 1662283737e41bc0d799ef6a2c96619a150a5132 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 3 Jun 2026 15:30:06 +0200 Subject: [PATCH 07/26] docs: clarify calculation of coupling coefficients Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/tests/test_commitments.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index e498466a42..38e0418c69 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -1565,9 +1565,10 @@ def test_chp_coupling(): on device 1. Substituting into the coupling constraints gives the expected solution: - P_gas = 5 kW (gas consumed) + P_gas = 5 kW (gas consumed) P_heat = -10 kW (heat produced, forced) - P_power = -50/3 ≈ -16.67 kW (electricity produced) + P_power = 5 kW / -0.3 + ≈ -17 kW (electricity produced) Note: the coefficients above do not represent a physically realisable CHP (total output exceeds input). They are chosen to exercise the constraint From 0d92dc432888e7b932d99fc50d381f8ae4d43d77 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 3 Jun 2026 15:41:08 +0200 Subject: [PATCH 08/26] feat: invert interpretation of coefficients to better match thermal and electrical efficiencies Signed-off-by: F.N. Claessen --- .../models/planning/linear_optimization.py | 8 +++--- .../models/planning/tests/test_commitments.py | 25 ++++++++----------- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 922935d9e1..d9bdfcafb0 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -75,7 +75,7 @@ def device_scheduler( # noqa C901 or use a single value to set the initial stock to be the same for all devices. :param coupling_groups: Hard flow-coupling constraints between devices. Each entry maps a group name to a list of ``(device_index, coefficient)`` tuples. For every pair of devices in a group the constraint - ``coeff_ref * P[d_ref, j] == coeff_i * P[d_i, j]`` is enforced for every time step ``j``, + ``P[d_ref, j] / coeff_ref == P[d_i, j] / coeff_i`` is enforced for every time step ``j``, using the first member of the list as the reference device. Example — a CHP with gas input (d=0, coeff 1.0), heat output (d=1, coeff −0.5) and power output (d=2, coeff −0.3):: @@ -128,7 +128,7 @@ def device_scheduler( # noqa C901 # Build flat list of pairwise flow-coupling constraints from coupling_groups. # Each entry is (d_ref, coeff_ref, d_i, coeff_i) and enforces: - # coeff_ref * ems_power[d_ref, j] == coeff_i * ems_power[d_i, j] + # ems_power[d_ref, j] / coeff_ref == ems_power[d_i, j] / coeff_i coupling_pairs: list[tuple[int, float, int, float]] = [] if coupling_groups: for _group_name, members in coupling_groups.items(): @@ -765,13 +765,13 @@ def flow_coupling_rule(m, p, j): """Enforce a fixed ratio between the flows of two coupled devices. For coupling pair ``p`` at time ``j`` the constraint is: - coeff_ref * ems_power[d_ref, j] == coeff_i * ems_power[d_i, j] + ems_power[d_ref, j] / coeff_ref == ems_power[d_i, j] / coeff_i which is stored as the Pyomo equality (0, lhs - rhs, 0). """ d_ref, coeff_ref, d_i, coeff_i = coupling_pairs[p] return ( 0, - coeff_ref * m.ems_power[d_ref, j] - coeff_i * m.ems_power[d_i, j], + m.ems_power[d_ref, j] / coeff_ref - m.ems_power[d_i, j] / coeff_i, 0, ) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 38e0418c69..239484b720 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -1558,21 +1558,18 @@ def test_chp_coupling(): ``[(0, 1.0), (1, -0.5), (2, -0.3)]``. This generates two hard equality constraints for every time step ``j``: - 1.0 * P_gas[j] == -0.5 * P_heat[j] → P_gas == -0.5 * P_heat - 1.0 * P_gas[j] == -0.3 * P_power[j] → P_gas == -0.3 * P_power + 1.0 * P_gas[j] == P_heat[j] / -0.5 + 1.0 * P_gas[j] == P_power[j] / -0.3 Heat production is forced to exactly 10 kW via ``derivative equals = -10`` on device 1. Substituting into the coupling constraints gives the expected solution: - P_gas = 5 kW (gas consumed) + P_gas = 20 kW (gas consumed) P_heat = -10 kW (heat produced, forced) - P_power = 5 kW / -0.3 - ≈ -17 kW (electricity produced) + P_power = 20 kW * -0.3 + ≈ -6 kW (electricity produced) - Note: the coefficients above do not represent a physically realisable CHP - (total output exceeds input). They are chosen to exercise the constraint - arithmetic with non-trivial numbers that are easy to verify by hand. """ start = pd.Timestamp("2026-01-01T00:00+01:00") end = pd.Timestamp("2026-01-01T04:00+01:00") @@ -1669,22 +1666,22 @@ def test_chp_coupling(): obj="heat output forced to -10 kW by derivative_equals", ) - # Coupling: 1.0 * P_gas == -0.5 * P_heat → P_gas = -0.5 * (-10) = 5 kW + # Coupling: P_gas / 1.0 == P_heat / -0.5 → P_gas = -10 / -0.5 = 20 kW pd.testing.assert_series_equal( schedules[0], - pd.Series(5.0, index=index), + pd.Series(20.0, index=index), check_names=False, rtol=1e-4, - obj="gas consumption determined by coupling (5 kW from 10 kW heat at coeff -0.5)", + obj="gas consumption determined by coupling (20 kW from 10 kW heat at coeff -0.5)", ) - # Coupling: 1.0 * P_gas == -0.3 * P_power → P_power = -5 / 0.3 = -50/3 kW + # Coupling: P_gas / 1.0 == P_power / -0.3 → P_power = 20 / -0.3 = -6 kW pd.testing.assert_series_equal( schedules[2], - pd.Series(-50.0 / 3.0, index=index), + pd.Series(-6.0, index=index), check_names=False, rtol=1e-4, - obj="power output determined by coupling (-50/3 kW from 5 kW gas at coeff -0.3)", + obj="power output determined by coupling (20/-0.3 kW from 20 kW gas at coeff -0.3)", ) From a12032f987139054d0840c61ff40a9cdd2bbad34 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 3 Jun 2026 22:52:16 +0200 Subject: [PATCH 09/26] feat: support multiple inputs to coupling point Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/__init__.py | 31 +++- .../models/planning/linear_optimization.py | 56 +++---- .../models/planning/tests/test_commitments.py | 150 +++++++++++++++++- .../models/planning/tests/test_storage.py | 45 +++--- .../data/schemas/scheduling/storage.py | 13 +- 5 files changed, 229 insertions(+), 66 deletions(-) diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py index 0c2ffe37df..bc29ed9470 100644 --- a/flexmeasures/data/models/planning/__init__.py +++ b/flexmeasures/data/models/planning/__init__.py @@ -109,16 +109,29 @@ def _build_coupling_groups( """Build coupling groups from the 'coupling' and 'coupling_coefficient' fields of each device model. - Devices that share the same coupling name form a coupling group. Within each - group the first device encountered acts as the reference device (coefficient - assigned as given) and the remaining devices are linked to it via a pairwise - hard equality constraint enforced by ``device_scheduler``. + Devices sharing the same coupling name form a coupling group. + The optimization model introduces a decision variable ``alpha`` per group per time step, + and constrains every device by ``P[d] == coeff_d * alpha``. + + - Input devices (consuming) are specified with positive coupling coefficients. + Their coefficients must sum up to 1. + - Output devices (producing) are specified with negative coupling coefficients. + Their coefficients do not need to sum up to -1; the remainder denotes a loss. + + Example — a CHP with 50% heat efficiency and 30% power efficiency: + + [ + {"coupling": "chp", "coupling_coefficient": 1.0}, # gas input (alpha = P_gas) + {"coupling": "chp", "coupling_coefficient": -0.5}, # heat output (50% of gas becomes heat) + {"coupling": "chp", "coupling_coefficient": -0.3}, # power output (30% of gas becomes power) + ] :param flex_model: List of deserialized device flex-model dicts. :returns: Mapping from coupling-group name to a list of ``(device_index, coefficient)`` tuples suitable for passing to - ``device_scheduler(coupling_groups=...)``. Returns an empty dict + ``device_scheduler(coupling_groups=...)``. Returns an empty dict when no device defines a ``coupling`` field. + :raises ValueError: if sum of positive coefficients in a group is not 1. """ groups: dict[str, list[tuple[int, float]]] = defaultdict(list) for d, fm in enumerate(flex_model): @@ -127,6 +140,14 @@ def _build_coupling_groups( continue coefficient = fm.get("coupling_coefficient", 1.0) groups[coupling_name].append((d, coefficient)) + + # Check sum of positive coefficients + for group_name, devices in groups.items(): + positive_sum = sum(coeff for _, coeff in devices if coeff > 0) + if not abs(positive_sum - 1.0) < 1e-8: # tiny tolerance for floating point + raise ValueError( + f"Sum of positive coefficients in coupling group '{group_name}' is {positive_sum}, but must equal 1." + ) return dict(groups) def __init__( diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index d9bdfcafb0..4721384e12 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -73,10 +73,11 @@ def device_scheduler( # noqa C901 device: 0 (corresponds to device d; if not set, commitment is on an EMS level) :param initial_stock: initial stock for each device. Use a list with the same number of devices as device_constraints, or use a single value to set the initial stock to be the same for all devices. - :param coupling_groups: Hard flow-coupling constraints between devices. Each entry maps a group name to a list of - ``(device_index, coefficient)`` tuples. For every pair of devices in a group the constraint - ``P[d_ref, j] / coeff_ref == P[d_i, j] / coeff_i`` is enforced for every time step ``j``, - using the first member of the list as the reference device. + :param coupling_groups: Hard flow-coupling constraints between devices. Each entry maps a group name to a list of + ``(device_index, coefficient)`` tuples. A decision variable ``alpha`` is introduced per group + per time step and every device ``d`` in the group is constrained by ``P[d, j] == coeff_d * alpha[group, j]``. + Sign convention: positive coefficient for input devices (consuming, positive ``ems_power``), + negative coefficient for output devices (producing, negative ``ems_power``). Example — a CHP with gas input (d=0, coeff 1.0), heat output (d=1, coeff −0.5) and power output (d=2, coeff −0.3):: @@ -126,17 +127,14 @@ def device_scheduler( # noqa C901 for d in range(len(device_constraints)): device_to_group[d] = d - # Build flat list of pairwise flow-coupling constraints from coupling_groups. - # Each entry is (d_ref, coeff_ref, d_i, coeff_i) and enforces: - # ems_power[d_ref, j] / coeff_ref == ems_power[d_i, j] / coeff_i - coupling_pairs: list[tuple[int, float, int, float]] = [] + # 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 _group_name, members in coupling_groups.items(): - if len(members) < 2: - continue - d_ref, coeff_ref = members[0] - for d_i, coeff_i in members[1:]: - coupling_pairs.append((d_ref, coeff_ref, d_i, coeff_i)) + 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)) # Move commitments from old structure to new if commitments is None: @@ -759,25 +757,27 @@ def device_derivative_equalities(m, d, j): model.d, model.j, rule=device_derivative_equalities ) - if coupling_pairs: + if coupling_device_specs: + n_coupling_groups = len(coupling_groups) - def flow_coupling_rule(m, p, j): - """Enforce a fixed ratio between the flows of two coupled devices. + # One free variable per group per time step: the common normalised flow. + model.coupling_group_range = RangeSet(0, n_coupling_groups - 1) + model.coupling_alpha = Var(model.coupling_group_range, model.j, domain=Reals) - For coupling pair ``p`` at time ``j`` the constraint is: - ems_power[d_ref, j] / coeff_ref == ems_power[d_i, j] / coeff_i - which is stored as the Pyomo equality (0, lhs - rhs, 0). + model.coupling_device_range = RangeSet(0, len(coupling_device_specs) - 1) + + def flow_coupling_rule(m, c, j): + """Enforce P[d, j] == coeff * alpha[group, j] for each coupled device. + + This pins every device's flow to the same normalised level ``alpha``, + scaled by its coupling coefficient. The coefficient sign indicates direction: + positive for inputs (consuming), negative for outputs (producing). """ - d_ref, coeff_ref, d_i, coeff_i = coupling_pairs[p] - return ( - 0, - m.ems_power[d_ref, j] / coeff_ref - m.ems_power[d_i, j] / coeff_i, - 0, - ) + g, d, coeff = coupling_device_specs[c] + return m.ems_power[d, j] == coeff * m.coupling_alpha[g, j] - model.coupling_pair = RangeSet(0, len(coupling_pairs) - 1) model.flow_coupling_constraints = Constraint( - model.coupling_pair, model.j, rule=flow_coupling_rule + model.coupling_device_range, model.j, rule=flow_coupling_rule ) # Add objective diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 239484b720..20fa6dab77 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -1548,22 +1548,22 @@ def test_simulation_with_dynamic_consumption_capacity(app, db): def test_chp_coupling(): """Test that coupling_groups enforces fixed flow ratios between CHP devices. - Models a Combined Heat and Power unit with three flow devices: + Models a Combined Heat and Power unit with three pure flow devices: - d=0 gas input: can only consume gas (derivative_min=0) - d=1 heat output: can only produce heat (derivative_max=0) - d=2 power output: can only produce electricity (derivative_max=0) The coupling group ``"chp"`` is specified with coefficients - ``[(0, 1.0), (1, -0.5), (2, -0.3)]``. This generates two hard equality - constraints for every time step ``j``: + ``[(0, 1.0), (1, -0.5), (2, -0.3)]``, introducing a decision variable ``alpha`` + and enforcing ``P[d] == coeff * alpha`` for each device: - 1.0 * P_gas[j] == P_heat[j] / -0.5 - 1.0 * P_gas[j] == P_power[j] / -0.3 + P_gas = 1.0 * alpha (input, coeff = 1.0) + P_heat = -0.5 * alpha (output, coeff = -0.5, heat efficiency 50%) + P_power = -0.3 * alpha (output, coeff = -0.3, power efficiency 30%) Heat production is forced to exactly 10 kW via ``derivative equals = -10`` - on device 1. Substituting into the coupling constraints gives the expected - solution: + on device 1. Substituting ``P_heat = -10`` gives ``alpha = 20``, so: P_gas = 20 kW (gas consumed) P_heat = -10 kW (heat produced, forced) @@ -1681,7 +1681,141 @@ def test_chp_coupling(): pd.Series(-6.0, index=index), check_names=False, rtol=1e-4, - obj="power output determined by coupling (20/-0.3 kW from 20 kW gas at coeff -0.3)", + obj="power output determined by coupling (-0.3 * alpha = -0.3 * 20 = -6 kW)", + ) + + +def test_dual_fuel_chp_coupling(): + """Test coupling_groups with two input devices (dual-fuel CHP). + + Models a CHP unit that consumes equal parts natural gas and hydrogen, + producing heat and electricity: + + - d=0 gas input: can only consume gas (derivative_min=0) + - d=1 hydrogen input: can only consume hydrogen (derivative_min=0) + - d=2 heat output: can only produce heat (derivative_max=0) + - d=3 power output: can only produce electricity (derivative_max=0) + + Coupling group ``"chp"`` with coefficients + ``[(0, 0.5), (1, 0.5), (2, -0.5), (3, -0.3)]`` introduces a free variable + ``alpha`` and enforces ``P[d] == coeff * alpha``: + + P_gas = 0.5 * alpha (50% of total fuel from gas) + P_hydrogen = 0.5 * alpha (50% of total fuel from hydrogen) + P_heat = -0.5 * alpha (heat efficiency 50% of total fuel) + P_power = -0.3 * alpha (power efficiency 30% of total fuel) + + Because gas and hydrogen share the same coefficient the two fuel flows are + always equal, confirming that device order does not affect the result. + + Heat production is forced to exactly 10 kW via ``derivative equals = -10`` on device 2. + Substituting ``P_heat = -10`` gives ``alpha = 20``, so: + + P_gas = 10 kW (equal gas input) + P_hydrogen = 10 kW (equal hydrogen input) + P_heat = -10 kW (heat produced, forced) + P_power = -6 kW (electricity produced) + """ + start = pd.Timestamp("2026-01-01T00:00+01:00") + end = pd.Timestamp("2026-01-01T04:00+01:00") + resolution = pd.Timedelta("1h") + index = initialize_index(start=start, end=end, resolution=resolution) + + def _flow_df(**kwargs) -> pd.DataFrame: + defaults = { + "min": np.nan, + "max": np.nan, + "equals": np.nan, + "derivative min": 0.0, + "derivative max": 0.0, + "derivative equals": np.nan, + "derivative down efficiency": 1.0, + "derivative up efficiency": 1.0, + } + defaults.update(kwargs) + return pd.DataFrame(defaults, index=index) + + # d=0: gas input — can only consume, capacity 100 kW + gas_constraints = _flow_df(**{"derivative max": 100.0}) + # d=1: hydrogen input — can only consume, capacity 100 kW + hydrogen_constraints = _flow_df(**{"derivative max": 100.0}) + # d=2: heat output — can only produce, forced to -10 kW + heat_constraints = _flow_df( + **{"derivative min": -100.0, "derivative equals": -10.0} + ) + # d=3: power output — can only produce, free (coupling determines value) + power_constraints = _flow_df(**{"derivative min": -100.0}) + + ems_constraints = pd.DataFrame( + {"derivative min": -200.0, "derivative max": 200.0}, + index=index, + ) + + # Both fuel inputs share coefficient 0.5, so they receive identical flows. + # Outputs have negative coefficients equal to their efficiency fractions. + coupling_groups = {"chp": [(0, 0.5), (1, 0.5), (2, -0.5), (3, -0.3)]} + + # Gas-price commitment for device 0 just to give the objective a finite value + # Even though hydrogen is free, it will still be used because its consumption is coupled to gas. + fuel_cost_commitment = FlowCommitment( + name="fuel cost", + index=index, + quantity=pd.Series(0.0, index=index), + upwards_deviation_price=pd.Series(1.0, index=index), + downwards_deviation_price=pd.Series(0.0, index=index), + device=pd.Series(0, index=index), + ) + + schedules, _costs, results, _model = device_scheduler( + device_constraints=[ + gas_constraints, + hydrogen_constraints, + heat_constraints, + power_constraints, + ], + ems_constraints=ems_constraints, + commitments=[fuel_cost_commitment], + coupling_groups=coupling_groups, + ) + + assert ( + results.solver.termination_condition == "optimal" + ), "Solver did not find an optimal solution." + + # Heat is fixed to -10 kW; alpha = -10 / -0.5 = 20. + pd.testing.assert_series_equal( + schedules[2], + pd.Series(-10.0, index=index), + check_names=False, + rtol=1e-4, + obj="heat output forced to -10 kW by derivative_equals", + ) + + # Coupling: P_gas = 0.5 * alpha = 0.5 * 20 = 10 kW + pd.testing.assert_series_equal( + schedules[0], + pd.Series(10.0, index=index), + check_names=False, + rtol=1e-4, + obj="gas input = 0.5 * alpha = 10 kW", + ) + + # Coupling: P_hydrogen = 0.5 * alpha = 10 kW (equal to gas) + pd.testing.assert_series_equal( + schedules[1], + pd.Series(10.0, index=index), + check_names=False, + rtol=1e-4, + obj="hydrogen input = 0.5 * alpha = 10 kW (equal to gas input)", + ) + + # Coupling: P_power = -0.3 * alpha = -0.3 * 20 = -6 kW + pd.testing.assert_series_equal( + schedules[3], + pd.Series(-6.0, index=index), + check_names=False, + rtol=1e-4, + obj="power output = -0.3 * alpha = -6 kW", ) diff --git a/flexmeasures/data/models/planning/tests/test_storage.py b/flexmeasures/data/models/planning/tests/test_storage.py index 97d89be159..45331f3850 100644 --- a/flexmeasures/data/models/planning/tests/test_storage.py +++ b/flexmeasures/data/models/planning/tests/test_storage.py @@ -586,16 +586,18 @@ def test_resolve_soc_at_start_from_percent_sensor_uses_device_sensor_fallback( def test_storage_scheduler_chp_coupling(app, db): """Test that the StorageScheduler enforces CHP coupling constraints between devices. - Models a Combined Heat and Power unit with three flow sensors: + Models a Combined Heat and Power unit with three sensors: - - d=0 gas input: CHP gas consumption (positive ems_power, coeff 1.0) - - d=1 heat output: CHP heat → heat buffer (positive ems_power, coeff 2.0) - - d=2 power output: CHP electricity production (negative ems_power, coeff −10/3) + - d=0 gas input: CHP gas consumption (positive ems_power, coeff 1.0) + - d=1 heat output: CHP heat → heat buffer (positive ems_power, coeff 0.5) + - d=2 power output: CHP electricity production (negative ems_power, coeff −0.3) - The coupling group ``"chp"`` enforces:: + The coupling group ``"chp"`` introduces a free variable ``alpha`` and enforces + ``P[d] == coeff * alpha`` for every device: - 1.0 * P_gas == 2.0 * P_heat → P_heat = 0.5 * P_gas (η_heat = 0.5) - 1.0 * P_gas == −10/3 * P_power → P_power = −0.3 * P_gas (η_power = 0.3) + P_gas = 1.0 * alpha + P_heat = 0.5 * alpha (η_heat = 0.5) + P_power = −0.3 * alpha (η_power = 0.3) The heat output is forced to exactly 5 kW per step by combining: - ``production-capacity: "0 kW"`` (hard lower bound: derivative_min = 0) @@ -603,9 +605,10 @@ def test_storage_scheduler_chp_coupling(app, db): - ``soc-targets`` requiring 20 kWh at the end of the 4-hour window With soc_at_start = 0 and max 5 kW over 4 × 1-hour steps the only feasible - solution is P_heat = 5 kW every step. The coupling constraints then fix:: + solution is P_heat = 5 kW every step. Substituting P_heat = 5 kW gives + alpha = 5 / 0.5 = 10 kW, so: - P_gas = 2.0 × 5 kW = 10 kW + P_gas = 1.0 × 10 kW = 10 kW P_power = −0.3 × 10 kW = −3 kW """ # ---- asset type + asset @@ -646,8 +649,9 @@ def test_storage_scheduler_chp_coupling(app, db): db.session.flush() # ---- flex model - # The "coupling-coefficient" for each device determines its ratio in the - # hard-equality coupling constraint enforced by device_scheduler. + # Coupling-coefficients equal the signed efficiency fractions. + # Positive coeff = input or stock-accumulating device (positive ems_power). + # Negative coeff = production device (negative ems_power). flex_model = [ { # d=0: gas input — pure flow device (no SoC), can only consume gas. @@ -655,11 +659,11 @@ def test_storage_scheduler_chp_coupling(app, db): "power-capacity": "20 kW", "production-capacity": "0 kW", # derivative_min = 0 "coupling": "chp", - "coupling-coefficient": 1.0, # reference device + "coupling-coefficient": 1.0, # reference: alpha = P_gas }, { # d=1: heat output — tracks heat-buffer SoC, positive ems_power = heat - # added to buffer. The SoC target forces P_heat = 5 kW per step. + # added to buffer. The SoC target forces P_heat = 5 kW per step. "sensor": heat_output_sensor.id, "soc-at-start": "0 MWh", "soc-min": "0 MWh", @@ -679,7 +683,7 @@ def test_storage_scheduler_chp_coupling(app, db): "production-capacity": "0 kW", # can only add heat, not extract "prefer-charging-sooner": True, "coupling": "chp", - "coupling-coefficient": 1.0 / ETA_HEAT, # = 2.0 + "coupling-coefficient": ETA_HEAT, # = 0.5 }, { # d=2: power output — pure flow device (no SoC), can only produce @@ -688,7 +692,7 @@ def test_storage_scheduler_chp_coupling(app, db): "power-capacity": "6 kW", "consumption-capacity": "0 kW", # derivative_max = 0 "coupling": "chp", - "coupling-coefficient": -1.0 / ETA_POWER, # = -10/3 + "coupling-coefficient": -ETA_POWER, # = -0.3 }, ] @@ -730,6 +734,7 @@ def test_storage_scheduler_chp_coupling(app, db): active_steps = slice(None, -1) # exclude the final trailing idle slot # Heat output is forced to exactly 5 kW per step by the SoC target. + # alpha = P_heat / ETA_HEAT = 0.005 / 0.5 = 0.010 MW np.testing.assert_allclose( heat_schedule.iloc[active_steps], 0.005, # 5 kW expressed in MW @@ -737,18 +742,18 @@ def test_storage_scheduler_chp_coupling(app, db): err_msg="Heat output should be exactly 5 kW per step (forced by SoC target)", ) - # Coupling: 1.0 * P_gas == 2.0 * P_heat → P_gas = 2.0 * 5 kW = 10 kW + # Coupling: P_gas = 1.0 * alpha = 0.010 MW = 10 kW np.testing.assert_allclose( gas_schedule.iloc[active_steps], 0.010, # 10 kW expressed in MW rtol=1e-4, - err_msg="Gas input must be 10 kW — determined by coupling (2× heat output)", + err_msg="Gas input must be 10 kW — determined by coupling (1.0 * alpha)", ) - # Coupling: 1.0 * P_gas == −10/3 * P_power → P_power = −0.3 * 10 kW = −3 kW + # Coupling: P_power = -ETA_POWER * alpha = -0.3 * 0.010 MW = -0.003 MW = -3 kW np.testing.assert_allclose( power_schedule.iloc[active_steps], - -0.003, # −3 kW expressed in MW + -0.003, # -3 kW expressed in MW rtol=1e-4, - err_msg="Power output must be −3 kW — determined by coupling (−0.3× gas input)", + err_msg="Power output must be -3 kW — determined by coupling (-0.3 * alpha)", ) diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 8dcbf81cc9..1ad834c336 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -272,11 +272,14 @@ class StorageFlexModelSchema(Schema): required=False, load_default=1.0, metadata=dict( - description="Coefficient for this device within its coupling group. " - "For each pair of devices in the group the constraint " - "coeff_ref * P[d_ref] == coeff_i * P[d_i] is enforced at every time step, " - "where d_ref is the first device listed in the group. Defaults to 1.0.", - example=1.0, + description="Coupling coefficient for this device within its coupling group. " + "The optimizer introduces a decision variable 'alpha' per group per time step " + "and constrains every device by P[d] == coeff * alpha. " + "Sign convention: positive for input devices or stock-accumulating devices (positive ems_power); " + "negative for output/producing devices (negative ems_power). " + "Example: a CHP with gas input (coeff 1.0), heat output (coeff -0.5) and power output (coeff -0.3). " + "Defaults to 1.0.", + example=0.5, ), ) From ee33e279525e9b5ef483c146ac852ebdd506153a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 3 Jun 2026 22:54:17 +0200 Subject: [PATCH 10/26] feat: stop collapsing the heat buffer and steam node in the factory test Signed-off-by: F.N. Claessen --- .../models/planning/linear_optimization.py | 23 ++- .../models/planning/tests/test_commitments.py | 170 ++++++++++++------ 2 files changed, 130 insertions(+), 63 deletions(-) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 4721384e12..f462980ef1 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -111,21 +111,30 @@ def device_scheduler( # noqa C901 resolution = pd.to_timedelta(device_constraints[0].index.freq).to_pytimedelta() end = device_constraints[0].index.to_pydatetime()[-1] + resolution - # map device → stock group + # map device -> primary stock group (used for per-device stock bounds) + # and map stock group -> all member devices (used for stock accumulation). device_to_group = {} + group_to_devices = {} if stock_groups: for g, devices in stock_groups.items(): + group_to_devices[g] = list(devices) for d in devices: - device_to_group[d] = g - # For devices not in any stock group (e.g., inflexible devices), - # map them to themselves so they're treated as individual groups + # Keep first assignment as the primary group. A device can still + # participate in multiple groups via ``group_to_devices``. + if d not in device_to_group: + device_to_group[d] = g + # Devices not in any stock group are treated as single-device groups. for d in range(len(device_constraints)): if d not in device_to_group: - device_to_group[d] = d + g = f"_device_{d}" + device_to_group[d] = g + group_to_devices[g] = [d] else: for d in range(len(device_constraints)): - device_to_group[d] = d + g = f"_device_{d}" + device_to_group[d] = g + group_to_devices[g] = [d] # 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] @@ -569,7 +578,7 @@ def _get_stock_change(m, d, j): group = device_to_group[d] # all devices belonging to this stock - devices = [dev for dev, g in device_to_group.items() if g == group] + devices = group_to_devices[group] # initial stock if isinstance(initial_stock, list): diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 20fa6dab77..bd61ec16b4 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -1823,45 +1823,29 @@ def _run_factory_scenario( gas_price: float, elec_price: float, ) -> tuple: - """Run the simplified factory scenario and return the 6 device schedules. - - Layout - ------ - The model collapses the heat buffer (T) and steam node (P) into a single - shared heat buffer whose SoC is tracked by ``stock_groups``. The steam - demand is an exogenous drain modelled as ``stock_delta = -steam_demand`` on - the demand device (d=5). + """Run the simplified factory scenario and return the 7 device schedules. Devices ~~~~~~~ - d=0 e-heater electricity → heat buffer (ems_power ≥ 0) - d=1 gas boiler gas → heat buffer (ems_power ≥ 0) - d=2 CHP gas input consumes gas (ems_power ≥ 0, coupling ref) - d=3 CHP heat out heat → heat buffer (ems_power ≥ 0, coupling member) - d=4 CHP power out produces electricity (ems_power ≤ 0, coupling member) - d=5 steam demand fixed drain, no flow (ems_power = 0, stock_delta = -15) + d=0 e-heater electricity → heat coupling (ems_power ≥ 0, i.e. consumes electricity) + d=1 gas boiler gas → heat coupling (ems_power ≥ 0, i.e. consumes gas) + d=2 steamer heat coupling → steam (ems_power ≤ 0, i.e. produces steam) + d=3 CHP gas input gas → chp coupling (ems_power ≥ 0, i.e. consumes gas, coupling member = alpha) + d=4 CHP heat out chp coupling → steam (ems_power ≤ 0, i.e. produces steam, coupling member = -0.5 alpha) + d=5 CHP power out chp coupling → electricity (ems_power ≤ 0, i.e. produces electricity, coupling member = -0.3 alpha) + d=6 steam demand steam → fixed flow (ems_power = 15, i.e. consumes steam) CHP coupling coefficients ~~~~~~~~~~~~~~~~~~~~~~~~~ - The coupling constraint is built from the general pairwise equality:: - - coeff_ref * P[d_ref] == coeff_i * P[d_i] - - Choosing d_ref = 2 (gas input, coeff 1.0) and thermal efficiency η_heat = 0.5, - power efficiency η_power = 0.3:: - - P_heat = η_heat * P_gas = 0.5 * P_gas - P_power = -η_power * P_gas = -0.3 * P_gas - - Solving for the coupling coefficients:: + The coupling constraint introduces a free variable ``alpha`` (the normalised gas flow) + and enforces ``P[d_i] == coeff_i * alpha`` for every device in the group. + Choosing thermal efficiency η_heat = 0.5 and power efficiency η_power = 0.3, + the coefficients simply become the signed efficiency fractions:: - 1.0 * P_gas = coeff_heat * P_heat → coeff_heat = 1/η_heat = 2.0 - 1.0 * P_gas = coeff_power * P_power → coeff_power = -1/η_power = -10/3 + P_gas = 1.0 * alpha (input, coeff = 1.0) + P_heat = -0.5 * alpha (output, coeff = η_heat = -0.5) + P_power = -0.3 * alpha (output, coeff = η_power = −0.3) - Note: both d=2 and d=3 have *positive* ems_power (they both "consume" from - their respective commodity nodes, which causes the stock accumulation formula - to add a positive contribution to the heat buffer for d=3). d=4 has - *negative* ems_power (it produces electricity), so coeff_power is negative. """ ETA_HEAT = 0.5 # fraction of CHP gas input that becomes heat ETA_POWER = 0.3 # fraction of CHP gas input that becomes power @@ -1892,20 +1876,45 @@ def _df(**kwargs) -> pd.DataFrame: return pd.DataFrame(defaults, index=index) device_constraints = [ - # d=0 e-heater: heat-node reference device. min=max=0 forces the heat + # 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}), - # d=1 gas boiler: up to 100 kW gas → 100 kW heat (efficiency 1 for clean maths) - _df(**{"derivative max": BOILER_GAS_MAX}), - # d=2 CHP gas input: up to CHP_GAS_MAX kW gas - _df(**{"derivative max": CHP_GAS_MAX}), - # d=3 CHP heat output: positive ems_power adds heat to the buffer - _df(**{"derivative max": CHP_GAS_MAX * ETA_HEAT}), - # d=4 CHP power output: negative ems_power only (production) + # 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). + # The lower bound is finite to avoid unbounded model messages while still + # being looser than the upstream heat-supply limits. + _df( + **{ + "derivative min": -(HEATER_POWER_MAX + BOILER_GAS_MAX), + "derivative max": 0.0, + "commodity": "steam", + } + ), + # d=3 CHP gas input: up to CHP_GAS_MAX kW gas + _df(**{"derivative max": CHP_GAS_MAX, "commodity": "gas"}), + # 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, + **{ + "derivative min": -CHP_GAS_MAX * ETA_HEAT, + "derivative max": 0.0, + "commodity": "steam", + }, + ), + # d=5 CHP power output: negative ems_power only (production) _df(**{"derivative min": -CHP_GAS_MAX * ETA_POWER, "derivative max": 0.0}), - # d=5 steam demand: zero flow, constant stock drain of STEAM_DEMAND kW - _df(**{"stock delta": -STEAM_DEMAND}), + # d=6 steam demand: fixed steam consumption at STEAM_DEMAND kW. + _df( + **{ + "derivative min": STEAM_DEMAND, + "derivative max": STEAM_DEMAND, + "commodity": "steam", + } + ), ] ems_constraints = pd.DataFrame( @@ -1916,22 +1925,23 @@ def _df(**kwargs) -> pd.DataFrame: # 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 - stock_groups = {heat_group_id: [0, 1, 3, 5]} + steam_group_id = 1 + stock_groups = {heat_group_id: [0, 1, 2], steam_group_id: [2, 4, 6]} - # CHP coupling: gas_in (d=2) is the reference device - # coeff_heat = 1/η_heat = 2.0 → P_heat = 0.5 * P_gas - # coeff_power = -1/η_power = -10/3 → P_power = -0.3 * P_gas + # CHP coupling: coefficients are signed efficiency fractions. + # coeff_heat = -η_heat = -0.5 → P_heat = -0.5 * alpha = -0.5 * P_gas + # coeff_power = -η_power = -0.3 → P_power = -0.3 * alpha = -0.3 * P_gas coupling_groups = { "chp": [ - (2, 1.0), - (3, 1.0 / ETA_HEAT), # = 2.0 - (4, -1.0 / ETA_POWER), # = -10/3 + (3, 1.0), + (4, -ETA_HEAT), # = -0.5 + (5, -ETA_POWER), # = -0.3 ] } # --- energy-price commitments ------------------------------------------- - # Gas price applies to gas boiler (d=1) and CHP gas input (d=2). - # Electricity price applies to e-heater (d=0) and CHP power output (d=4). + # Gas price applies to gas boiler (d=1) and CHP gas input (d=3). + # Electricity price applies to e-heater (d=0) and CHP power output (d=5). # Using both upwards and downwards prices makes each commitment a two-sided # soft equality (quantity = 0): # • upward deviation = consuming more than 0 → positive cost @@ -1940,10 +1950,10 @@ def _df(**kwargs) -> pd.DataFrame: elec_p = pd.Series(elec_price, index=index) commitments = [] - for d, price in [(1, gas_p), (2, gas_p), (0, elec_p), (4, elec_p)]: + for d, price in [(1, gas_p), (3, gas_p), (0, elec_p), (5, elec_p)]: commitments.append( FlowCommitment( - name="gas cost" if d in (1, 2) else "electricity cost", + name="gas cost" if d in (1, 3) else "electricity cost", index=index, quantity=pd.Series(0.0, index=index), upwards_deviation_price=price, @@ -2020,14 +2030,16 @@ def test_factory_chp_dispatch(): # ------------------------------------------------------------------ # # Scenario A: gas cheaper — CHP at max, gas boiler fills the rest # # ------------------------------------------------------------------ # - (e_heater, gas_boiler, chp_gas, chp_heat, chp_power, _demand) = ( + (e_heater, gas_boiler, steamer, chp_gas, chp_heat, chp_power, demand) = ( _run_factory_scenario(gas_price=20.0, elec_price=50.0) ) expected_chp_gas = pd.Series(20.0, index=e_heater.index) - expected_chp_heat = pd.Series(10.0, index=e_heater.index) # 0.5 * 20 + expected_chp_heat = pd.Series(-10.0, index=e_heater.index) # -0.5 * 20 expected_chp_power = pd.Series(-6.0, index=e_heater.index) # -0.3 * 20 expected_boiler = pd.Series(5.0, index=e_heater.index) # fills 15-10 kW gap + expected_steamer = pd.Series(-5.0, index=e_heater.index) + expected_demand = pd.Series(15.0, index=e_heater.index) expected_eheater = pd.Series(0.0, index=e_heater.index) pd.testing.assert_series_equal( @@ -2058,6 +2070,20 @@ def test_factory_chp_dispatch(): rtol=1e-4, obj="Scenario A: gas boiler fills remaining 5 kW heat demand", ) + pd.testing.assert_series_equal( + steamer, + expected_steamer, + check_names=False, + rtol=1e-4, + obj="Scenario A: steamer supplies remaining 5 kW steam", + ) + pd.testing.assert_series_equal( + demand, + expected_demand, + check_names=False, + rtol=1e-4, + obj="Scenario A: steam demand fixed at 15 kW", + ) pd.testing.assert_series_equal( e_heater, expected_eheater, @@ -2069,12 +2095,14 @@ def test_factory_chp_dispatch(): # ------------------------------------------------------------------ # # Scenario B: electricity cheaper — e-heater meets all demand # # ------------------------------------------------------------------ # - (e_heater, gas_boiler, chp_gas, chp_heat, chp_power, _demand) = ( + (e_heater, gas_boiler, steamer, chp_gas, chp_heat, chp_power, demand) = ( _run_factory_scenario(gas_price=100.0, elec_price=10.0) ) expected_eheater_b = pd.Series(15.0, index=e_heater.index) expected_zero = pd.Series(0.0, index=e_heater.index) + expected_steamer_b = pd.Series(-15.0, index=e_heater.index) + expected_demand_b = pd.Series(15.0, index=e_heater.index) pd.testing.assert_series_equal( e_heater, @@ -2097,11 +2125,25 @@ def test_factory_chp_dispatch(): atol=1e-4, obj="Scenario B: gas boiler not used (electricity is cheapest)", ) + pd.testing.assert_series_equal( + steamer, + expected_steamer_b, + check_names=False, + rtol=1e-4, + obj="Scenario B: steamer supplies all 15 kW steam", + ) + pd.testing.assert_series_equal( + demand, + expected_demand_b, + check_names=False, + rtol=1e-4, + obj="Scenario B: steam demand fixed at 15 kW", + ) # --------------------------------------------------------------------------------- # # Scenario C: gas slightly cheaper — gas boiler at max, e-heater fills the rest # # --------------------------------------------------------------------------------- # - (e_heater, gas_boiler, chp_gas, chp_heat, chp_power, _demand) = ( + (e_heater, gas_boiler, steamer, chp_gas, chp_heat, chp_power, demand) = ( _run_factory_scenario(gas_price=50.0, elec_price=55.0) ) @@ -2109,6 +2151,8 @@ def test_factory_chp_dispatch(): expected_chp_heat = pd.Series(0.0, index=e_heater.index) expected_chp_power = pd.Series(0.0, index=e_heater.index) expected_boiler = pd.Series(10.0, index=e_heater.index) + expected_steamer = pd.Series(-15.0, index=e_heater.index) + expected_demand = pd.Series(15.0, index=e_heater.index) expected_eheater = pd.Series(5.0, index=e_heater.index) # fills 15-10 kW gap pd.testing.assert_series_equal( @@ -2139,6 +2183,20 @@ def test_factory_chp_dispatch(): rtol=1e-4, obj="Scenario C: gas boiler at maximum (10 kW)", ) + pd.testing.assert_series_equal( + steamer, + expected_steamer, + check_names=False, + rtol=1e-4, + obj="Scenario C: steamer supplies all 15 kW steam", + ) + pd.testing.assert_series_equal( + demand, + expected_demand, + check_names=False, + rtol=1e-4, + obj="Scenario C: steam demand fixed at 15 kW", + ) pd.testing.assert_series_equal( e_heater, expected_eheater, From 094cd5d1dc5deb00a1f1f9ae46eee01c68e59ccb Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 4 Jun 2026 00:34:19 +0200 Subject: [PATCH 11/26] 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 --- .../data/models/planning/tests/test_storage.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_storage.py b/flexmeasures/data/models/planning/tests/test_storage.py index 45331f3850..04236c2d9f 100644 --- a/flexmeasures/data/models/planning/tests/test_storage.py +++ b/flexmeasures/data/models/planning/tests/test_storage.py @@ -588,14 +588,14 @@ def test_storage_scheduler_chp_coupling(app, db): Models a Combined Heat and Power unit with three sensors: - - d=0 gas input: CHP gas consumption (positive ems_power, coeff 1.0) + - d=0 gas input: CHP gas consumption (positive ems_power, coeff 0.5) - d=1 heat output: CHP heat → heat buffer (positive ems_power, coeff 0.5) - d=2 power output: CHP electricity production (negative ems_power, coeff −0.3) The coupling group ``"chp"`` introduces a free variable ``alpha`` and enforces ``P[d] == coeff * alpha`` for every device: - P_gas = 1.0 * alpha + P_gas = 0.5 * alpha P_heat = 0.5 * alpha (η_heat = 0.5) P_power = −0.3 * alpha (η_power = 0.3) @@ -608,7 +608,7 @@ def test_storage_scheduler_chp_coupling(app, db): solution is P_heat = 5 kW every step. Substituting P_heat = 5 kW gives alpha = 5 / 0.5 = 10 kW, so: - P_gas = 1.0 × 10 kW = 10 kW + P_gas = 0.5 × 10 kW = 5 kW P_power = −0.3 × 10 kW = −3 kW """ # ---- asset type + asset @@ -659,7 +659,7 @@ def test_storage_scheduler_chp_coupling(app, db): "power-capacity": "20 kW", "production-capacity": "0 kW", # derivative_min = 0 "coupling": "chp", - "coupling-coefficient": 1.0, # reference: alpha = P_gas + "coupling-coefficient": 0.5, }, { # d=1: heat output — tracks heat-buffer SoC, positive ems_power = heat @@ -742,12 +742,12 @@ def test_storage_scheduler_chp_coupling(app, db): err_msg="Heat output should be exactly 5 kW per step (forced by SoC target)", ) - # Coupling: P_gas = 1.0 * alpha = 0.010 MW = 10 kW + # Coupling: P_gas = 0.5 * alpha = 0.005 MW = 5 kW np.testing.assert_allclose( gas_schedule.iloc[active_steps], - 0.010, # 10 kW expressed in MW + 0.005, # 5 kW expressed in MW rtol=1e-4, - err_msg="Gas input must be 10 kW — determined by coupling (1.0 * alpha)", + err_msg="Gas input must be 5 kW — determined by coupling (0.5 * alpha)", ) # Coupling: P_power = -ETA_POWER * alpha = -0.3 * 0.010 MW = -0.003 MW = -3 kW From d1193910b10d43cf915e2a5cfd152b8ce165316c Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 4 Jun 2026 00:36:04 +0200 Subject: [PATCH 12/26] 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 --- flexmeasures/data/models/planning/__init__.py | 56 ++++++++++++------- .../models/planning/tests/test_storage.py | 16 +++--- .../data/schemas/scheduling/storage.py | 8 +-- flexmeasures/ui/static/openapi-specs.json | 15 +++++ 4 files changed, 63 insertions(+), 32 deletions(-) diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py index bc29ed9470..3a55837fdf 100644 --- a/flexmeasures/data/models/planning/__init__.py +++ b/flexmeasures/data/models/planning/__init__.py @@ -110,44 +110,60 @@ def _build_coupling_groups( of each device model. Devices sharing the same coupling name form a coupling group. - The optimization model introduces a decision variable ``alpha`` per group per time step, - and constrains every device by ``P[d] == coeff_d * alpha``. + The optimization model introduces a decision variable ``alpha`` per group per time + step, and constrains every device by ``P[d] == coeff_d * alpha``. - - Input devices (consuming) are specified with positive coupling coefficients. - Their coefficients must sum up to 1. - - Output devices (producing) are specified with negative coupling coefficients. - Their coefficients do not need to sum up to -1; the remainder denotes a loss. + Coupling coefficients in flex-models are user-facing positive magnitudes. + The internal sign is inferred from directional capacities: + + - ``consumption_capacity == 0`` -> output device -> internally negative coefficient + - ``production_capacity == 0`` -> input device -> internally positive coefficient + + If neither direction is explicitly blocked, the coefficient stays positive. Example — a CHP with 50% heat efficiency and 30% power efficiency: [ - {"coupling": "chp", "coupling_coefficient": 1.0}, # gas input (alpha = P_gas) - {"coupling": "chp", "coupling_coefficient": -0.5}, # heat output (50% of gas becomes heat) - {"coupling": "chp", "coupling_coefficient": -0.3}, # power output (30% of gas becomes power) + {"coupling": "chp", "coupling_coefficient": 1.0}, # gas input (alpha = P_gas) + {"coupling": "chp", "coupling_coefficient": 0.5}, # heat output (50% of gas) + {"coupling": "chp", "coupling_coefficient": 0.3}, # power output (30% of gas) ] :param flex_model: List of deserialized device flex-model dicts. :returns: Mapping from coupling-group name to a list of - ``(device_index, coefficient)`` tuples suitable for passing to - ``device_scheduler(coupling_groups=...)``. Returns an empty dict + ``(device_index, internal_signed_coefficient)`` tuples suitable for + passing to ``device_scheduler(coupling_groups=...)``. Returns an empty dict when no device defines a ``coupling`` field. - :raises ValueError: if sum of positive coefficients in a group is not 1. """ + + def _is_zero_capacity(value: Any) -> bool: + """Return True if the capacity value is numerically zero.""" + + if value is None: + return False + + # Pint quantities expose ``magnitude``. + magnitude = getattr(value, "magnitude", value) + try: + return bool(np.isclose(float(magnitude), 0.0)) + except (TypeError, ValueError): + return False + groups: dict[str, list[tuple[int, float]]] = defaultdict(list) for d, fm in enumerate(flex_model): coupling_name = fm.get("coupling") if coupling_name is None: continue - coefficient = fm.get("coupling_coefficient", 1.0) + coefficient = abs(float(fm.get("coupling_coefficient", 1.0))) + + is_output = _is_zero_capacity(fm.get("consumption_capacity")) + is_input = _is_zero_capacity(fm.get("production_capacity")) + + if is_output and not is_input: + coefficient = -coefficient + groups[coupling_name].append((d, coefficient)) - # Check sum of positive coefficients - for group_name, devices in groups.items(): - positive_sum = sum(coeff for _, coeff in devices if coeff > 0) - if not abs(positive_sum - 1.0) < 1e-8: # tiny tolerance for floating point - raise ValueError( - f"Sum of positive coefficients in coupling group '{group_name}' is {positive_sum}, but must equal 1." - ) return dict(groups) def __init__( diff --git a/flexmeasures/data/models/planning/tests/test_storage.py b/flexmeasures/data/models/planning/tests/test_storage.py index 04236c2d9f..256756b2f6 100644 --- a/flexmeasures/data/models/planning/tests/test_storage.py +++ b/flexmeasures/data/models/planning/tests/test_storage.py @@ -588,14 +588,14 @@ def test_storage_scheduler_chp_coupling(app, db): Models a Combined Heat and Power unit with three sensors: - - d=0 gas input: CHP gas consumption (positive ems_power, coeff 0.5) + - d=0 gas input: CHP gas consumption (positive ems_power, coeff 1.0) - d=1 heat output: CHP heat → heat buffer (positive ems_power, coeff 0.5) - d=2 power output: CHP electricity production (negative ems_power, coeff −0.3) The coupling group ``"chp"`` introduces a free variable ``alpha`` and enforces ``P[d] == coeff * alpha`` for every device: - P_gas = 0.5 * alpha + P_gas = 1.0 * alpha P_heat = 0.5 * alpha (η_heat = 0.5) P_power = −0.3 * alpha (η_power = 0.3) @@ -608,7 +608,7 @@ def test_storage_scheduler_chp_coupling(app, db): solution is P_heat = 5 kW every step. Substituting P_heat = 5 kW gives alpha = 5 / 0.5 = 10 kW, so: - P_gas = 0.5 × 10 kW = 5 kW + P_gas = 1.0 × 10 kW = 10 kW P_power = −0.3 × 10 kW = −3 kW """ # ---- asset type + asset @@ -659,7 +659,7 @@ def test_storage_scheduler_chp_coupling(app, db): "power-capacity": "20 kW", "production-capacity": "0 kW", # derivative_min = 0 "coupling": "chp", - "coupling-coefficient": 0.5, + "coupling-coefficient": 1.0, }, { # d=1: heat output — tracks heat-buffer SoC, positive ems_power = heat @@ -692,7 +692,7 @@ def test_storage_scheduler_chp_coupling(app, db): "power-capacity": "6 kW", "consumption-capacity": "0 kW", # derivative_max = 0 "coupling": "chp", - "coupling-coefficient": -ETA_POWER, # = -0.3 + "coupling-coefficient": ETA_POWER, # = 0.3 (sign inferred from capacities) }, ] @@ -742,12 +742,12 @@ def test_storage_scheduler_chp_coupling(app, db): err_msg="Heat output should be exactly 5 kW per step (forced by SoC target)", ) - # Coupling: P_gas = 0.5 * alpha = 0.005 MW = 5 kW + # Coupling: P_gas = 1.0 * alpha = 0.010 MW = 10 kW np.testing.assert_allclose( gas_schedule.iloc[active_steps], - 0.005, # 5 kW expressed in MW + 0.010, # 10 kW expressed in MW rtol=1e-4, - err_msg="Gas input must be 5 kW — determined by coupling (0.5 * alpha)", + err_msg="Gas input must be 10 kW — determined by coupling (1.0 * alpha)", ) # Coupling: P_power = -ETA_POWER * alpha = -0.3 * 0.010 MW = -0.003 MW = -3 kW diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 1ad834c336..edc2b2e650 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -272,12 +272,12 @@ class StorageFlexModelSchema(Schema): required=False, load_default=1.0, metadata=dict( - description="Coupling coefficient for this device within its coupling group. " + description="Positive coupling magnitude for this device within its coupling group. " "The optimizer introduces a decision variable 'alpha' per group per time step " "and constrains every device by P[d] == coeff * alpha. " - "Sign convention: positive for input devices or stock-accumulating devices (positive ems_power); " - "negative for output/producing devices (negative ems_power). " - "Example: a CHP with gas input (coeff 1.0), heat output (coeff -0.5) and power output (coeff -0.3). " + "The sign of coeff is inferred internally from directional capacities: " + "consumption-capacity = 0 implies output (negative), production-capacity = 0 implies input (positive). " + "Example: a CHP with gas input (1.0), heat output (0.5) and power output (0.3). " "Defaults to 1.0.", example=0.5, ), diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 4f5c2fb73b..881c31c845 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -6329,6 +6329,21 @@ ], "description": "Commodity label for this device/asset." }, + "coupling": { + "type": [ + "string", + "null" + ], + "default": null, + "description": "Name of the coupling group this device belongs to. Devices sharing the same coupling name are constrained to have proportionally related flows via a hard equality constraint. Use together with 'coupling-coefficient' to set the ratio.", + "example": "chp" + }, + "coupling-coefficient": { + "type": "number", + "default": 1.0, + "description": "Positive coupling magnitude for this device within its coupling group. The optimizer introduces a decision variable 'alpha' per group per time step and constrains every device by P[d] == coeff * alpha. The sign of coeff is inferred internally from directional capacities: consumption-capacity = 0 implies output (negative), production-capacity = 0 implies input (positive). Example: a CHP with gas input (1.0), heat output (0.5) and power output (0.3). Defaults to 1.0.", + "example": 0.5 + }, "sensor": { "type": "integer", "description": "ID of the device's power sensor." From a86f6a75030c99979585d2aebfa372fddc7ff088 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 4 Jun 2026 00:39:32 +0200 Subject: [PATCH 13/26] 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 --- .../models/planning/tests/test_storage.py | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_storage.py b/flexmeasures/data/models/planning/tests/test_storage.py index 256756b2f6..39c337acca 100644 --- a/flexmeasures/data/models/planning/tests/test_storage.py +++ b/flexmeasures/data/models/planning/tests/test_storage.py @@ -586,18 +586,26 @@ def test_resolve_soc_at_start_from_percent_sensor_uses_device_sensor_fallback( def test_storage_scheduler_chp_coupling(app, db): """Test that the StorageScheduler enforces CHP coupling constraints between devices. - Models a Combined Heat and Power unit with three sensors: + Models a Combined Heat and Power unit with three sensors. - - d=0 gas input: CHP gas consumption (positive ems_power, coeff 1.0) - - d=1 heat output: CHP heat → heat buffer (positive ems_power, coeff 0.5) - - d=2 power output: CHP electricity production (negative ems_power, coeff −0.3) + In the flex-model, the coupling coefficients are entered as positive magnitudes:: - The coupling group ``"chp"`` introduces a free variable ``alpha`` and enforces - ``P[d] == coeff * alpha`` for every device: + gas input -> 1.0 + heat output -> 0.5 + power output -> 0.3 - P_gas = 1.0 * alpha - P_heat = 0.5 * alpha (η_heat = 0.5) - P_power = −0.3 * alpha (η_power = 0.3) + Internally, the CHP is interpreted with the signed commodity-flow coefficients:: + + P_gas -> 1.0 + P_heat -> -0.5 + P_power -> -0.3 + + The returned storage schedule for the heat buffer is still positive, because this + test uses the storage sign convention for buffer charging. + + - d=0 gas input: CHP gas consumption + - d=1 heat output: CHP heat -> heat buffer + - d=2 power output: CHP electricity production The heat output is forced to exactly 5 kW per step by combining: - ``production-capacity: "0 kW"`` (hard lower bound: derivative_min = 0) @@ -649,9 +657,9 @@ def test_storage_scheduler_chp_coupling(app, db): db.session.flush() # ---- flex model - # Coupling-coefficients equal the signed efficiency fractions. - # Positive coeff = input or stock-accumulating device (positive ems_power). - # Negative coeff = production device (negative ems_power). + # Flex-model coupling-coefficients are user-facing positive magnitudes. + # The intended internal CHP coefficients are +1.0 for gas, -0.5 for heat, + # and -0.3 for power. flex_model = [ { # d=0: gas input — pure flow device (no SoC), can only consume gas. From bc6a88f6696d334321aedcc641825652e690fd78 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Jun 2026 17:34:16 +0200 Subject: [PATCH 14/26] chore: black Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 6e02f56ea6..1067c8eb16 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -342,7 +342,12 @@ def device_list_series( number_flexible_devices = len(flex_model) number_inflexible_devices = len(self.flex_context["inflexible_device_sensors"]) num_flexible_devices = len(flex_model) - commodity_to_devices["electricity"] += list(range(number_flexible_devices, number_flexible_devices + number_inflexible_devices)) + commodity_to_devices["electricity"] += list( + range( + number_flexible_devices, + number_flexible_devices + number_inflexible_devices, + ) + ) commodity_contexts = self._get_commodity_contexts() price_frames_by_commodity = {} From 229349d00c572be3fd50cb41ca620ad3cb2cae4a Mon Sep 17 00:00:00 2001 From: Ahmad Wahid <59763365+Ahmad-Wahid@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:13:10 +0200 Subject: [PATCH 15/26] fix: keep ems-constraints and fix the test cases (#2233) * fix: keep ems-constraints and fix the test cases Signed-off-by: Ahmad-Wahid * 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 --------- Signed-off-by: Ahmad-Wahid Signed-off-by: Ahmad Wahid <59763365+Ahmad-Wahid@users.noreply.github.com> Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com> --- .../models/planning/linear_optimization.py | 59 +++++++++++++++---- flexmeasures/data/models/planning/storage.py | 43 ++++++++++---- .../models/planning/tests/test_commitments.py | 15 ++++- .../data/models/planning/tests/test_solver.py | 38 ++++++------ .../data/tests/test_scheduling_sequential.py | 2 +- .../tests/test_scheduling_simultaneous.py | 4 +- 6 files changed, 117 insertions(+), 44 deletions(-) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index f462980ef1..02ef111d4e 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -35,7 +35,7 @@ def device_scheduler( # noqa C901 device_constraints: list[pd.DataFrame], - ems_constraints: pd.DataFrame, + ems_constraints: pd.DataFrame | list[pd.DataFrame], commitment_quantities: list[pd.Series] | None = None, commitment_downwards_deviation_price: list[pd.Series] | list[float] | None = None, commitment_upwards_deviation_price: list[pd.Series] | list[float] | None = None, @@ -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, + 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, with various types of constraints on the EMS level and on the device level, @@ -65,6 +66,13 @@ def device_scheduler( # noqa C901 :param ems_constraints: EMS constraints are on an EMS level. Handled constraints (listed by column name): derivative max: maximum flow derivative min: minimum flow + May be a single DataFrame (the constraint is applied to the summed flow of all devices), + or a list of DataFrames (one per device group). In the latter case, ``ems_constraint_groups`` + lists the device indices each DataFrame applies to. The StorageScheduler uses one device + group per commodity, so each commodity gets its own EMS-level capacity constraint. + :param ems_constraint_groups: For each EMS constraint DataFrame, the list of device indices it applies to. When omitted, + each EMS constraint is applied to the summed flow of all devices (legacy behaviour). A device + may appear in more than one group. :param commitments: Commitments are on an EMS level by default. Handled parameters (listed by column name): quantity: for example, 5.5 downwards deviation price: 10.1 @@ -111,6 +119,23 @@ def device_scheduler( # noqa C901 resolution = pd.to_timedelta(device_constraints[0].index.freq).to_pytimedelta() end = device_constraints[0].index.to_pydatetime()[-1] + resolution + # Normalise EMS constraints to a list of (DataFrame, device-group) pairs. + # A single DataFrame (legacy behaviour) applies to the summed flow of all devices; + # a list of DataFrames applies one EMS-level constraint per device group, as set up + # per commodity by the StorageScheduler. + all_devices = list(range(len(device_constraints))) + if isinstance(ems_constraints, pd.DataFrame): + ems_constraints_list = [ems_constraints] + ems_constraint_device_groups = [all_devices] + else: + ems_constraints_list = list(ems_constraints) + if ems_constraint_groups is None: + raise ValueError( + "When passing multiple EMS constraint DataFrames, you must also specify ems_constraint_groups." + ) + else: + ems_constraint_device_groups = ems_constraint_groups + # map device -> primary stock group (used for per-device stock bounds) # and map stock group -> all member devices (used for stock accumulation). device_to_group = {} @@ -417,15 +442,15 @@ def device_derivative_min_select(m, d, j): else: return np.nanmax([min_v, equal_v]) - def ems_derivative_max_select(m, j): - v = ems_constraints["derivative max"].iloc[j] + def ems_derivative_max_select(m, g, j): + v = ems_constraints_list[g]["derivative max"].iloc[j] if np.isnan(v): return infinity else: return v - def ems_derivative_min_select(m, j): - v = ems_constraints["derivative min"].iloc[j] + def ems_derivative_min_select(m, g, j): + v = ems_constraints_list[g]["derivative min"].iloc[j] if np.isnan(v): return -infinity else: @@ -511,8 +536,15 @@ def grouped_commitment_equalities(m, c, j, g): model.device_derivative_min = Param( model.d, model.j, initialize=device_derivative_min_select ) - model.ems_derivative_max = Param(model.j, initialize=ems_derivative_max_select) - model.ems_derivative_min = Param(model.j, initialize=ems_derivative_min_select) + model.eg = RangeSet( + 0, len(ems_constraints_list) - 1, doc="Set of EMS constraint (device) groups" + ) + model.ems_derivative_max = Param( + model.eg, model.j, initialize=ems_derivative_max_select + ) + model.ems_derivative_min = Param( + model.eg, model.j, initialize=ems_derivative_min_select + ) model.device_efficiency = Param(model.d, model.j, initialize=device_efficiency) model.device_derivative_down_efficiency = Param( model.d, model.j, initialize=device_derivative_down_efficiency @@ -656,8 +688,15 @@ def device_down_derivative_sign(m, d, j): """Derivative down if sign points down, derivative not down if sign points up.""" return -m.device_power_down[d, j] <= Md * (1 - m.device_power_sign[d, j]) - def ems_derivative_bounds(m, j): - return m.ems_derivative_min[j], sum(m.ems_power[:, j]), m.ems_derivative_max[j] + def ems_derivative_bounds(m, g, j): + devices = ems_constraint_device_groups[g] + if not devices: + return Constraint.Skip + return ( + m.ems_derivative_min[g, j], + sum(m.ems_power[d, j] for d in devices), + m.ems_derivative_max[g, j], + ) def commitment_up_derivative_sign(m, c): """Up deviation active only if sign points up.""" @@ -750,7 +789,7 @@ def device_derivative_equalities(m, d, j): model.device_power_down_sign = Constraint( model.d, model.j, rule=device_down_derivative_sign ) - model.ems_power_bounds = Constraint(model.j, rule=ems_derivative_bounds) + model.ems_power_bounds = Constraint(model.eg, model.j, rule=ems_derivative_bounds) if not convex_cost_curve: model.commitment_up_derivative_sign_con = Constraint( model.c, rule=commitment_up_derivative_sign diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index d5349db2da..8ea2f06f57 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -315,18 +315,19 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 index = initialize_index(start, end, resolution) commitment_quantities = initialize_series(0, start, end, resolution) - # Keep EMS constraints global only. + # EMS constraints are kept per commodity (one device group per commodity). # - # Important: - # Do NOT put commodity-specific site-consumption-capacity or - # site-production-capacity into ems_constraints, because these constraints - # are applied to the sum of all devices in device_scheduler. + # The site-power / site-consumption / site-production capacities + # are enforced as hard EMS-level constraints (derivative max/min). Because each + # commodity has its own set of devices, ``ems_constraints`` is a list of + # DataFrames and ``ems_constraint_groups`` lists the device indices each + # DataFrame applies to. The device_scheduler then bounds the summed flow of each + # commodity's devices separately (instead of summing across all commodities). # - # Commodity-specific capacities are modelled below as FlowCommitments - # with device=commodity_devices. - ems_constraints = initialize_df( - StorageScheduler.COLUMNS, start, end, resolution - ) + # The commodity-specific breach/peak penalties below remain modelled as + # FlowCommitments on top of these hard constraints. + ems_constraints: list[pd.DataFrame] = [] + ems_constraint_groups: list[list[int]] = [] def device_list_series( devices: list[int], index: pd.DatetimeIndex @@ -650,6 +651,25 @@ def device_list_series( ) ) + # Hard EMS-level capacity constraint for this commodity's device group. + # If a breach price is set, the physical power capacity is the + # hard limit (the contracted capacity is then only softly penalised via the + # breach commitments above); otherwise the contracted capacity itself is the + # hard limit. + commodity_ems_constraints = initialize_df( + StorageScheduler.COLUMNS, start, end, resolution + ) + if ems_consumption_breach_price is not None: + commodity_ems_constraints["derivative max"] = ems_power_capacity + else: + commodity_ems_constraints["derivative max"] = ems_consumption_capacity + if ems_production_breach_price is not None: + commodity_ems_constraints["derivative min"] = -ems_power_capacity + else: + commodity_ems_constraints["derivative min"] = ems_production_capacity + ems_constraints.append(commodity_ems_constraints) + ems_constraint_groups.append(list(devices)) + # Keep one price frame for later preference logic. # The existing "prefer charging sooner" code uses `up_deviation_prices`. # Prefer electricity prices if available, otherwise use the first commodity price. @@ -1231,6 +1251,8 @@ def device_list_series( # Store original stock_deltas for use in _build_soc_schedule self.original_stock_deltas = original_stock_deltas + # Device indices each EMS constraint DataFrame applies to (one group per commodity). + self.ems_constraint_groups = ems_constraint_groups return ( sensors, start, @@ -2103,6 +2125,7 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: ems_schedule, expected_costs, scheduler_results, model = device_scheduler( device_constraints=device_constraints, ems_constraints=ems_constraints, + ems_constraint_groups=self.ems_constraint_groups, commitments=commitments, initial_stock=initial_stock, stock_groups=self.stock_groups, diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index bd61ec16b4..72c307b669 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -764,9 +764,18 @@ def test_mixed_gas_and_electricity_assets(app, db): ] flex_context = { - "consumption-price": "100 EUR/MWh", # electricity price - "production-price": "100 EUR/MWh", - "gas-price": "50 EUR/MWh", # gas price + "commodities": [ + { + "commodity": "electricity", + "consumption-price": "100 EUR/MWh", # electricity price + "production-price": "100 EUR/MWh", + }, + { + "commodity": "gas", + "consumption-price": "50 EUR/MWh", # gas price + "production-price": "50 EUR/MWh", + }, + ] } scheduler = StorageScheduler( diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 59ed27d811..c727d357e2 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -759,21 +759,16 @@ def test_building_solver_day_2( soc_max, ur.Quantity(battery.get_attribute("soc-max")).to("MWh").magnitude ) - if market_scenario == "dynamic contract": - # Result after 8 hours: Sell what you begin with (high prices drive full discharge) - assert soc_schedule.loc[start + timedelta(hours=8)] == soc_min_value - # Result after second 8 hour-interval: Buy as much as possible (low prices) - assert soc_schedule.loc[start + timedelta(hours=16)] == soc_max_value - # Result at end of day: Sold out at end of planning horizon - assert soc_schedule.iloc[-1] == soc_min_value - else: - # fixed contract: inflexible devices are not included in the energy commitment - # coupling under the new multi-commodity scheduler, so price-based scheduling - # drives the result independently of the inflexible load profile. - # The battery partially discharges early and fully discharges near end of day. - assert soc_schedule.loc[start + timedelta(hours=8)] == 2.0 - assert soc_schedule.loc[start + timedelta(hours=16)] == 2.0 - assert soc_schedule.iloc[-1] == soc_min_value + # In both scenarios the battery should fully discharge in the first 8 hours, + # fully charge in the next 8, and fully discharge again in the last 8 (driven by + # 1) the dynamic price profile, or 2) the net-consumption/net-production profile of + # the inflexible devices, which are part of the electricity commodity device group). + # Result after 8 hours: discharged as far as possible. + assert soc_schedule.loc[start + timedelta(hours=8)] == soc_min_value + # Result after second 8 hour-interval: charged as far as possible. + assert soc_schedule.loc[start + timedelta(hours=16)] == soc_max_value + # Result at end of day: discharged as far as possible. + assert soc_schedule.iloc[-1] == soc_min_value def test_soc_bounds_timeseries(db, add_battery_assets): @@ -1388,8 +1383,14 @@ def set_if_not_none(dictionary, key, value): assert all(device_constraints[0]["derivative min"] == -expected_capacity) assert all(device_constraints[0]["derivative max"] == expected_capacity) - assert all(ems_constraints["derivative min"] == expected_site_production_capacity) - assert all(ems_constraints["derivative max"] == expected_site_consumption_capacity) + # EMS constraints are kept per commodity; this single-battery case has only the + # default "electricity" commodity, so its constraints are in ems_constraints[0]. + assert all( + ems_constraints[0]["derivative min"] == expected_site_production_capacity + ) + assert all( + ems_constraints[0]["derivative max"] == expected_site_consumption_capacity + ) @pytest.mark.parametrize( @@ -1669,7 +1670,8 @@ def test_battery_power_capacity_as_sensor( data_to_solver = scheduler._prepare() device_constraints = data_to_solver[5][0] - ems_constraints = data_to_solver[6] + # EMS constraints are kept per commodity; index [0] selects the "electricity" group. + ems_constraints = data_to_solver[6][0] assert all(device_constraints["derivative min"].values == expected_production) assert all(device_constraints["derivative max"].values == expected_consumption) diff --git a/flexmeasures/data/tests/test_scheduling_sequential.py b/flexmeasures/data/tests/test_scheduling_sequential.py index ca4b0e674c..68d5689f2d 100644 --- a/flexmeasures/data/tests/test_scheduling_sequential.py +++ b/flexmeasures/data/tests/test_scheduling_sequential.py @@ -137,7 +137,7 @@ def test_create_sequential_jobs(db, app, flex_description_sequential, smart_buil # Assert costs expected_ev_costs = 2.2375 - expected_battery_costs = -7.565 + expected_battery_costs = -4.415 assert ( ev_costs == expected_ev_costs ), f"EV cost should be {expected_ev_costs} €, got {ev_costs} €" diff --git a/flexmeasures/data/tests/test_scheduling_simultaneous.py b/flexmeasures/data/tests/test_scheduling_simultaneous.py index 0594420e5e..b5469d0e6b 100644 --- a/flexmeasures/data/tests/test_scheduling_simultaneous.py +++ b/flexmeasures/data/tests/test_scheduling_simultaneous.py @@ -133,8 +133,8 @@ def test_create_simultaneous_jobs( total_cost = ev_costs + battery_costs # Define expected costs based on resolution - expected_total_cost = -5.7025 - expected_ev_costs = 2.2375 + expected_total_cost = -3.2775 + expected_ev_costs = 2.3125 expected_battery_costs = expected_total_cost - expected_ev_costs # Check costs From afa5eb580e01c9564f7f2f3e2b6fb20531b73327 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 13:15:47 +0200 Subject: [PATCH 16/26] feat: list the commodity field first rather than last Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/scheduling/__init__.py | 10 ++++++++++ flexmeasures/ui/static/openapi-specs.json | 16 ++++++++-------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 9b39a4ceab..1cf0b9e698 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -1,4 +1,6 @@ from __future__ import annotations + +from collections import OrderedDict from datetime import timedelta from typing import Any, Callable, Dict @@ -253,6 +255,14 @@ class CommodityFlexContextSchema(SharedSchema): metadata=metadata.COMMODITY.to_dict(), ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + commodity_field = self.fields.pop("commodity") + self.fields = OrderedDict( + [("commodity", commodity_field), *self.fields.items()] + ) + class FlexContextSchema(SharedSchema): """This schema defines fields that provide context to the portfolio to be optimized.""" diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 9a16e7c31c..5445d3901d 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4559,6 +4559,14 @@ "CommodityFlexContext": { "type": "object", "properties": { + "commodity": { + "type": "string", + "description": "Commodity type for this storage flex-model.\nDefaults to ``electricity``.\n", + "examples": [ + "electricity", + "gas" + ] + }, "consumption-price": { "description": "The electricity price applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem. [#old_consumption_price_field]_", "examples": [ @@ -4632,14 +4640,6 @@ "items": { "type": "integer" } - }, - "commodity": { - "type": "string", - "description": "Commodity type for this storage flex-model.\nDefaults to ``electricity``.\n", - "examples": [ - "electricity", - "gas" - ] } }, "required": [ From bf22eb818c224d9e34cd76961ff1f682ab02053e Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 13:25:32 +0200 Subject: [PATCH 17/26] feat: commodity is a field in both flex-model and flex-context Signed-off-by: F.N. Claessen --- documentation/features/scheduling.rst | 7 +++++-- flexmeasures/data/schemas/scheduling/__init__.py | 4 ++-- flexmeasures/data/schemas/scheduling/metadata.py | 12 +++++++++--- flexmeasures/data/schemas/scheduling/storage.py | 2 +- flexmeasures/ui/static/openapi-specs.json | 8 ++++++-- 5 files changed, 23 insertions(+), 10 deletions(-) diff --git a/documentation/features/scheduling.rst b/documentation/features/scheduling.rst index 4cc319c7fd..3042c3b3ea 100644 --- a/documentation/features/scheduling.rst +++ b/documentation/features/scheduling.rst @@ -58,6 +58,9 @@ And if the asset belongs to a larger system (a hierarchy of assets), the schedul * - Field - Example value - Description + * - ``commodity`` + - |COMMODITY_FLEX_CONTEXT.example| + - .. include:: ../_autodoc/COMMODITY_FLEX_CONTEXT.rst * - ``inflexible-device-sensors`` - |INFLEXIBLE_DEVICE_SENSORS.example| - .. include:: ../_autodoc/INFLEXIBLE_DEVICE_SENSORS.rst @@ -187,8 +190,8 @@ For more details on the possible formats for field values, see :ref:`variable_qu - Example value - Description * - ``commodity`` - - |COMMODITY.example| - - .. include:: ../_autodoc/COMMODITY.rst + - |COMMODITY_FLEX_MODEL.example| + - .. include:: ../_autodoc/COMMODITY_FLEX_MODEL.rst * - ``consumption`` - |CONSUMPTION.example| - .. include:: ../_autodoc/CONSUMPTION.rst diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 1cf0b9e698..b5bb9d8540 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -252,7 +252,7 @@ class CommodityFlexContextSchema(SharedSchema): commodity = fields.Str( required=True, data_key="commodity", - metadata=metadata.COMMODITY.to_dict(), + metadata=metadata.COMMODITY_FLEX_CONTEXT.to_dict(), ) def __init__(self, *args, **kwargs): @@ -795,7 +795,7 @@ def _to_currency_per_mwh(price_unit: str) -> str: }, "commodity": { "default": "electricity", - "description": rst_to_openapi(metadata.COMMODITY.description), + "description": rst_to_openapi(metadata.COMMODITY_FLEX_MODEL.description), "types": { "backend": "typeOne", "ui": "One fixed value only.", diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index 70645d98d0..3b97899926 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -27,6 +27,12 @@ def to_dict(self): # FLEX-CONTEXT +COMMODITY_FLEX_CONTEXT = MetaData( + description="""Commodity to which this part of the flex-context applies. +Defaults to ``"electricity"``. +""", + examples=["electricity", "gas"], +) INFLEXIBLE_DEVICE_SENSORS = MetaData( description="""Power sensors representing devices that are relevant, but not flexible in the timing of their demand/supply. For example, a sensor recording rooftop solar power that is connected behind the main meter, and whose production falls under the same contract as the flexible device(s) being scheduled. @@ -183,9 +189,9 @@ def to_dict(self): # FLEX-MODEL -COMMODITY = MetaData( - description="""Commodity type for this storage flex-model. -Defaults to ``electricity``. +COMMODITY_FLEX_MODEL = MetaData( + description="""Commodity on which this device acts. +Defaults to ``"electricity"``. """, examples=["electricity", "gas"], ) diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index edc2b2e650..d5dfd1f151 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -252,7 +252,7 @@ class StorageFlexModelSchema(Schema): data_key="commodity", load_default="electricity", validate=OneOf(["electricity", "gas"]), - metadata=dict(description="Commodity label for this device/asset."), + metadata=metadata.COMMODITY_FLEX_MODEL.to_dict(), ) coupling = fields.Str( diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 5445d3901d..d34f2e4a61 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4561,7 +4561,7 @@ "properties": { "commodity": { "type": "string", - "description": "Commodity type for this storage flex-model.\nDefaults to ``electricity``.\n", + "description": "Commodity to which this part of the flex-context applies.\nDefaults to ``\"electricity\"``.\n", "examples": [ "electricity", "gas" @@ -6327,7 +6327,11 @@ "electricity", "gas" ], - "description": "Commodity label for this device/asset." + "description": "Commodity on which this device acts.\nDefaults to \"electricity\".\n", + "examples": [ + "electricity", + "gas" + ] }, "coupling": { "type": [ From 710bf22e60d4bc96888347d961c110e492171afe Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 13:26:15 +0200 Subject: [PATCH 18/26] feat: flex-model commodity can also be more than just electricity and gas Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/scheduling/storage.py | 1 - flexmeasures/ui/static/openapi-specs.json | 4 ---- 2 files changed, 5 deletions(-) diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index d5dfd1f151..7c29e607b7 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -251,7 +251,6 @@ class StorageFlexModelSchema(Schema): commodity = fields.Str( data_key="commodity", load_default="electricity", - validate=OneOf(["electricity", "gas"]), metadata=metadata.COMMODITY_FLEX_MODEL.to_dict(), ) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index d34f2e4a61..8c64698154 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -6323,10 +6323,6 @@ "commodity": { "type": "string", "default": "electricity", - "enum": [ - "electricity", - "gas" - ], "description": "Commodity on which this device acts.\nDefaults to \"electricity\".\n", "examples": [ "electricity", From 410bb4c95338849c8f20e842ca497c92261ffa78 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 13:31:16 +0200 Subject: [PATCH 19/26] docs: remove mention of gas-price field Signed-off-by: F.N. Claessen --- documentation/features/scheduling.rst | 3 --- 1 file changed, 3 deletions(-) diff --git a/documentation/features/scheduling.rst b/documentation/features/scheduling.rst index 3042c3b3ea..c4c0e271d2 100644 --- a/documentation/features/scheduling.rst +++ b/documentation/features/scheduling.rst @@ -73,9 +73,6 @@ And if the asset belongs to a larger system (a hierarchy of assets), the schedul * - ``production-price`` - |PRODUCTION_PRICE.example| - .. include:: ../_autodoc/PRODUCTION_PRICE.rst - * - ``gas-price`` - - |GAS_PRICE.example| - - .. include:: ../_autodoc/GAS_PRICE.rst * - ``site-power-capacity`` - |SITE_POWER_CAPACITY.example| - .. include:: ../_autodoc/SITE_POWER_CAPACITY.rst From e994767970249da543b8d3bdb22e4c562e82baca Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 13:33:27 +0200 Subject: [PATCH 20/26] docs: adjust scheduling section for multi-commodity Signed-off-by: F.N. Claessen --- documentation/features/scheduling.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/features/scheduling.rst b/documentation/features/scheduling.rst index c4c0e271d2..aacfa2db11 100644 --- a/documentation/features/scheduling.rst +++ b/documentation/features/scheduling.rst @@ -5,8 +5,8 @@ Scheduling Scheduling is the main value-drive of FlexMeasures. We have two major types of schedulers built-in, for storage devices (usually batteries or hot water storage) and processes (usually in industry). -FlexMeasures computes schedules for energy systems that consist of multiple devices that consume and/or produce electricity. -We model a device as an asset with a power sensor, and compute schedules only for flexible devices, while taking into account inflexible devices. +FlexMeasures computes schedules for energy systems that consist of multiple devices that consume and/or produce a commodity (e.g. electricity or gas). +We model a device as an asset with a consumption/production sensor recording power values, and compute schedules only for flexible devices, while taking into account inflexible devices. .. contents:: :local: From 12ff212507ce40964ade46446a60c71c52be6fc5 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 13:35:23 +0200 Subject: [PATCH 21/26] docs: adjust field descriptions for multi-commodity Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/scheduling/metadata.py | 8 ++++---- flexmeasures/ui/static/openapi-specs.json | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index 3b97899926..7463579864 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -50,11 +50,11 @@ def to_dict(self): example=[], ) CONSUMPTION_PRICE = MetaData( - description="The electricity price applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO₂ intensity—whatever fits your optimization problem. [#old_consumption_price_field]_", + description="The commodity price (e.g. electricity price) applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO₂ intensity—whatever fits your optimization problem. [#old_consumption_price_field]_", examples=[{"sensor": 5}, "0.29 EUR/kWh"], ) PRODUCTION_PRICE = MetaData( - description="The electricity price applied to the site's aggregate production. Can be (a sensor recording) market prices, but also CO₂ intensity—whatever fits your optimization problem, as long as the unit matches the ``consumption-price`` unit. [#old_production_price_field]_", + description="The commodity price (e.g. electricity price) applied to the site's aggregate production. Can be (a sensor recording) market prices, but also CO₂ intensity—whatever fits your optimization problem, as long as the unit matches the ``consumption-price`` unit. [#old_production_price_field]_", example="0.12 EUR/kWh", ) SITE_POWER_CAPACITY = MetaData( @@ -309,14 +309,14 @@ def to_dict(self): example="90%", ) CHARGING_EFFICIENCY = MetaData( - description="""One-way conversion efficiency from electricity to the storage's state of charge. + description="""One-way conversion efficiency from the commodity (e.g. electricity) to the storage's state of charge. Can be a percentage, a ratio in the range [0,1], or a coefficient of performance (>1). Defaults to 100% (no conversion loss). """, example=".9", ) DISCHARGING_EFFICIENCY = MetaData( - description="""One-way conversion efficiency from the storage's state of charge to electricity. + description="""One-way conversion efficiency from the storage's state of charge to the commodity (e.g. electricity). Defaults to 100% (no conversion loss).""", example="90%", ) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 8c64698154..e222eb3127 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4568,7 +4568,7 @@ ] }, "consumption-price": { - "description": "The electricity price applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem. [#old_consumption_price_field]_", + "description": "The commodity price (e.g. electricity price) applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem. [#old_consumption_price_field]_", "examples": [ { "sensor": 5 @@ -4577,7 +4577,7 @@ ] }, "production-price": { - "description": "The electricity price applied to the site's aggregate production. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem, as long as the unit matches the ``consumption-price`` unit. [#old_production_price_field]_", + "description": "The commodity price (e.g. electricity price) applied to the site's aggregate production. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem, as long as the unit matches the ``consumption-price`` unit. [#old_production_price_field]_", "example": "0.12 EUR/kWh" }, "site-power-capacity": { @@ -4651,7 +4651,7 @@ "type": "object", "properties": { "consumption-price": { - "description": "The electricity price applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem.", + "description": "The commodity price (e.g. electricity price) applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem.", "examples": [ { "sensor": 5 @@ -4661,7 +4661,7 @@ "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, "production-price": { - "description": "The electricity price applied to the site's aggregate production. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem, as long as the unit matches the consumption-price unit.", + "description": "The commodity price (e.g. electricity price) applied to the site's aggregate production. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem, as long as the unit matches the consumption-price unit.", "example": "0.12 EUR/kWh", "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, @@ -6275,12 +6275,12 @@ "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, "charging-efficiency": { - "description": "One-way conversion efficiency from electricity to the storage's state of charge.\nCan be a percentage, a ratio in the range [0,1], or a coefficient of performance (>1).\nDefaults to 100% (no conversion loss).\n", + "description": "One-way conversion efficiency from the commodity (e.g. electricity) to the storage's state of charge.\nCan be a percentage, a ratio in the range [0,1], or a coefficient of performance (>1).\nDefaults to 100% (no conversion loss).\n", "example": ".9", "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, "discharging-efficiency": { - "description": "One-way conversion efficiency from the storage's state of charge to electricity.\nDefaults to 100% (no conversion loss).", + "description": "One-way conversion efficiency from the storage's state of charge to the commodity (e.g. electricity).\nDefaults to 100% (no conversion loss).", "example": "90%", "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, From c664e2a9dfc45358df9f3bcfdcc84efe489df6ce Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 16 Jun 2026 10:12:17 +0200 Subject: [PATCH 22/26] docs: add type annotation Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 93c2d2e93f..fcb77a01c5 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -290,7 +290,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 ] # Get info from flex-context - inflexible_device_sensors = self.flex_context.get( + inflexible_device_sensors: list[Sensor] = self.flex_context.get( "inflexible_device_sensors", [] ) From fd663721909df4041b2f123341916e3439550249 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 16 Jun 2026 10:17:10 +0200 Subject: [PATCH 23/26] fix: keep track of inflexible device sensors per commodity, too Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 49 ++++++++++++++++---- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index fcb77a01c5..8091f07f42 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -3,7 +3,8 @@ import re import copy from datetime import datetime, timedelta -from typing import Type +from itertools import chain +from typing import Any, Type import pandas as pd import numpy as np @@ -331,24 +332,56 @@ def device_list_series( ) -> pd.Series: return pd.Series([tuple(devices)] * len(index), index=index, name="device") + # Set up a mapping between commodities and a list of enumerated flexible and inflexible devices + # 1. The enumeration starts with all flexible devices in the order that they are given in the flex-model + # 2. The enumeration continues with the inflexible devices referenced in the top-level flex-context, in the order given + # 3. Then the enumeration goes through the commodities under the commodity_contexts field, in the order given, + # extending the enumeration with the inflexible devices referenced in these commodity contexts. commodity_to_devices = {} + # Step 1: enumerate the flexible devices for d, flex_model_d in enumerate(flex_model): commodity = flex_model_d.get("commodity", "electricity") commodity_to_devices.setdefault(commodity, []).append(d) - # inflexible devices are electricity by default - number_flexible_devices = len(flex_model) - number_inflexible_devices = len( - self.flex_context.get("inflexible_device_sensors", []) - ) + # Step 2: enumerate the top-level inflexible devices (electric for backwards compatibility) num_flexible_devices = len(flex_model) commodity_to_devices["electricity"] += list( range( - number_flexible_devices, - number_flexible_devices + number_inflexible_devices, + num_flexible_devices, + num_flexible_devices + len(inflexible_device_sensors), ) ) + # Step 3: enumerate the inflexible devices per commodity + def list_to_map(listing: list, key: Any) -> dict: + """Note: the key is retained in the map values.""" + return {l[key]: l for l in listing} + + # Move inflexible-device-sensors per commodity into the device listing for the commodity + commodity_mapping: dict[str, dict] = list_to_map( + self.flex_context.get("commodity_contexts", []), key="commodity" + ) + inflexible_devices_per_commodity = { + com: con.get("inflexible_device_sensors", []) + for com, con in commodity_mapping.items() + } + num_devices = num_flexible_devices + len(inflexible_device_sensors) + for ( + commodity, + commodity_inflexible_device_sensors, + ) in inflexible_devices_per_commodity.items(): + commodity_to_devices[commodity] += list( + range( + num_devices, + num_devices + len(commodity_inflexible_device_sensors), + ) + ) + num_devices = num_devices + len(commodity_inflexible_device_sensors) + + inflexible_device_sensors = inflexible_device_sensors + list( + chain.from_iterable(inflexible_devices_per_commodity.values()) + ) + commodity_contexts = self._get_commodity_contexts() price_frames_by_commodity = {} From 7d99f1a6afe50e98cfc0dc3d5b4223e93780c0b5 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 16 Jun 2026 10:17:59 +0200 Subject: [PATCH 24/26] refactor: place all group args at the end Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 8091f07f42..94c97d621d 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -2155,11 +2155,11 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: ems_schedule, expected_costs, scheduler_results, model = device_scheduler( device_constraints=device_constraints, ems_constraints=ems_constraints, - ems_constraint_groups=self.ems_constraint_groups, commitments=commitments, initial_stock=initial_stock, stock_groups=self.stock_groups, coupling_groups=self.coupling_groups if self.coupling_groups else None, + ems_constraint_groups=self.ems_constraint_groups, ) if "infeasible" in (tc := scheduler_results.solver.termination_condition): raise InfeasibleProblemException(tc) From 73f800431ff361db7da8a9bc16652f0813747c57 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 16 Jun 2026 10:19:51 +0200 Subject: [PATCH 25/26] dev: add todos for checking prices Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 94c97d621d..98a3e56f62 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -402,10 +402,12 @@ def list_to_map(listing: list, key: Any) -> dict: if production_price is None: production_price = consumption_price - if consumption_price is None: - raise ValueError( - f"Missing consumption price for commodity '{commodity}'." - ) + # todo: log info statement if commodity has no associated prices + # todo: raise if none of the commodities (or maybe electricity specifically) has prices + # if consumption_price is None: + # raise ValueError( + # f"Missing consumption price for commodity '{commodity}'." + # ) # Energy prices for this commodity. up_deviation_prices = get_continuous_series_sensor_or_quantity( @@ -416,7 +418,8 @@ def list_to_map(listing: list, key: Any) -> dict: beliefs_before=belief_time, fill_sides=True, ).to_frame(name="event_value") - ensure_prices_are_not_empty(up_deviation_prices, consumption_price) + # todo: see above todo + # ensure_prices_are_not_empty(up_deviation_prices, consumption_price) down_deviation_prices = get_continuous_series_sensor_or_quantity( variable_quantity=production_price, @@ -426,7 +429,8 @@ def list_to_map(listing: list, key: Any) -> dict: beliefs_before=belief_time, fill_sides=True, ).to_frame(name="event_value") - ensure_prices_are_not_empty(down_deviation_prices, production_price) + # todo: see above todo + # ensure_prices_are_not_empty(down_deviation_prices, production_price) price_frames_by_commodity[commodity] = up_deviation_prices From e3f7cbd2df585904105198e9afe9b1adf90567a4 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 30 Jun 2026 11:09:24 +0200 Subject: [PATCH 26/26] fix: test should exclude COMMODITY_FLEX_CONTEXT and COMMODITY_FLEX_MODEL, which are named commodity in scheduling.rst Signed-off-by: F.N. Claessen --- tests/documentation/test_schemas.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/documentation/test_schemas.py b/tests/documentation/test_schemas.py index 0a85803103..ead6fb0a9e 100644 --- a/tests/documentation/test_schemas.py +++ b/tests/documentation/test_schemas.py @@ -10,6 +10,8 @@ # Metadata constants that intentionally do not appear in the documentation EXCLUDED_METADATA = { + "COMMODITY_FLEX_CONTEXT", # appears as `commodity` in the flex-context listing in scheduling.rst + "COMMODITY_FLEX_MODEL", # appears as `commodity` in the flex-model listing in scheduling.rst "RELAX_CAPACITY_CONSTRAINTS", "RELAX_SITE_CAPACITY_CONSTRAINTS", "RELAX_SOC_CONSTRAINTS",