diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 6cbc733e1e..9335cca73d 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 `PR #2278 `_] +* Generalise operation modes across commodities: an operation mode may carry per-commodity power ranges (``commodity-power-ranges``, following S2's ``FRBC.OperationModeElement.power_ranges``, with the same sign-explicit ``consumption-range``/``production-range`` keys), 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..b5afa4ad70 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,26 @@ 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 pair ``(c0, c1)`` in flow units, + giving that commodity's flow at the mode's two factor extremes: ``c0`` at + factor 0 (the banded device's own band minimum) and ``c1`` at factor 1 (its + band maximum). Sign is resolved upstream from the mode's + ``consumption-range``/``production-range`` keys, so these values are already + signed and need not be ordered. 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 + ``c0 + lambda * (c1 - c0)``. This ties the commodities affinely: the factor-0 + endpoints 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 +450,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 +929,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/storage.py b/flexmeasures/data/models/planning/storage.py index b73f58e4fd..457cecfa30 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -69,16 +69,20 @@ SCHEDULING_RESULT_KEY = "scheduling_result" -def _operation_mode_signed_band(mode: dict) -> tuple[float, float]: - """Convert one operation mode's consumption-/production-range into a signed - ``(min, max)`` band in MW (positive is consumption) for the device scheduler. - - ``consumption-range`` maps to the positive side, ``production-range`` to the - negative side; combining both (each validated to start at 0) yields one band - through zero ``[-production_max, +consumption_max]``. +def _signed_range_endpoints(cons, prod) -> tuple[float, float]: + """Signed ``(low-running, high-running)`` power endpoints in MW for one range spec. + + Applies the sign convention shared by an operation mode's own band and each of its + per-commodity ranges: ``consumption-range`` maps to the positive side and + ``production-range`` to the negative side. The two endpoints are returned ordered by + the mode's *running level* (the operation-mode factor): the first is the flow when the + device runs least (lowest magnitude), the second when it runs most. + + - consumption only ``[a, b]`` (0 <= a <= b): ``(+a, +b)`` — more consumption is more running. + - production only ``[a, b]``: ``(-a, -b)`` — more production (more negative) is more running. + - both (each validated to start at 0): a band through zero, returned as + ``(-production_max, +consumption_max)`` (used for a device's own band, not for coupling). """ - cons = mode.get("consumption_range") - prod = mode.get("production_range") if cons and prod: return ( -float(prod[1].to("MW").magnitude), @@ -90,9 +94,49 @@ def _operation_mode_signed_band(mode: dict) -> tuple[float, float]: float(cons[1].to("MW").magnitude), ) return ( - -float(prod[1].to("MW").magnitude), -float(prod[0].to("MW").magnitude), + -float(prod[1].to("MW").magnitude), + ) + + +def _operation_mode_signed_band(mode: dict) -> tuple[float, float]: + """Convert one operation mode's consumption-/production-range into a signed + ``(min, max)`` band in MW (positive is consumption) for the device scheduler. + """ + lo, hi = _signed_range_endpoints( + mode.get("consumption_range"), mode.get("production_range") + ) + return (min(lo, hi), max(lo, hi)) + + +def _operation_mode_commodity_bands(mode: dict) -> dict[str, tuple[float, float]]: + """Per-commodity signed power endpoints for one operation mode, keyed by commodity. + + Each value is ``(value_at_factor_0, value_at_factor_1)`` in signed MW, aligned with the + device scheduler's operation-mode factor: factor 0 sits at the device's own band minimum + and factor 1 at its band maximum (see ``device_mode_commodity_ranges`` in + ``device_scheduler``). We derive the alignment from the shared running-level ordering of + ``_signed_range_endpoints``: if the mode's own band minimum is its low-running endpoint + (a consumption-led device) factor 0 is low running, otherwise (a production-led device) + factor 0 is high running, and each commodity's endpoints are ordered to match. This ties + every commodity affinely to the device's own power through the single shared factor. + """ + ranges = mode.get("commodity_power_ranges") + if not ranges: + return {} + p_low, p_high = _signed_range_endpoints( + mode.get("consumption_range"), mode.get("production_range") ) + factor_0_is_low_running = p_low <= p_high + bands: dict[str, tuple[float, float]] = {} + for entry in ranges: + c_low, c_high = _signed_range_endpoints( + entry.get("consumption_range"), entry.get("production_range") + ) + bands[entry["commodity"]] = ( + (c_low, c_high) if factor_0_is_low_running else (c_high, c_low) + ) + return bands class MetaStorageScheduler(Scheduler): diff --git a/flexmeasures/data/models/planning/tests/test_operation_modes.py b/flexmeasures/data/models/planning/tests/test_operation_modes.py index c499974d8a..3784061c0b 100644 --- a/flexmeasures/data/models/planning/tests/test_operation_modes.py +++ b/flexmeasures/data/models/planning/tests/test_operation_modes.py @@ -83,6 +83,221 @@ def test_device_scheduler_with_on_off_bands(): assert np.isclose(costs, 1.0) +def _cogeneration_modes(): + """The two operation modes of an affine cogeneration unit, sign-explicit (S2 shape). + + The unit produces electricity (its own, banded flow) and heat, while consuming gas: + + - off: everything 0 (no no-load fuel) + - on: electricity produced in [0.4, 0.5] MW and, tied by the shared operation-mode + factor, gas = 0.2 + 1.0 * |electricity| (consumed; no-load base 0.2) and + heat = 0.1 + 0.5 * |electricity| (produced; no-load base 0.1). + + Electricity and heat therefore use ``production-range`` and gas uses ``consumption-range``. + Loaded through ``OperationModeSchema`` and reduced to signed scheduler bands via the shared + ``_operation_mode_signed_band`` / ``_operation_mode_commodity_bands`` helpers. + """ + return [ + { + "production-range": ["0 MW", "0 MW"], + "commodity-power-ranges": [ + {"commodity": "gas", "consumption-range": ["0 MW", "0 MW"]}, + {"commodity": "heat", "production-range": ["0 MW", "0 MW"]}, + ], + }, + { + # electricity produced between 0.4 and 0.5 MW when on + "production-range": ["0.4 MW", "0.5 MW"], + "commodity-power-ranges": [ + # gas consumed: 0.6 at 0.4 output, 0.7 at 0.5 output (base 0.2, slope 1.0) + {"commodity": "gas", "consumption-range": ["0.6 MW", "0.7 MW"]}, + # heat produced: 0.3 at 0.4 output, 0.35 at 0.5 output (base 0.1, slope 0.5) + {"commodity": "heat", "production-range": ["0.3 MW", "0.35 MW"]}, + ], + }, + ] + + +def _cogeneration_setup( + delivery_target: float | None, + electricity_price, + gas_price: float, +): + """A unit-committed affine cogeneration unit as a multi-commodity operation mode. + + Three scheduler devices share one operation-mode binary carried by the (banded) + electricity device: electricity (device 0, the primary/banded device, producing so its + signed power is non-positive), gas (device 1, consumed) and heat (device 2, produced). + The sign-explicit modes are turned into signed scheduler bands via the storage helpers, + exactly as ``StorageScheduler`` would. + """ + from flexmeasures.data.schemas.scheduling.storage import OperationModeSchema + from flexmeasures.data.models.planning.storage import ( + _operation_mode_signed_band, + _operation_mode_commodity_bands, + ) + + 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) + + modes = [OperationModeSchema().load(mode) for mode in _cogeneration_modes()] + # Primary (electricity) signed bands and per-commodity (gas=1, heat=2) signed endpoints. + device_power_bands = [ + [_operation_mode_signed_band(mode) for mode in modes], + None, + None, + ] + commodity_index = {"gas": 1, "heat": 2} + device_mode_commodity_ranges = [ + [ + { + commodity_index[name]: band + for name, band in _operation_mode_commodity_bands(mode).items() + } + for mode in modes + ], + None, + None, + ] + + equals = pd.Series(np.nan, index=index) + if delivery_target is not None: + # Negative stock target: the unit must net-produce ``delivery_target`` MWh. + equals.iloc[-1] = -delivery_target + electricity = pd.DataFrame( + { + "min": -10, + "max": 10, + "equals": equals, + "derivative min": -0.5, # production only + "derivative max": 0, + "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 + ) + # Producing electricity earns revenue: a positive price on the deviation from a zero + # quantity makes the (negative) electricity flow lower the objective. Pricing both + # directions equally keeps the commitment convex and pins the deviation to the flow + # (the electricity device is production-only, so only the downward side is ever active). + energy_commitment = FlowCommitment( + name="energy", + index=index, + quantity=0, + upwards_deviation_price=pd.Series(electricity_price, index=index), + downwards_deviation_price=pd.Series(electricity_price, index=index), + device=pd.Series(0, index=index), + ) + # Consuming gas costs money: a positive price on the upward (consumption) deviation. + gas_commitment = FlowCommitment( + name="gas", + index=index, + quantity=0, + upwards_deviation_price=gas_price, + downwards_deviation_price=0, + device=pd.Series(1, index=index), + ) + return ( + device_constraints, + ems_constraints, + [energy_commitment, gas_commitment], + device_power_bands, + device_mode_commodity_ranges, + ) + + +def _run_cogen(delivery_target, electricity_price, gas_price): + ( + device_constraints, + ems_constraints, + commitments, + device_power_bands, + device_mode_commodity_ranges, + ) = _cogeneration_setup(delivery_target, electricity_price, gas_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 delivery target, so nothing forces the unit on. Running a step earns only + 0.4 * 1 in electricity revenue but burns gas worth 2 * (0.2 + 0.4) = 1.2, a net cost of + 0.8. 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( + delivery_target=None, + electricity_price=1.0, + gas_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 deliver a small amount of electricity, forcing minimum-level running. + + A 1.2 MWh net-production target cannot be met below the 0.4 MW mode minimum, so exactly + three steps run at 0.4 MW output. Gas (consumed) and heat (produced) follow the affine + tie including their no-load bases, and the hand-computed objective is revenue + gas cost. + Revenue is maximised by producing in the three highest-priced steps (prices [10, 1, 10, 1], + so both 10-steps and one 1-step): + + revenue = -(0.4*10 + 0.4*10 + 0.4*1) = -8.4 (negative == earned) + gas cost = 2 * (0.6 + 0.6 + 0.6) = 3.6 (0.6 gas consumed per step at 0.4) + total = -4.8 + """ + electricity, gas, heat, costs = _run_cogen( + delivery_target=1.2, + electricity_price=[10, 1, 10, 1], + gas_price=2.0, + ) + # Three steps producing at the 0.4 MW minimum (negative == production), one idle step. + assert np.isclose(sorted(electricity), [-0.4, -0.4, -0.4, 0]).all() + assert np.isclose(electricity.sum(), -1.2) + # Both high-priced steps (0 and 2) are used. + assert np.isclose(electricity[0], -0.4) and np.isclose(electricity[2], -0.4) + # Affine commodities incl. no-load base; both are zero exactly when the unit is off. + for p, g, h in zip(electricity, gas, heat): + output = -p # electricity produced (>= 0) + if np.isclose(output, 0): + assert np.isclose(g, 0) and np.isclose(h, 0) + else: + assert np.isclose(g, 0.2 + 1.0 * output) # gas consumed (positive) + assert np.isclose(h, -(0.1 + 0.5 * output)) # heat produced (negative) + assert np.isclose(costs, -4.8) + + 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 acc7a50a15..b9edb0bcd0 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -125,6 +125,79 @@ def __init__(self, *args, **kwargs): ) +class CommodityPowerRangeSchema(Schema): + """A single commodity's power range within one operation mode. + + This mirrors one entry of an S2 ``FRBC.OperationModeElement.power_ranges`` list: the + power range that this commodity's flow spans as the mode's operation-mode factor sweeps + from 0 to 1. As on the mode itself, the sign is given explicitly via ``consumption-range`` + (non-negative, positive means consumption) and/or ``production-range`` (non-negative, + positive means production). The factor sweeps all commodities in lockstep with the mode's + own power, so the endpoints are magnitude-ordered like the mode's own range (the device + scheduler resolves which endpoint pairs with which extreme of the mode's power); the + lower-magnitude endpoint is the commodity's fixed no-load flow and the difference to the + other 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')." + ), + ) + consumption_range = fields.List( + QuantityField(to_unit="MW", default_src_unit="MW", return_magnitude=False), + data_key="consumption-range", + required=False, + validate=validate.Length(equal=2), + metadata=dict( + description="Consumption power range [min, max] of this commodity within the " + "mode (non-negative; positive is consumption)." + ), + ) + production_range = fields.List( + QuantityField(to_unit="MW", default_src_unit="MW", return_magnitude=False), + data_key="production-range", + required=False, + validate=validate.Length(equal=2), + metadata=dict( + description="Production power range [min, max] of this commodity within the " + "mode (non-negative; positive is production)." + ), + ) + + @validates_schema + def check_ranges(self, data: dict, **kwargs): + cons = data.get("consumption_range") + prod = data.get("production_range") + if cons is None and prod is None: + raise ValidationError( + "A commodity power range must declare a consumption-range and/or a " + "production-range." + ) + for name, rng in (("consumption-range", cons), ("production-range", prod)): + if rng is None: + continue + if rng[0].to("MW").magnitude < 0: + raise ValidationError(f"A commodity's {name} must be non-negative.") + if rng[0] > rng[1]: + raise ValidationError( + f"The minimum of a commodity's {name} cannot exceed its maximum." + ) + if ( + cons is not None + and prod is not None + and (cons[0].to("MW").magnitude != 0 or prod[0].to("MW").magnitude != 0) + ): + raise ValidationError( + "When a commodity combines consumption-range and production-range, both " + "must start at 0 (so they form one contiguous band through zero)." + ) + + class OperationModeSchema(Schema): """One operation mode of a device, in the sense of the S2 standard. @@ -139,6 +212,14 @@ class OperationModeSchema(Schema): 883.7 W of consumption declares: [{"consumption-range": ["0 W", "0 W"]}, {"consumption-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 (production) ``consumption-range``/``production-range`` and gas and heat as + ``commodity-power-ranges``, each with a no-load base plus a proportional part. """ consumption_range = fields.List( @@ -161,6 +242,15 @@ class OperationModeSchema(Schema): "(non-negative; positive 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_ranges(self, data: dict, **kwargs): @@ -191,6 +281,15 @@ def check_ranges(self, data: dict, **kwargs): "both must start at 0 (so they form one contiguous band through zero)." ) + @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..9d054647e1 100644 --- a/flexmeasures/data/schemas/tests/test_scheduling.py +++ b/flexmeasures/data/schemas/tests/test_scheduling.py @@ -1455,3 +1455,60 @@ 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 sign-explicit per-commodity ranges (S2 shape).""" + from flexmeasures.data.schemas.scheduling.storage import OperationModeSchema + from flexmeasures.data.models.planning.storage import ( + _operation_mode_signed_band, + _operation_mode_commodity_bands, + ) + + mode = OperationModeSchema().load( + { + "production-range": ["0.4 MW", "0.5 MW"], + "commodity-power-ranges": [ + {"commodity": "gas", "consumption-range": ["0.6 MW", "0.7 MW"]}, + {"commodity": "heat", "production-range": ["0.3 MW", "0.35 MW"]}, + ], + } + ) + assert len(mode["commodity_power_ranges"]) == 2 + assert mode["commodity_power_ranges"][0]["commodity"] == "gas" + # The electricity device produces, so its band is negative and factor 0 is at full output. + assert _operation_mode_signed_band(mode) == (-0.5, -0.4) + bands = _operation_mode_commodity_bands(mode) + # gas consumed: 0.7 at factor 0 (0.5 MW output), 0.6 at factor 1 (0.4 MW output) + assert bands["gas"] == (0.7, 0.6) + # heat produced (negative): -0.35 at factor 0, -0.3 at factor 1 + assert bands["heat"] == (-0.35, -0.3) + + +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( + { + "consumption-range": ["0 MW", "1 MW"], + "commodity-power-ranges": [ + {"commodity": "gas", "consumption-range": ["0 MW", "1 MW"]}, + {"commodity": "gas", "consumption-range": ["0 MW", "2 MW"]}, + ], + } + ) + + +def test_commodity_power_range_requires_a_sign_explicit_range(app): + """A commodity range must declare a consumption-range and/or production-range.""" + from flexmeasures.data.schemas.scheduling.storage import OperationModeSchema + + with pytest.raises(ValidationError): + OperationModeSchema().load( + { + "consumption-range": ["0 MW", "1 MW"], + "commodity-power-ranges": [{"commodity": "gas"}], + } + ) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 85499466ec..2ef8f779e3 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -6204,6 +6204,37 @@ ], "additionalProperties": false }, + "CommodityPowerRange": { + "type": "object", + "properties": { + "commodity": { + "type": "string", + "description": "Name of the commodity this power range applies to (e.g. 'gas' or 'heat')." + }, + "consumption-range": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "description": "Consumption power range [min, max] of this commodity within the mode (non-negative; positive is consumption).", + "items": { + "type": "string" + } + }, + "production-range": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "description": "Production power range [min, max] of this commodity within the mode (non-negative; positive is production).", + "items": { + "type": "string" + } + } + }, + "required": [ + "commodity" + ], + "additionalProperties": false + }, "OperationMode": { "type": "object", "properties": { @@ -6224,6 +6255,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" + } } }, "additionalProperties": false