From 88650902e9e5cc1dcfb862351bc057aa1c207068 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 10 Jul 2026 16:56:14 +0200 Subject: [PATCH 1/4] feat: confine device power to operation-mode power bands (#2113) New storage flex-model field "operation-modes" (S2 terminology): a list of signed power ranges; the device must operate within one of them at every time step. Adds one binary per device per band per time step to the device scheduler, so devices that cannot modulate below a minimum power (or are strictly on/off) no longer receive fractional schedules that their control layer must round up, overshooting site capacity limits. Co-Authored-By: Claude Fable 5 --- .../models/planning/linear_optimization.py | 58 ++++++++++ flexmeasures/data/models/planning/storage.py | 17 +++ .../planning/tests/test_operation_modes.py | 101 ++++++++++++++++++ .../data/schemas/scheduling/storage.py | 47 ++++++++ flexmeasures/ui/static/openapi-specs.json | 28 ++++- 5 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 flexmeasures/data/models/planning/tests/test_operation_modes.py diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 12b33fd70c..672b018ad0 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -43,6 +43,7 @@ def device_scheduler( # noqa C901 initial_stock: float | list[float] = 0, stock_groups: dict[int, list[int]] | None = None, ems_constraint_groups: list[list[int]] | None = None, + device_power_bands: list[list[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, @@ -80,6 +81,11 @@ def device_scheduler( # noqa C901 device: 0 (corresponds to device d; if not set, commitment is on an EMS level) :param initial_stock: initial stock for each device. Use a list with the same number of devices as device_constraints, or use a single value to set the initial stock to be the same for all devices. + :param device_power_bands: optional per-device list of signed power bands (min, max), in flow units + (e.g. MW, positive for consumption). A device with bands must operate within + 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. Potentially deprecated arguments: commitment_quantities: amounts of flow specified in commitments (both previously ordered and newly requested) @@ -327,6 +333,15 @@ def convert_commitments_to_subcommitments( device_constraints[d]["stock delta"].astype(float).fillna(0) ) + # Look up power bands (S2 operation modes) per device + if device_power_bands is None: + device_power_bands = [None] * len(device_constraints) + band_lookup: dict[int, list[tuple[float, float]]] = { + d: list(bands) + for d, bands in enumerate(device_power_bands) + if bands is not None and len(bands) > 0 + } + # 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( @@ -778,6 +793,49 @@ def device_derivative_equalities(m, d, j): model.d, model.j, rule=device_derivative_equalities ) + # Power bands (S2 operation modes): a banded device must operate within + # exactly one of its declared signed power ranges at every time step. + model.db = Set( + dimen=2, + initialize=lambda m: ( + (d, b) for d, bands in band_lookup.items() for b in range(len(bands)) + ), + doc="Set of (device, band) pairs for devices with power bands", + ) + model.device_band = Var(model.db, model.j, domain=Binary, initialize=0) + + def device_band_choice(m, d, b, j): + """Each banded device runs in exactly one band per time step (tied to band 0).""" + if b != 0: + return Constraint.Skip + return sum(m.device_band[d, b_, j] for b_ in range(len(band_lookup[d]))) == 1 + + def device_band_power_lower(m, d, b, j): + """Device power at least the chosen band's minimum.""" + 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] + for b_ in range(len(band_lookup[d])) + ) + + def device_band_power_upper(m, d, b, j): + """Device power at most the chosen band's maximum.""" + 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_][1] + for b_ in range(len(band_lookup[d])) + ) + + model.device_band_choice = Constraint(model.db, model.j, rule=device_band_choice) + model.device_band_power_lower = Constraint( + model.db, model.j, rule=device_band_power_lower + ) + model.device_band_power_upper = Constraint( + model.db, model.j, rule=device_band_power_upper + ) + # 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 dc0c02b7f7..0e63b1099c 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -292,6 +292,9 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 production_capacity = [ flex_model_d.get("production_capacity") for flex_model_d in flex_model ] + operation_modes = [ + flex_model_d.get("operation_modes") for flex_model_d in flex_model + ] charging_efficiency = [ flex_model_d.get("charging_efficiency") for flex_model_d in flex_model ] @@ -978,6 +981,17 @@ def device_list_series( device_constraints[d]["derivative max"] = power_capacity_in_mw[d] device_constraints[d]["derivative min"] = -power_capacity_in_mw[d] + # Power bands (S2 operation modes): carried on the constraints frame, + # in signed MW (positive is consumption), for the device scheduler. + if operation_modes[d]: + device_constraints[d].attrs["operation_modes"] = [ + ( + float(mode["power_range"][0].to("MW").magnitude), + float(mode["power_range"][1].to("MW").magnitude), + ) + for mode in operation_modes[d] + ] + if sensor_d is not None and sensor_d.get_attribute( "is_strictly_non_positive" ): @@ -2651,6 +2665,9 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: commitments=commitments, initial_stock=initial_stock, stock_groups=self.stock_groups, + device_power_bands=[ + dc.attrs.get("operation_modes") for dc in device_constraints + ], ) if "infeasible" in (tc := scheduler_results.solver.termination_condition): raise InfeasibleProblemException(tc) diff --git a/flexmeasures/data/models/planning/tests/test_operation_modes.py b/flexmeasures/data/models/planning/tests/test_operation_modes.py new file mode 100644 index 0000000000..119615d44f --- /dev/null +++ b/flexmeasures/data/models/planning/tests/test_operation_modes.py @@ -0,0 +1,101 @@ +"""Tests for power bands (S2 operation modes) in the device scheduler.""" + +import numpy as np +import pandas as pd + +from flexmeasures.data.models.planning import FlowCommitment +from flexmeasures.data.models.planning.linear_optimization import device_scheduler +from flexmeasures.data.models.planning.utils import initialize_index + + +def _one_device_setup(stock_target: float): + """One storage device charging towards a stock target over 4 hourly steps. + + Consumption is priced per step: cheap in steps 1 and 3, expensive in 0 and 2. + """ + 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) + equals.iloc[-1] = stock_target + device_constraints = [ + pd.DataFrame( + { + "min": 0, + "max": 10, + "equals": equals, + "derivative min": 0, + "derivative max": 0.5, + "derivative equals": np.nan, + }, + index=index, + ) + ] + ems_constraints = pd.DataFrame( + { + "derivative min": -10, + "derivative max": 10, + }, + index=index, + ) + energy_commitment = FlowCommitment( + name="energy", + index=index, + quantity=0, + upwards_deviation_price=pd.Series([10, 1, 10, 1], index=index), + downwards_deviation_price=0, + device=pd.Series(0, index=index), + ) + return device_constraints, ems_constraints, energy_commitment + + +def _schedule(device_power_bands=None, stock_target: float = 1.2): + device_constraints, ems_constraints, energy_commitment = _one_device_setup( + stock_target + ) + schedule, costs, results, model = device_scheduler( + device_constraints, + ems_constraints, + commitments=[energy_commitment], + device_power_bands=device_power_bands, + ) + assert "optimal" in str(results.solver.termination_condition) + return schedule[0].values, costs + + +def test_device_scheduler_without_bands_uses_fractional_power(): + """Sanity check: without bands, the cheapest plan uses fractional power (0.2).""" + values, costs = _schedule(stock_target=1.2) + # Cheap steps maxed out (0.5 each); the 0.2 remainder lands in the expensive steps + assert np.isclose(values[1], 0.5) and np.isclose(values[3], 0.5) + assert np.isclose(values[0] + values[2], 0.2) + assert np.isclose(costs, 1.0 + 2.0) + + +def test_device_scheduler_with_on_off_bands(): + """A device confined to {0} U {0.5} runs full-on where cheap, never fractionally.""" + values, costs = _schedule( + device_power_bands=[[(0, 0), (0.5, 0.5)]], stock_target=1.0 + ) + assert np.isclose(values, [0, 0.5, 0, 0.5]).all() + assert np.isclose(costs, 1.0) + + +def test_device_scheduler_with_min_power_band(): + """A device confined to {0} U [0.4, 0.5] cannot run below its minimum power. + + To reach the 1.2 stock target, running the two cheap steps at 0.4 plus one + expensive step at 0.4 (cost 4.8) beats maxing out the cheap steps, because + the 0.2 remainder would have to be rounded up to the 0.4 band minimum + (0.5 + 0.5 + 0.4 sums to 1.4, overshooting the exact stock target). + """ + values, costs = _schedule( + device_power_bands=[[(0, 0), (0.4, 0.5)]], stock_target=1.2 + ) + for v in values: + assert np.isclose(v, 0) or (0.4 - 1e-6 <= v <= 0.5 + 1e-6) + assert np.isclose(values.sum(), 1.2) + assert np.isclose(sorted(values), [0, 0.4, 0.4, 0.4]).all() + assert np.isclose(costs, 4.8) diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 60d9da3448..a1b1351480 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -76,6 +76,40 @@ def __init__(self, *args, **kwargs): ) +class OperationModeSchema(Schema): + """One operation mode of a device, in the sense of the S2 standard. + + A device with operation modes can only run at a power within one of the + declared modes' power ranges at any given time. The power range is signed: + positive values denote consumption and negative values denote production. + 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"]}] + """ + + 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 [min, max] of this operation mode " + "(positive is consumption, negative is production).", + ), + ) + + @validates_schema + def check_range_order(self, data: dict, **kwargs): + if data["power_range"][0] > data["power_range"][1]: + raise ValidationError( + "The minimum of an operation mode's power-range cannot exceed its maximum." + ) + + class StorageFlexModelSchema(Schema): """ This schema lists fields we require when scheduling storage assets. @@ -148,6 +182,19 @@ class StorageFlexModelSchema(Schema): metadata=metadata.PRODUCTION_CAPACITY.to_dict(), ) + operation_modes = fields.List( + fields.Nested(OperationModeSchema()), + data_key="operation-modes", + required=False, + validate=validate.Length(min=1), + metadata=dict( + description="Operation modes (S2 terminology) confining the device's " + "power to a set of power bands, e.g. a device that cannot modulate " + "below a minimum power. The device must operate within one of the " + "declared power ranges at every time step.", + ), + ) + # Activation prices prefer_curtailing_later = fields.Bool( data_key="prefer-curtailing-later", diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index be45760153..9eeba4c9ab 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -7,7 +7,7 @@ }, "termsOfService": null, "title": "FlexMeasures", - "version": "1.0.0" + "version": "0.33.2" }, "externalDocs": { "description": "FlexMeasures runs on the open source FlexMeasures technology. Read the docs here.", @@ -6119,6 +6119,24 @@ ], "additionalProperties": false }, + "OperationMode": { + "type": "object", + "properties": { + "power-range": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "description": "Signed power range [min, max] of this operation mode (positive is consumption, negative is production).", + "items": { + "type": "string" + } + } + }, + "required": [ + "power-range" + ], + "additionalProperties": false + }, "StorageFlexModelSchemaOpenAPI": { "type": "object", "properties": { @@ -6178,6 +6196,14 @@ "example": "0 kW", "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, + "operation-modes": { + "type": "array", + "minItems": 1, + "description": "Operation modes (S2 terminology) confining the device's power to a set of power bands, e.g. a device that cannot modulate below a minimum power. The device must operate within one of the declared power ranges at every time step.", + "items": { + "$ref": "#/components/schemas/OperationMode" + } + }, "prefer-curtailing-later": { "type": "boolean", "default": true, From babbda9ae174814fd022d2f9b20f49a8548420ac Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 10 Jul 2026 17:25:43 +0200 Subject: [PATCH 2/4] docs: document the operation-modes storage flex-model field Adds the field to the storage flex-model table (via a new OPERATION_MODES MetaData entry, which the schema now also uses for its API docs), including the sign convention, an on/off device example, the MILP note, and a reference to the S2 standard's FRBC OperationMode concept. Also adds a changelog entry. Co-Authored-By: Claude Fable 5 --- documentation/changelog.rst | 1 + documentation/features/scheduling.rst | 3 +++ flexmeasures/data/schemas/scheduling/metadata.py | 12 ++++++++++++ flexmeasures/data/schemas/scheduling/storage.py | 7 +------ flexmeasures/ui/static/openapi-specs.json | 16 +++++++++++++++- 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 7f020f3647..5c1557ae5b 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -21,6 +21,7 @@ New features * The flex-context can now define multiple commodities, each specifying their own prices and grid capacities [see `PR #1946 `_, `PR #2172 `_, `PR #2235 `_ and `PR #2271 `_] * CLI support for adding/editing account attributes [see `PR #2242 `_] * Extended ``GET /api/v3_0/jobs/`` with a ``result`` field containing ``unresolved`` and ``resolved`` soft state-of-charge constraint analysis (``soc-minima``/``soc-maxima`` violations or satisfied constraints, keyed by asset ID) for scheduling jobs; both arrays are empty when no SoC constraints were defined [see `PR #2072 `_] +* 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 `_] Infrastructure / Support ---------------------- diff --git a/documentation/features/scheduling.rst b/documentation/features/scheduling.rst index aec2ce3397..57fc368036 100644 --- a/documentation/features/scheduling.rst +++ b/documentation/features/scheduling.rst @@ -259,6 +259,9 @@ For more details on the possible formats for field values, see :ref:`variable_qu * - ``production-capacity`` - |PRODUCTION_CAPACITY.example| (only consumption) - .. include:: ../_autodoc/PRODUCTION_CAPACITY.rst + * - ``operation-modes`` + - |OPERATION_MODES.example| + - .. include:: ../_autodoc/OPERATION_MODES.rst .. [#quantity_field] Can only be set as a fixed quantity. diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index 4f6f9d4298..786e670279 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -392,3 +392,15 @@ def to_dict(self): """, example="0 kW", ) +OPERATION_MODES = MetaData( + description="""Confine the device's power to one of several power ranges at every time step. +Each operation mode declares a signed power range (positive is consumption, negative is production). +This is useful for devices that cannot modulate their power freely, such as a device that is either off or running at some minimum power (or at one fixed power). +Terminology and semantics follow the `operation modes of the S2 standard `_. +Declaring operation modes introduces binary decision variables into the optimization problem (making it a mixed-integer linear program), which may increase solve times. +""", + example=[ + {"power-range": ["0 W", "0 W"]}, + {"power-range": ["883.7 W", "883.7 W"]}, + ], +) diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index a1b1351480..69ad1935bf 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -187,12 +187,7 @@ class StorageFlexModelSchema(Schema): data_key="operation-modes", required=False, validate=validate.Length(min=1), - metadata=dict( - description="Operation modes (S2 terminology) confining the device's " - "power to a set of power bands, e.g. a device that cannot modulate " - "below a minimum power. The device must operate within one of the " - "declared power ranges at every time step.", - ), + metadata=metadata.OPERATION_MODES.to_dict(), ) # Activation prices diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 9eeba4c9ab..fc266663f9 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -6199,7 +6199,21 @@ "operation-modes": { "type": "array", "minItems": 1, - "description": "Operation modes (S2 terminology) confining the device's power to a set of power bands, e.g. a device that cannot modulate below a minimum power. The device must operate within one of the declared power ranges at every time step.", + "description": "Confine the device's power to one of several power ranges at every time step.\nEach operation mode declares a signed power range (positive is consumption, negative is production).\nThis is useful for devices that cannot modulate their power freely, such as a device that is either off or running at some minimum power (or at one fixed power).\nTerminology and semantics follow the `operation modes of the S2 standard `_.\nDeclaring operation modes introduces binary decision variables into the optimization problem (making it a mixed-integer linear program), which may increase solve times.\n", + "example": [ + { + "power-range": [ + "0 W", + "0 W" + ] + }, + { + "power-range": [ + "883.7 W", + "883.7 W" + ] + } + ], "items": { "$ref": "#/components/schemas/OperationMode" } From b407fe2bf2f7e48a3737a9ad7f8bd6d01fe94e67 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 22 Jul 2026 12:05:06 +0200 Subject: [PATCH 3/4] Address review: sign-explicit operation-mode ranges Replace the single signed `power-range` on an operation mode with explicit `consumption-range` (positive = consumption) and/or `production-range` (positive = production). A mode may use either or both; combining both (each starting at 0) forms one band through zero. Document that the S2 power-range maps to the FM consumption-range (S2 fixes one sign convention; FM leaves it to the user). Also update the changelog reference to PR #2278. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B Signed-off-by: F.N. Claessen --- documentation/changelog.rst | 2 +- flexmeasures/data/models/planning/storage.py | 35 ++++++++-- .../planning/tests/test_operation_modes.py | 52 ++++++++++++++ .../data/schemas/scheduling/metadata.py | 8 +-- .../data/schemas/scheduling/storage.py | 69 ++++++++++++++----- flexmeasures/ui/static/openapi-specs.json | 22 +++--- 6 files changed, 152 insertions(+), 36 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 0de5a5e48f..6cbc733e1e 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -26,7 +26,7 @@ New features * CLI support for adding/editing account attributes [see `PR #2242 `_] * 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 `_] +* 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 `_] * 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/storage.py b/flexmeasures/data/models/planning/storage.py index f21d18fd78..b73f58e4fd 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -69,6 +69,32 @@ 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]``. + """ + cons = mode.get("consumption_range") + prod = mode.get("production_range") + if cons and prod: + return ( + -float(prod[1].to("MW").magnitude), + float(cons[1].to("MW").magnitude), + ) + if cons: + return ( + float(cons[0].to("MW").magnitude), + float(cons[1].to("MW").magnitude), + ) + return ( + -float(prod[1].to("MW").magnitude), + -float(prod[0].to("MW").magnitude), + ) + + class MetaStorageScheduler(Scheduler): """This class defines the constraints of a schedule for a storage device from the flex-model, flex-context, and sensor and asset attributes""" @@ -994,13 +1020,12 @@ def device_list_series( # Power bands (S2 operation modes): carried on the constraints frame, # in signed MW (positive is consumption), for the device scheduler. + # A mode's consumption-range maps to the positive side, its + # production-range to the negative side; combining both (each starting + # at 0) forms one band through zero [-production_max, +consumption_max]. if operation_modes[d]: device_constraints[d].attrs["operation_modes"] = [ - ( - float(mode["power_range"][0].to("MW").magnitude), - float(mode["power_range"][1].to("MW").magnitude), - ) - for mode in operation_modes[d] + _operation_mode_signed_band(mode) for mode in operation_modes[d] ] if sensor_d is not None and sensor_d.get_attribute( diff --git a/flexmeasures/data/models/planning/tests/test_operation_modes.py b/flexmeasures/data/models/planning/tests/test_operation_modes.py index 119615d44f..c499974d8a 100644 --- a/flexmeasures/data/models/planning/tests/test_operation_modes.py +++ b/flexmeasures/data/models/planning/tests/test_operation_modes.py @@ -99,3 +99,55 @@ def test_device_scheduler_with_min_power_band(): assert np.isclose(values.sum(), 1.2) assert np.isclose(sorted(values), [0, 0.4, 0.4, 0.4]).all() assert np.isclose(costs, 4.8) + + +# --- schema: consumption-range / production-range -> signed band ----------------- + +import pytest # noqa: E402 +from marshmallow import ValidationError # noqa: E402 + +from flexmeasures.data.schemas.scheduling.storage import ( # noqa: E402 + OperationModeSchema, +) +from flexmeasures.data.models.planning.storage import ( # noqa: E402 + _operation_mode_signed_band, +) + + +def _band(payload): + return _operation_mode_signed_band(OperationModeSchema().load(payload)) + + +def test_consumption_range_maps_to_positive_band(): + # The S2 signed power-range maps to the FM consumption-range. + assert _band({"consumption-range": ["0 MW", "10 MW"]}) == (0.0, 10.0) + + +def test_production_range_maps_to_negative_band(): + assert _band({"production-range": ["4 MW", "55 MW"]}) == (-55.0, -4.0) + + +def test_combined_ranges_form_a_band_through_zero(): + assert _band( + {"consumption-range": ["0 MW", "20 MW"], "production-range": ["0 MW", "55 MW"]} + ) == (-55.0, 20.0) + + +def test_operation_mode_requires_at_least_one_range(): + with pytest.raises(ValidationError): + OperationModeSchema().load({}) + + +def test_combined_ranges_must_start_at_zero(): + with pytest.raises(ValidationError, match="contiguous band"): + OperationModeSchema().load( + { + "consumption-range": ["1 MW", "20 MW"], + "production-range": ["0 MW", "55 MW"], + } + ) + + +def test_range_min_cannot_exceed_max(): + with pytest.raises(ValidationError): + OperationModeSchema().load({"consumption-range": ["10 MW", "5 MW"]}) diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index 60ce1f13d6..4cb8085659 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -394,14 +394,14 @@ def to_dict(self): ) OPERATION_MODES = MetaData( description="""Confine the device's power to one of several power ranges at every time step. -Each operation mode declares a signed power range (positive is consumption, negative is production). +Each operation mode declares a ``consumption-range`` (non-negative, positive is consumption) and/or a ``production-range`` (non-negative, positive is production); a mode may use either or both, and combining both (each starting at 0) forms a single band through zero. This is useful for devices that cannot modulate their power freely, such as a device that is either off or running at some minimum power (or at one fixed power). -Terminology and semantics follow the `operation modes of the S2 standard `_. +Terminology and semantics follow the `operation modes of the S2 standard `_; the S2 signed power-range maps to the FM ``consumption-range`` (S2 fixes one sign convention for power, whereas FM leaves it to the user). Declaring operation modes introduces binary decision variables into the optimization problem (making it a mixed-integer linear program), which may increase solve times. """, example=[ - {"power-range": ["0 W", "0 W"]}, - {"power-range": ["883.7 W", "883.7 W"]}, + {"consumption-range": ["0 W", "0 W"]}, + {"consumption-range": ["883.7 W", "883.7 W"]}, ], ) GROUP = MetaData( diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index b2821ad748..acc7a50a15 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -128,34 +128,67 @@ def __init__(self, *args, **kwargs): class OperationModeSchema(Schema): """One operation mode of a device, in the sense of the S2 standard. - A device with operation modes can only run at a power within one of the - declared modes' power ranges at any given time. The power range is signed: - positive values denote consumption and negative values denote production. - 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"]}] + A device with operation modes can only run within one of the declared modes' + power ranges at any given time. Each range is given with an explicit sign + convention: ``consumption-range`` (non-negative, positive means consumption) + and/or ``production-range`` (non-negative, positive means production). A mode + may use either or both; using both forms a single band through zero (so both + must then start at 0). The S2 standard's signed power-range maps to the FM + ``consumption-range`` (S2 fixes one sign convention for power, whereas FM + leaves it to the user). A device that can only be off or run at exactly + 883.7 W of consumption declares: + + [{"consumption-range": ["0 W", "0 W"]}, {"consumption-range": ["883.7 W", "883.7 W"]}] """ - power_range = fields.List( - QuantityField( - to_unit="MW", - default_src_unit="MW", - return_magnitude=False, + 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 operation mode " + "(non-negative; positive is consumption). The S2 power-range maps to this field.", ), - data_key="power-range", - required=True, + ) + 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="Signed power range [min, max] of this operation mode " - "(positive is consumption, negative is production).", + description="Production power range [min, max] of this operation mode " + "(non-negative; positive is production).", ), ) @validates_schema - def check_range_order(self, data: dict, **kwargs): - if data["power_range"][0] > data["power_range"][1]: + 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( + "An operation mode 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"An operation mode's {name} must be non-negative." + ) + if rng[0] > rng[1]: + raise ValidationError( + f"The minimum of an operation mode'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( - "The minimum of an operation mode's power-range cannot exceed its maximum." + "When an operation mode combines consumption-range and production-range, " + "both must start at 0 (so they form one contiguous band through zero)." ) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index a2a16a7f01..85499466ec 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -6207,19 +6207,25 @@ "OperationMode": { "type": "object", "properties": { - "power-range": { + "consumption-range": { "type": "array", "minItems": 2, "maxItems": 2, - "description": "Signed power range [min, max] of this operation mode (positive is consumption, negative is production).", + "description": "Consumption power range [min, max] of this operation mode (non-negative; positive is consumption). The S2 power-range maps to this field.", + "items": { + "type": "string" + } + }, + "production-range": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "description": "Production power range [min, max] of this operation mode (non-negative; positive is production).", "items": { "type": "string" } } }, - "required": [ - "power-range" - ], "additionalProperties": false }, "GroupReference": { @@ -6296,16 +6302,16 @@ "operation-modes": { "type": "array", "minItems": 1, - "description": "Confine the device's power to one of several power ranges at every time step.\nEach operation mode declares a signed power range (positive is consumption, negative is production).\nThis is useful for devices that cannot modulate their power freely, such as a device that is either off or running at some minimum power (or at one fixed power).\nTerminology and semantics follow the `operation modes of the S2 standard `_.\nDeclaring operation modes introduces binary decision variables into the optimization problem (making it a mixed-integer linear program), which may increase solve times.\n", + "description": "Confine the device's power to one of several power ranges at every time step.\nEach operation mode declares a consumption-range (non-negative, positive is consumption) and/or a production-range (non-negative, positive is production); a mode may use either or both, and combining both (each starting at 0) forms a single band through zero.\nThis is useful for devices that cannot modulate their power freely, such as a device that is either off or running at some minimum power (or at one fixed power).\nTerminology and semantics follow the `operation modes of the S2 standard `_; the S2 signed power-range maps to the FM consumption-range (S2 fixes one sign convention for power, whereas FM leaves it to the user).\nDeclaring operation modes introduces binary decision variables into the optimization problem (making it a mixed-integer linear program), which may increase solve times.\n", "example": [ { - "power-range": [ + "consumption-range": [ "0 W", "0 W" ] }, { - "power-range": [ + "consumption-range": [ "883.7 W", "883.7 W" ] From e179c7fa58b7e4a6f9a77cd9287a7749bb0197a5 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 22 Jul 2026 12:07:38 +0200 Subject: [PATCH 4/4] 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": [