From e179c7fa58b7e4a6f9a77cd9287a7749bb0197a5 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 22 Jul 2026 12:07:38 +0200 Subject: [PATCH] feat: multi-commodity operation modes (per-commodity ranges + no-load flow) Generalise operation modes (#2278) so that a mode can carry, in addition to the device's own signed power band, per-commodity power ranges plus a per-commodity fixed no-load flow, matching S2's FRBC.OperationModeElement. The device scheduler ties the commodities affinely through a single continuous operation-mode factor per (banded device, band, time step), gated by the existing device_band binary. When a mode is active the factor sweeps the device's own power between its band min/max and every coupled commodity between its own (min, max) in lockstep: the range minima are the fixed no-load flows (gated by the binary), the shared factor supplies the marginal part. This natively expresses a unit-committed affine cogeneration unit (off + affine-on gas/heat), with a minimum level when on. Min up/down time is out of scope. Single-commodity operation modes are untouched (new machinery is only built for devices that declare commodity ranges). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B Signed-off-by: F.N. Claessen --- documentation/changelog.rst | 1 + .../models/planning/linear_optimization.py | 114 +++++++++++++ .../planning/tests/test_operation_modes.py | 155 ++++++++++++++++++ .../data/schemas/scheduling/storage.py | 62 +++++++ .../data/schemas/tests/test_scheduling.py | 34 ++++ flexmeasures/ui/static/openapi-specs.json | 30 ++++ 6 files changed, 396 insertions(+) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 0de5a5e48f..0a0f94b31e 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -27,6 +27,7 @@ New features * Improve chart axis domain for event values not around zero, with a per-sub-chart ``y-axis`` option in ``sensors_to_show`` (default ``zero``, which pads the axis out to include zero) that can be set to ``data`` to fit a sub-chart's y-axis to the values shown, to an explicit ``[min, max]`` domain that the axis will cover at least (expanding to fit data beyond it), or to a strict ``{"min": min, "max": max}`` domain that the axis will never exceed (clamping data beyond it, with a warning when that happens), editable from the graph editor [see `PR #2244 `_] * Extended ``GET /api/v3_0/jobs/`` with a ``result`` field containing ``unresolved`` and ``resolved`` soft state-of-charge constraint analysis (``soc-minima``/``soc-maxima`` violations or satisfied constraints, keyed by asset ID) for scheduling jobs; both arrays are empty when no SoC constraints were defined [see `PR #2072 `_] * New storage flex-model field ``operation-modes`` confines a device's power to one of several power bands, following the S2 standard's operation modes — for example, a device that is either off or running at one fixed power [see `Issue #2113 `_] +* Generalise operation modes across commodities: an operation mode may carry per-commodity power ranges (``commodity-power-ranges``, following S2's ``FRBC.OperationModeElement.power_ranges``), and the device scheduler ties the commodities affinely through a single operation-mode factor gated by the mode's binary, so a unit-committed affine cogeneration unit (with a per-commodity no-load flow plus a minimum level when on) can be expressed natively [see `Issue #2113 `_] * New ``FLEXMEASURES_LP_SOLVER_OPTIONS`` config setting to pass solver options to the scheduling solver, validated against the installed HiGHS build so that unknown or unsupported options raise instead of being silently ignored [see `PR #2283 `_] * Add support for intermediate power constraints on groups of devices, via a new ``group`` field in the storage flex-model [see `PR #2276 `_ and `issue #2092 `_] * The ``group`` field now also accepts a ``{"asset": }`` reference (in addition to ``{"sensor": }``), allowing intermediate power constraints to be defined entirely from flex-models stored on the asset tree, with results saved via the group's ``consumption``/``production`` output sensors, without needing any flex-model in the scheduling trigger [see `issue #2092 `_] diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 434a5a04c1..995091ec2d 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -84,6 +84,9 @@ def device_scheduler( # noqa C901 stock_groups: dict[int, list[int]] | None = None, ems_constraint_groups: list[list[int]] | None = None, device_power_bands: list[list[tuple[float, float]] | None] | None = None, + device_mode_commodity_ranges: ( + list[list[dict[int, tuple[float, float]]] | None] | 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, @@ -126,6 +129,21 @@ def device_scheduler( # noqa C901 one of its bands at every time step (see S2 operation modes); this introduces binary variables (one per device per band per time step). Use None (per device or for the whole argument) for devices without band restrictions. + :param device_mode_commodity_ranges: + optional per-device generalisation of ``device_power_bands`` to + *multiple commodities* (S2 ``FRBC.OperationModeElement.power_ranges``). + For a device ``d`` that carries operation modes, this is a list parallel + to ``device_power_bands[d]`` (one entry per mode/band). Each entry maps + *other* device indices (the coupled commodities, e.g. the fuel and heat + flows of a cogeneration unit) to a signed power range ``(min, max)`` in + flow units. When mode ``b`` is active, a single operation-mode factor + ``lambda in [0, 1]`` (shared across all commodities of that mode) sets the + banded device's own power to ``pmin_b + lambda * (pmax_b - pmin_b)`` and each + coupled commodity's power to ``cmin + lambda * (cmax - cmin)``. This ties the + commodities affinely: the range minima are the per-commodity fixed no-load + flows (gated by the mode's binary), and the shared factor makes the marginal + (proportional) part move in lockstep. Use None (per device or for the whole + argument) for single-commodity operation modes, which behave exactly as before. Potentially deprecated arguments: commitment_quantities: amounts of flow specified in commitments (both previously ordered and newly requested) @@ -427,6 +445,35 @@ def convert_commitments_to_subcommitments( if bands is not None and len(bands) > 0 } + # Multi-commodity operation modes (S2 FRBC.OperationModeElement.power_ranges): + # per banded device, per band, a mapping of coupled device (commodity) -> (min, max). + # Only kept for banded devices, and only for bands that actually declare coupled + # commodity ranges, so single-commodity operation modes stay untouched. + if device_mode_commodity_ranges is None: + device_mode_commodity_ranges = [None] * len(device_constraints) + mode_commodity_lookup: dict[int, list[dict[int, tuple[float, float]]]] = {} + for d, per_band in enumerate(device_mode_commodity_ranges): + if per_band is None or d not in band_lookup: + continue + if len(per_band) != len(band_lookup[d]): + raise ValueError( + "device_mode_commodity_ranges[%d] must have one entry per power band" + " (got %d entries for %d bands)." + % (d, len(per_band), len(band_lookup[d])) + ) + cleaned = [dict(entry) if entry else {} for entry in per_band] + if any(cleaned): + mode_commodity_lookup[d] = cleaned + # (device, coupled-commodity-device) pairs whose flow is set by an operation-mode factor. + mode_commodity_pairs = sorted( + { + (d, dc) + for d, per_band in mode_commodity_lookup.items() + for entry in per_band + for dc in entry + } + ) + # Add indices for devices (d), datetimes (j) and commitments (c) model.d = RangeSet(0, len(device_constraints) - 1, doc="Set of devices") model.j = RangeSet( @@ -877,6 +924,73 @@ def device_band_power_upper(m, d, b, j): model.db, model.j, rule=device_band_power_upper ) + # Multi-commodity operation modes: a single, continuous operation-mode factor per + # (banded device, band, time step) interpolates every commodity of that mode at once. + # The factor is gated by the band's binary (0 <= factor <= device_band), so it is + # zero for any band that is not selected, leaving only the selected band's factor free + # in [0, 1]. Summed over bands it interpolates the banded device's own power between + # its band minimum and maximum, and each coupled commodity between its own (min, max). + # This is the affine tie between commodities (S2's operation_mode_factor). + model.dbf = Set( + dimen=2, + initialize=lambda m: ( + (d, b) for d in mode_commodity_lookup for b in range(len(band_lookup[d])) + ), + doc="(device, band) pairs carrying a multi-commodity operation-mode factor", + ) + model.device_mode_factor = Var( + model.dbf, model.j, domain=NonNegativeReals, bounds=(0, 1), initialize=0 + ) + + def device_mode_factor_gate(m, d, b, j): + """The operation-mode factor is only non-zero for the selected band.""" + return m.device_mode_factor[d, b, j] <= m.device_band[d, b, j] + + def device_mode_primary_power(m, d, b, j): + """Tie the banded device's own power to its bands via the shared factor. + + Only added once per (device, time step), anchored on band 0. Combined with the + gate and the exactly-one-band choice, this both fixes the power to the selected + band's interpolation and makes the band-power lower/upper bounds redundant-but-safe. + """ + if b != 0: + return Constraint.Skip + return m.device_power_down[d, j] + m.device_power_up[d, j] == sum( + m.device_band[d, b_, j] * band_lookup[d][b_][0] + + m.device_mode_factor[d, b_, j] + * (band_lookup[d][b_][1] - band_lookup[d][b_][0]) + for b_ in range(len(band_lookup[d])) + ) + + def device_mode_commodity_power(m, d, dc, j): + """Tie each coupled commodity's flow to the same shared operation-mode factor. + + cmin is the commodity's fixed no-load flow (gated by the mode binary); the factor + term adds the proportional part, in lockstep with the banded device's own power. + """ + return m.device_power_down[dc, j] + m.device_power_up[dc, j] == sum( + m.device_band[d, b_, j] + * mode_commodity_lookup[d][b_].get(dc, (0.0, 0.0))[0] + + m.device_mode_factor[d, b_, j] + * ( + mode_commodity_lookup[d][b_].get(dc, (0.0, 0.0))[1] + - mode_commodity_lookup[d][b_].get(dc, (0.0, 0.0))[0] + ) + for b_ in range(len(band_lookup[d])) + ) + + model.device_mode_factor_gate = Constraint( + model.dbf, model.j, rule=device_mode_factor_gate + ) + model.device_mode_primary_power = Constraint( + model.dbf, model.j, rule=device_mode_primary_power + ) + if mode_commodity_pairs: + model.dc_pairs = Set(dimen=2, initialize=mode_commodity_pairs) + model.device_mode_commodity_power = Constraint( + model.dc_pairs, model.j, rule=device_mode_commodity_power + ) + # Add objective def cost_function(m): costs = 0 diff --git a/flexmeasures/data/models/planning/tests/test_operation_modes.py b/flexmeasures/data/models/planning/tests/test_operation_modes.py index 119615d44f..4ef871ca1b 100644 --- a/flexmeasures/data/models/planning/tests/test_operation_modes.py +++ b/flexmeasures/data/models/planning/tests/test_operation_modes.py @@ -83,6 +83,161 @@ def test_device_scheduler_with_on_off_bands(): assert np.isclose(costs, 1.0) +def _cogeneration_setup( + stock_target: float | None, + electricity_up_price, + gas_up_price: float, +): + """A unit-committed affine cogeneration unit as a multi-commodity operation mode. + + Three devices share one operation-mode binary carried by the (banded) electricity + device: electricity (device 0, the primary/banded device), gas (device 1) and heat + (device 2). The unit has two modes: + + - off: electricity [0, 0], gas [0, 0], heat [0, 0] (no no-load fuel) + - on: electricity [0.4, 0.5] and, tied by the shared operation-mode factor, + gas = 0.2 + 1.0 * electricity (no-load base 0.2) + heat = 0.1 + 0.5 * electricity (no-load base 0.1) + + In S2 terms the "on" mode's per-commodity power_ranges are, from factor 0 (electricity + 0.4) to factor 1 (electricity 0.5): gas (0.6, 0.7) and heat (0.3, 0.35). + """ + start = pd.Timestamp("2026-01-01T00:00+01") + end = pd.Timestamp("2026-01-01T04:00+01") + resolution = pd.Timedelta("PT1H") + index = initialize_index(start=start, end=end, resolution=resolution) + + equals = pd.Series(np.nan, index=index) + if stock_target is not None: + equals.iloc[-1] = stock_target + electricity = pd.DataFrame( + { + "min": 0, + "max": 10, + "equals": equals, + "derivative min": 0, + "derivative max": 0.5, + "derivative equals": np.nan, + }, + index=index, + ) + # Gas and heat carry only a flow; their power is fully set by the operation-mode factor. + free_commodity = pd.DataFrame( + { + "min": np.nan, + "max": np.nan, + "equals": np.nan, + "derivative min": -10, + "derivative max": 10, + "derivative equals": np.nan, + }, + index=index, + ) + device_constraints = [electricity, free_commodity.copy(), free_commodity.copy()] + ems_constraints = pd.DataFrame( + {"derivative min": -100, "derivative max": 100}, index=index + ) + energy_commitment = FlowCommitment( + name="energy", + index=index, + quantity=0, + upwards_deviation_price=pd.Series(electricity_up_price, index=index), + downwards_deviation_price=0, + device=pd.Series(0, index=index), + ) + gas_commitment = FlowCommitment( + name="gas", + index=index, + quantity=0, + upwards_deviation_price=gas_up_price, + downwards_deviation_price=0, + device=pd.Series(1, index=index), + ) + device_power_bands = [[(0, 0), (0.4, 0.5)], None, None] + device_mode_commodity_ranges = [ + [{1: (0.0, 0.0), 2: (0.0, 0.0)}, {1: (0.6, 0.7), 2: (0.3, 0.35)}], + None, + None, + ] + return ( + device_constraints, + ems_constraints, + [energy_commitment, gas_commitment], + device_power_bands, + device_mode_commodity_ranges, + ) + + +def _run_cogen(stock_target, electricity_up_price, gas_up_price): + ( + device_constraints, + ems_constraints, + commitments, + device_power_bands, + device_mode_commodity_ranges, + ) = _cogeneration_setup(stock_target, electricity_up_price, gas_up_price) + schedule, costs, results, model = device_scheduler( + device_constraints, + ems_constraints, + commitments=commitments, + device_power_bands=device_power_bands, + device_mode_commodity_ranges=device_mode_commodity_ranges, + ) + assert "optimal" in str(results.solver.termination_condition) + electricity = schedule[0].values + gas = schedule[1].values + heat = schedule[2].values + return electricity, gas, heat, costs + + +def test_cogeneration_idles_fully_when_unprofitable(): + """When running never pays off, the unit idles: every commodity is 0, no no-load fuel. + + There is no stock target, so nothing forces the unit on, and running any step costs both + electricity (price 1) and gas (price 2 * (0.2 + electricity), incl. the no-load base). + Idling (cost 0) therefore dominates. The unit *could* run (it has an "on" band) but must + not spuriously do so, and in particular must not burn the no-load gas base while off. + """ + electricity, gas, heat, costs = _run_cogen( + stock_target=None, + electricity_up_price=1.0, + gas_up_price=2.0, + ) + assert np.allclose(electricity, 0) + assert np.allclose(gas, 0) # no no-load fuel while off + assert np.allclose(heat, 0) + assert np.isclose(costs, 0.0) + + +def test_cogeneration_runs_at_minimum_with_affine_commodities(): + """The unit must meet a small electricity stock target, forcing minimum-level running. + + The 1.2 target cannot be met below the 0.4 mode minimum, so exactly three steps run at + 0.4 (like the single-commodity min-power-band case). Gas and heat follow the affine tie + including their no-load bases, and the hand-computed objective is energy + gas cost: + + energy cost = 1*0.4 + 1*0.4 + 10*0.4 = 4.8 (cheap steps 1 & 3, plus one costly step) + gas cost = 2 * (0.6 + 0.6 + 0.6) = 3.6 (0.6 gas per running step at 0.4) + total = 8.4 + """ + electricity, gas, heat, costs = _run_cogen( + stock_target=1.2, + electricity_up_price=[10, 1, 10, 1], + gas_up_price=2.0, + ) + # Three steps at the 0.4 minimum, one idle step. + assert np.isclose(sorted(electricity), [0, 0.4, 0.4, 0.4]).all() + assert np.isclose(electricity.sum(), 1.2) + # Affine commodities incl. no-load base; both are zero exactly when the unit is off. + for p, g, h in zip(electricity, gas, heat): + if np.isclose(p, 0): + assert np.isclose(g, 0) and np.isclose(h, 0) + else: + assert np.isclose(g, 0.2 + 1.0 * p) # no-load 0.2 + proportional gas + assert np.isclose(h, 0.1 + 0.5 * p) # no-load 0.1 + proportional heat + assert np.isclose(costs, 8.4) + + def test_device_scheduler_with_min_power_band(): """A device confined to {0} U [0.4, 0.5] cannot run below its minimum power. diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index b2821ad748..c6553a653d 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -125,6 +125,42 @@ def __init__(self, *args, **kwargs): ) +class CommodityPowerRangeSchema(Schema): + """A single commodity's signed power range within one operation mode. + + This mirrors one entry of an S2 ``FRBC.OperationModeElement.power_ranges`` list: the + signed power range that this commodity's flow takes as the mode's operation-mode factor + sweeps from 0 to 1. The two endpoints therefore correspond to the low and high ends of + the mode's own ``power-range`` (they need not be ordered): the endpoint at factor 0 is + the commodity's fixed no-load flow, and the difference to the factor-1 endpoint is its + proportional (marginal) part. Sharing one factor across commodities ties them affinely, + which lets a unit-committed affine cogeneration unit be expressed natively. + """ + + commodity = fields.Str( + required=True, + metadata=dict( + description="Name of the commodity this power range applies to " + "(e.g. 'gas' or 'heat')." + ), + ) + power_range = fields.List( + QuantityField( + to_unit="MW", + default_src_unit="MW", + return_magnitude=False, + ), + data_key="power-range", + required=True, + validate=validate.Length(equal=2), + metadata=dict( + description="Signed power range of this commodity across the mode's " + "operation-mode factor (factor 0 endpoint first). Positive is consumption, " + "negative is production." + ), + ) + + class OperationModeSchema(Schema): """One operation mode of a device, in the sense of the S2 standard. @@ -134,6 +170,14 @@ class OperationModeSchema(Schema): A device that can only be off or run at exactly 883.7 W declares: [{"power-range": ["0 W", "0 W"]}, {"power-range": ["883.7 W", "883.7 W"]}] + + An operation mode may additionally carry per-commodity power ranges + (``commodity-power-ranges``), generalising it across commodities in the sense of + S2's ``FRBC.OperationModeElement.power_ranges``. A single operation-mode factor then + interpolates the device's own power and every listed commodity's power in lockstep, + tying them affinely. For example, a cogeneration unit's "on" mode carries electricity + as its own ``power-range`` and gas and heat as ``commodity-power-ranges``, each with + a no-load base (the factor-0 endpoint) plus a proportional part. """ power_range = fields.List( @@ -150,6 +194,15 @@ class OperationModeSchema(Schema): "(positive is consumption, negative is production).", ), ) + commodity_power_ranges = fields.List( + fields.Nested(CommodityPowerRangeSchema()), + data_key="commodity-power-ranges", + required=False, + metadata=dict( + description="Optional per-commodity signed power ranges tied to this mode's " + "operation-mode factor (S2 FRBC.OperationModeElement.power_ranges)." + ), + ) @validates_schema def check_range_order(self, data: dict, **kwargs): @@ -158,6 +211,15 @@ def check_range_order(self, data: dict, **kwargs): "The minimum of an operation mode's power-range cannot exceed its maximum." ) + @validates("commodity_power_ranges") + def check_unique_commodities(self, value: list, **kwargs): + commodities = [entry["commodity"] for entry in value] + if len(commodities) != len(set(commodities)): + raise ValidationError( + "Each commodity may appear at most once in an operation mode's " + "commodity-power-ranges." + ) + class StorageFlexModelSchema(Schema): """ diff --git a/flexmeasures/data/schemas/tests/test_scheduling.py b/flexmeasures/data/schemas/tests/test_scheduling.py index d3db401fcd..69016af85e 100644 --- a/flexmeasures/data/schemas/tests/test_scheduling.py +++ b/flexmeasures/data/schemas/tests/test_scheduling.py @@ -1455,3 +1455,37 @@ 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) + + +def test_operation_mode_schema_with_commodity_power_ranges(app): + """A multi-commodity operation mode loads its per-commodity power ranges (S2 shape).""" + from flexmeasures.data.schemas.scheduling.storage import OperationModeSchema + + mode = OperationModeSchema().load( + { + "power-range": ["0.4 MW", "0.5 MW"], + "commodity-power-ranges": [ + {"commodity": "gas", "power-range": ["0.6 MW", "0.7 MW"]}, + {"commodity": "heat", "power-range": ["0.3 MW", "0.35 MW"]}, + ], + } + ) + assert len(mode["commodity_power_ranges"]) == 2 + assert mode["commodity_power_ranges"][0]["commodity"] == "gas" + assert mode["commodity_power_ranges"][0]["power_range"][0].to("MW").magnitude == 0.6 + + +def test_operation_mode_schema_rejects_duplicate_commodities(app): + """A commodity may appear at most once in an operation mode's commodity-power-ranges.""" + from flexmeasures.data.schemas.scheduling.storage import OperationModeSchema + + with pytest.raises(ValidationError): + OperationModeSchema().load( + { + "power-range": ["0 MW", "1 MW"], + "commodity-power-ranges": [ + {"commodity": "gas", "power-range": ["0 MW", "1 MW"]}, + {"commodity": "gas", "power-range": ["0 MW", "2 MW"]}, + ], + } + ) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index a2a16a7f01..4d9bb5fb62 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -6204,6 +6204,29 @@ ], "additionalProperties": false }, + "CommodityPowerRange": { + "type": "object", + "properties": { + "commodity": { + "type": "string", + "description": "Name of the commodity this power range applies to (e.g. 'gas' or 'heat')." + }, + "power-range": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "description": "Signed power range of this commodity across the mode's operation-mode factor (factor 0 endpoint first). Positive is consumption, negative is production.", + "items": { + "type": "string" + } + } + }, + "required": [ + "commodity", + "power-range" + ], + "additionalProperties": false + }, "OperationMode": { "type": "object", "properties": { @@ -6215,6 +6238,13 @@ "items": { "type": "string" } + }, + "commodity-power-ranges": { + "type": "array", + "description": "Optional per-commodity signed power ranges tied to this mode's operation-mode factor (S2 FRBC.OperationModeElement.power_ranges).", + "items": { + "$ref": "#/components/schemas/CommodityPowerRange" + } } }, "required": [