diff --git a/documentation/features/scheduling.rst b/documentation/features/scheduling.rst index aec2ce3397..b3acbeefdd 100644 --- a/documentation/features/scheduling.rst +++ b/documentation/features/scheduling.rst @@ -196,6 +196,12 @@ For more details on the possible formats for field values, see :ref:`variable_qu * - ``commodity`` - |COMMODITY_FLEX_MODEL.example| - .. include:: ../_autodoc/COMMODITY_FLEX_MODEL.rst + * - ``coupling`` + - |COUPLING.example| + - .. include:: ../_autodoc/COUPLING.rst + * - ``coupling-coefficient`` + - |COUPLING_COEFFICIENT.example| + - .. include:: ../_autodoc/COUPLING_COEFFICIENT.rst * - ``consumption`` - |CONSUMPTION.example| - .. include:: ../_autodoc/CONSUMPTION.rst diff --git a/flexmeasures/data/models/planning/devices.py b/flexmeasures/data/models/planning/devices.py index b7eb4a98c2..ab70861b71 100644 --- a/flexmeasures/data/models/planning/devices.py +++ b/flexmeasures/data/models/planning/devices.py @@ -20,6 +20,7 @@ from __future__ import annotations +import math from dataclasses import dataclass, field from enum import Enum from functools import cached_property @@ -34,9 +35,11 @@ class DeviceRole(Enum): """The role a flex-model (or flex-context) entry plays in the scheduling problem. - Extension points (not yet implemented): GROUP (an entry constraining the aggregate - power of a set of member devices) and CONVERTER_PORT (a commodity port of a - multi-commodity converter). + Converter ports (the commodity ports of a multi-commodity converter, + such as a CHP unit) are DEVICE entries carrying a ``coupling`` field; + see :attr:`DeviceInventory.coupling_groups`. + Extension point (not yet implemented): + GROUP (an entry constraining the aggregate power of a set of member devices). """ #: A schedulable flexible device (usually with a power sensor). @@ -69,6 +72,12 @@ class FlexDevice: #: Key of the stock this device draws from: the id of its state-of-charge sensor, or a unique negative synthetic key for devices without one. #: None for inflexible devices. stock_key: int | None = None + #: Name of the coupling group this device belongs to (converter ports of one converter share a coupling name). + #: None for uncoupled devices. + coupling: str | None = None + #: Signed internal coupling coefficient: positive for input (consuming) ports, negative for output (producing) ports. + #: Meaningless (1.0) for uncoupled devices. + coupling_coefficient: float = 1.0 @property def sensor_id(self) -> int | None: @@ -147,6 +156,37 @@ def _resolve_stock_key(state_of_charge: Any) -> int | None: return key +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 math.isclose(float(magnitude), 0.0, abs_tol=1e-08) + except (TypeError, ValueError): + return False + + +def _resolve_coupling_coefficient(flex_model: dict) -> float: + """Resolve a coupled device's internal signed coupling coefficient. + + 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. + """ + coefficient = abs(float(flex_model.get("coupling_coefficient", 1.0))) + is_output = _is_zero_capacity(flex_model.get("consumption_capacity")) + is_input = _is_zero_capacity(flex_model.get("production_capacity")) + if is_output and not is_input: + coefficient = -coefficient + return coefficient + + #: Flex-model fields that make a device entry (with a state-of-charge sensor) #: also carry the SoC parameters of its stock. SOC_PARAM_FIELDS = ("soc_at_start", "soc_min", "soc_max", "soc_targets") @@ -267,6 +307,8 @@ def register_stock_params(stock_key: int, fm: dict) -> None: ), commodity=fm.get("commodity", "electricity"), stock_key=stock_key, + coupling=fm.get("coupling"), + coupling_coefficient=_resolve_coupling_coefficient(fm), ) inventory.entries.append(device) inventory.devices.append(device) @@ -344,6 +386,27 @@ def stock_params(self, stock_key: int) -> dict | None: """Return the flex-model entry holding the SoC parameters of the given stock.""" return self.stock_entries.get(stock_key) + @cached_property + def coupling_groups(self) -> dict[str, list[tuple[int, float]]]: + """Map each coupling-group name to its ports' (device index, signed coefficient) pairs. + + Devices sharing a coupling name are the commodity ports of one converter (e.g. a CHP unit's gas input, heat output and electricity output). + The optimization model introduces a decision variable ``alpha`` per group per time step, + and constrains every port by ``P[d] == coeff_d * alpha``. + The coefficient signs follow the internal convention (see :func:`_resolve_coupling_coefficient`): + positive for inputs, negative for outputs. + The result is suitable for passing to ``device_scheduler(coupling_groups=...)``; + it is empty when no device defines a ``coupling`` field. + """ + groups: dict[str, list[tuple[int, float]]] = {} + for device in self.devices: + if device.coupling is None: + continue + groups.setdefault(device.coupling, []).append( + (device.index, device.coupling_coefficient) + ) + return groups + @cached_property def commodity_to_devices(self) -> dict[str, list[int]]: """Map each commodity to its device indices, in canonical solver order.""" diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 12b33fd70c..cff76d9416 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, 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, @@ -80,6 +81,15 @@ 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. 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):: + + 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) @@ -131,19 +141,36 @@ def device_scheduler( # noqa C901 # 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] + # where alpha is a free variable representing the common normalised flow. + coupling_device_specs: list[tuple[int, int, float]] = [] + if coupling_groups: + 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: @@ -550,6 +577,35 @@ def grouped_commitment_equalities(m, c, j, g): ) model.commitment_sign = Var(model.c, domain=Binary, initialize=0) + # def _get_stock_change(m, d, j): + # """Determine final stock change of device d until time j. + # + # Apply conversion efficiencies to conversion from flow to stock change and vice versa, + # and apply storage efficiencies to stock levels from one datetime to the next. + # """ + # if isinstance(initial_stock, list): + # # No initial stock defined for inflexible device + # initial_stock_d = initial_stock[d] if d < len(initial_stock) else 0 + # else: + # initial_stock_d = initial_stock + # + # stock_changes = [ + # ( + # m.device_power_down[d, k] / m.device_derivative_down_efficiency[d, k] + # + m.device_power_up[d, k] * m.device_derivative_up_efficiency[d, k] + # + m.stock_delta[d, k] + # ) + # for k in range(0, j + 1) + # ] + # efficiencies = [m.device_efficiency[d, k] for k in range(0, j + 1)] + # final_stock_change = [ + # stock - initial_stock_d + # for stock in apply_stock_changes_and_losses( + # initial_stock_d, stock_changes, efficiencies + # ) + # ][-1] + # return final_stock_change + def _get_stock_change(m, d, j): """Determine final stock change of the stock group of device d until time j. @@ -583,7 +639,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): @@ -778,6 +834,29 @@ def device_derivative_equalities(m, d, j): model.d, model.j, rule=device_derivative_equalities ) + if coupling_device_specs: + n_coupling_groups = len(coupling_groups) + + # 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) + + 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). + """ + g, d, coeff = coupling_device_specs[c] + return m.ems_power[d, j] == coeff * m.coupling_alpha[g, j] + + model.flow_coupling_constraints = Constraint( + model.coupling_device_range, model.j, rule=flow_coupling_rule + ) + # Add objective def cost_function(m): costs = 0 diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index a6686748b8..993cd721cd 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -148,6 +148,10 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 # The stock groups' device indices align with the device models self.stock_groups = inventory.stock_groups + # The coupling groups (converter ports sharing a coupling name) also derive from the inventory, + # with signed coefficients per canonical device index. + self.coupling_groups = inventory.coupling_groups + # List the asset(s) and sensor(s) being scheduled sensors: list[Sensor | None] = inventory.power_sensors assets: list[Asset | None] = inventory.assets @@ -2542,6 +2546,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_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 4275892c53..5eb8620fc3 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -1556,6 +1556,667 @@ def test_simulation_with_dynamic_consumption_capacity(app, db): ), "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 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)]``, introducing a decision variable ``alpha`` + and enforcing ``P[d] == coeff * alpha`` for each device: + + 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 ``P_heat = -10`` gives ``alpha = 20``, so: + + P_gas = 20 kW (gas consumed) + P_heat = -10 kW (heat produced, forced) + P_power = 20 kW * -0.3 + ≈ -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) + + # 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: P_gas / 1.0 == P_heat / -0.5 → P_gas = -10 / -0.5 = 20 kW + pd.testing.assert_series_equal( + schedules[0], + pd.Series(20.0, index=index), + check_names=False, + rtol=1e-4, + obj="gas consumption determined by coupling (20 kW from 10 kW heat at coeff -0.5)", + ) + + # 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(-6.0, index=index), + check_names=False, + rtol=1e-4, + 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", + ) + + +def _run_factory_scenario( + gas_price: float, + elec_price: float, +) -> tuple: + """Run the simplified factory scenario and return the 7 device schedules. + + Devices + ~~~~~~~ + 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 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:: + + 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) + + """ + 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 + 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") + 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. 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 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=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( + {"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 + steam_group_id = 1 + stock_groups = {heat_group_id: [0, 1, 2], steam_group_id: [2, 4, 6]} + + # 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": [ + (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=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 + # • 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), (3, gas_p), (0, elec_p), (5, elec_p)]: + commitments.append( + FlowCommitment( + name="gas cost" if d in (1, 3) 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 (efficiency = 1) + + 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 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 # + # ------------------------------------------------------------------ # + (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_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( + 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( + 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, + 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, 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, + 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)", + ) + 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, steamer, 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_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( + 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( + 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, + check_names=False, + atol=1e-4, + obj="Scenario C: e-heater fills remaining 5 kW heat demand", + ) + + def test_all_gas_flex_model_without_electricity_device(app, db): """test_all_gas_flex_model_without_electricity_device: a flex-model with only gas devices (no electricity device at all) should not raise a KeyError, now that diff --git a/flexmeasures/data/models/planning/tests/test_storage.py b/flexmeasures/data/models/planning/tests/test_storage.py index a03d09e05d..30314a3e24 100644 --- a/flexmeasures/data/models/planning/tests/test_storage.py +++ b/flexmeasures/data/models/planning/tests/test_storage.py @@ -7,6 +7,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 @@ -16,6 +17,7 @@ get_sensors_from_db, series_to_ts_specs, ) +from flexmeasures.data.services.utils import get_or_create_model from flexmeasures.data.services.scheduling_result import SchedulingJobResult @@ -1223,3 +1225,187 @@ 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 sensors. + + In the flex-model, the coupling coefficients are entered as positive magnitudes:: + + gas input -> 1.0 + heat output -> 0.5 + power output -> 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) + - ``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. Substituting P_heat = 5 kW gives + alpha = 5 / 0.5 = 10 kW, so: + + P_gas = 1.0 × 10 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 + # 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. + "sensor": gas_input_sensor.id, + "power-capacity": "20 kW", + "production-capacity": "0 kW", # derivative_min = 0 + "coupling": "chp", + "coupling-coefficient": 1.0, + }, + { + # 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": ETA_HEAT, # = 0.5 + }, + { + # 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": ETA_POWER, # = 0.3 (sign inferred from capacities) + }, + ] + + 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. + # 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 + rtol=1e-4, + 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 + 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 (1.0 * alpha)", + ) + + # 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 + rtol=1e-4, + err_msg="Power output must be -3 kW — determined by coupling (-0.3 * alpha)", + ) diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index 4f6f9d4298..e6b531b36e 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -223,6 +223,25 @@ def to_dict(self): """, examples=["electricity", "gas"], ) +COUPLING = MetaData( + description="""Name of the coupling group this device belongs to. +Devices sharing the same coupling name are constrained to have proportionally related power flows, via a hard equality constraint. +Use this to model a device that converts one commodity into another, by describing each of its commodity ports as a separate device. +For example, a combined heat and power (CHP) unit is described as a gas input device, a heat output device and an electricity output device, all sharing one coupling name. +Use together with ``coupling-coefficient`` to set the flow ratios. +""", + example="chp", +) +COUPLING_COEFFICIENT = MetaData( + description="""Positive coupling magnitude for this device within its coupling group. +The scheduler couples the power flows of all devices in the group: each device's power is its coupling coefficient times the group's common flow level. +The flow direction of each device is inferred from its directional capacities: a device with ``consumption-capacity: "0 kW"`` is an output (producing) device, and a device with ``production-capacity: "0 kW"`` is an input (consuming) device. +Exactly one of the two directional capacities must be set to a fixed zero for each coupled device. +For example, a CHP unit with 50% thermal and 30% electrical efficiency uses a gas input device (coefficient 1), a heat output device (coefficient 0.5) and an electricity output device (coefficient 0.3). +Defaults to 1. +""", + example=0.5, +) CONSUMPTION = MetaData( description="""Sensor used to record the scheduled power as seen from a consumption perspective. diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 60d9da3448..06325375c8 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -245,6 +245,19 @@ class StorageFlexModelSchema(Schema): validate=validate.Length(min=1), metadata=metadata.SOC_USAGE.to_dict(), ) + coupling = fields.Str( + data_key="coupling", + required=False, + load_default=None, + metadata=metadata.COUPLING.to_dict(), + ) + coupling_coefficient = fields.Float( + data_key="coupling-coefficient", + required=False, + load_default=1.0, + validate=validate.Range(min=0, min_inclusive=False), + metadata=metadata.COUPLING_COEFFICIENT.to_dict(), + ) def __init__( self, @@ -394,6 +407,33 @@ def validate_commodity(self, commodity: str, **kwargs): if not isinstance(commodity, str) or not commodity.strip(): raise ValidationError("commodity must be a non-empty string.") + @validates_schema + def validate_coupling_direction_is_unambiguous(self, data: dict, **kwargs): + """A coupled device must have an inferable flow direction. + + The sign of the coupling coefficient is inferred from directional capacities: + a fixed zero consumption-capacity marks an output device, a fixed zero + production-capacity marks an input device. When both directional capacities + allow flow (or are given in a form whose value cannot be checked statically, + such as a sensor reference), the direction is ambiguous, so we reject the + flex-model rather than silently treating the device as an input. + """ + if data.get("coupling") is None: + return + + def _is_fixed_zero(value) -> bool: + return isinstance(value, ur.Quantity) and float(value.magnitude) == 0.0 + + consumption_blocked = _is_fixed_zero(data.get("consumption_capacity")) + production_blocked = _is_fixed_zero(data.get("production_capacity")) + if consumption_blocked == production_blocked: + raise ValidationError( + "A device with a 'coupling' field must have an unambiguous flow direction: " + "set either its consumption-capacity (for an output device) or its " + "production-capacity (for an input device) to a fixed 0, but not both.", + field_name="coupling", + ) + @post_load def post_load_sequence(self, data: dict, **kwargs) -> dict: """Perform some checks and corrections after we loaded.""" diff --git a/flexmeasures/data/schemas/tests/test_scheduling.py b/flexmeasures/data/schemas/tests/test_scheduling.py index 90842d2cf9..7d557ee178 100644 --- a/flexmeasures/data/schemas/tests/test_scheduling.py +++ b/flexmeasures/data/schemas/tests/test_scheduling.py @@ -1197,3 +1197,46 @@ def test_asset_trigger_schema_rejects_malformed_flex_context(app): with pytest.raises(ValidationError) as e_info: schema.normalize_flex_context_format({"flex-context": "not-a-dict-or-list"}) assert "flex-context" in str(e_info.value) + + +@pytest.mark.parametrize( + "capacity_fields, fails", + [ + # Input device: production blocked, direction is unambiguous + ({"production-capacity": "0 kW"}, False), + # Output device: consumption blocked, direction is unambiguous + ({"consumption-capacity": "0 kW"}, False), + # Output device with a bounded input side still has one blocked direction + ({"consumption-capacity": "5 kW", "production-capacity": "0 kW"}, False), + # Neither direction blocked: ambiguous + ({}, True), + # Both directions open: ambiguous + ({"consumption-capacity": "5 kW", "production-capacity": "5 kW"}, True), + # Both directions blocked: degenerate (device pinned to zero flow) + ({"consumption-capacity": "0 kW", "production-capacity": "0 kW"}, True), + ], +) +def test_coupling_direction_must_be_unambiguous(app, capacity_fields, fails): + """test_coupling_direction_must_be_unambiguous: a device with a `coupling` field must + have exactly one directional capacity fixed to zero, so the sign of its coupling + coefficient can be inferred.""" + schema = StorageFlexModelSchema(start=datetime(2026, 6, 1), sensor=None) + flex_model = { + "power-capacity": "20 kW", + "coupling": "chp", + "coupling-coefficient": 0.5, + **capacity_fields, + } + if fails: + with pytest.raises(ValidationError) as e_info: + schema.load(flex_model) + assert "unambiguous flow direction" in str(e_info.value) + else: + schema.load(flex_model) + + +def test_uncoupled_device_needs_no_directional_capacities(app): + """test_uncoupled_device_needs_no_directional_capacities: the coupling-direction check + only applies to devices that define a `coupling` field.""" + schema = StorageFlexModelSchema(start=datetime(2026, 6, 1), sensor=None) + schema.load({"power-capacity": "20 kW"}) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 0eab864eef..ec37ffbf09 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -6369,6 +6369,22 @@ ], "items": {} }, + "coupling": { + "type": [ + "string", + "null" + ], + "default": null, + "description": "Name of the coupling group this device belongs to.\nDevices sharing the same coupling name are constrained to have proportionally related power flows, via a hard equality constraint.\nUse this to model a device that converts one commodity into another, by describing each of its commodity ports as a separate device.\nFor example, a combined heat and power (CHP) unit is described as a gas input device, a heat output device and an electricity output device, all sharing one coupling name.\nUse together with coupling-coefficient to set the flow ratios.\n", + "example": "chp" + }, + "coupling-coefficient": { + "type": "number", + "default": 1.0, + "minimum": 0.0, + "description": "Positive coupling magnitude for this device within its coupling group.\nThe scheduler couples the power flows of all devices in the group: each device's power is its coupling coefficient times the group's common flow level.\nThe flow direction of each device is inferred from its directional capacities: a device with consumption-capacity: \"0 kW\" is an output (producing) device, and a device with production-capacity: \"0 kW\" is an input (consuming) device.\nExactly one of the two directional capacities must be set to a fixed zero for each coupled device.\nFor example, a CHP unit with 50% thermal and 30% electrical efficiency uses a gas input device (coefficient 1), a heat output device (coefficient 0.5) and an electricity output device (coefficient 0.3).\nDefaults to 1.\n", + "example": 0.5 + }, "sensor": { "type": "integer", "description": "ID of the device's power sensor." diff --git a/tests/documentation/test_schemas.py b/tests/documentation/test_schemas.py index fab2946647..ead6fb0a9e 100644 --- a/tests/documentation/test_schemas.py +++ b/tests/documentation/test_schemas.py @@ -10,11 +10,11 @@ # 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", - "COMMODITY_FLEX_CONTEXT", # Documented as "commodity" in flex-context section - "COMMODITY_FLEX_MODEL", # Documented as "commodity" in flex-model section }