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 0f2dd10121fc8495a9f86b0b055f48be51dc9409 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 24 Jul 2026 15:00:16 +0200 Subject: [PATCH 4/4] tests: cover StorageScheduler-level operation-modes wiring Add integration-level tests that exercise StorageScheduler.compute() end-to-end with "operation-modes" set in the flex-model, so that a regression severing the plumbing that reads operation-modes and passes device_power_bands into device_scheduler() would be caught. Previously only LP-level tests (calling device_scheduler() directly) covered this feature, which is why a recent merge could sever the wiring without any test failing. Co-Authored-By: Claude Signed-off-by: F.N. Claessen --- .../data/models/planning/tests/test_solver.py | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 15e29f0f1e..650bf864ff 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import datetime, timedelta +from unittest import mock import pytest import pytz import logging @@ -4215,3 +4216,135 @@ def test_multi_device_battery_fails_on_unresolvable_soc_sensor( ) with pytest.raises(ValueError, match="No recent state-of-charge value"): scheduler.compute() + + +def test_battery_solver_respects_operation_mode_bands(db, add_battery_assets): + """StorageScheduler.compute() must actually wire "operation-modes" from the + flex-model through to the LP's device power bands. + + The device is confined to a discrete power band {0 MW} U [0.4 MW, 0.5 MW], and + priced/targeted such that, without the bands, the cost-optimal schedule would use + an intermediate power value (0.2 MW) outside of that band (as demonstrated at the + LP level in test_operation_modes.py::test_device_scheduler_without_bands_uses_fractional_power + and test_device_scheduler_with_min_power_band). If StorageScheduler stopped reading + "operation-modes" from the flex-model and passing device_power_bands into + device_scheduler(), this test would start seeing schedule values like 0.2 MW, + which lie outside the allowed band. + """ + _epex_da, battery = get_sensors_from_db(db, add_battery_assets) + + resolution = timedelta(hours=1) + start = pd.Timestamp("2020-03-02T00:00:00", tz="Europe/Amsterdam") + end = start + 4 * resolution + + # Cheap in steps 1 and 3, expensive in steps 0 and 2 (mirrors the LP-level fixture). + consumption_prices = pd.Series( + [10, 1, 10, 1], + index=pd.date_range(start, end, freq=resolution, inclusive="left"), + ) + + flex_model = { + "soc-at-start": "0 MWh", + "soc-min": "0 MWh", + "soc-max": "5 MWh", + "soc-targets": [{"datetime": end.isoformat(), "value": "1.2 MWh"}], + "power-capacity": "0.5 MW", + "production-capacity": "0 MW", + "consumption-capacity": "0.5 MW", + "operation-modes": [ + {"consumption-range": ["0 MW", "0 MW"]}, + {"consumption-range": ["0.4 MW", "0.5 MW"]}, + ], + } + + scheduler: Scheduler = StorageScheduler( + battery, + start, + end, + resolution, + flex_model=flex_model, + flex_context={ + "consumption-price": series_to_ts_specs(consumption_prices, unit="EUR/MWh"), + "production-price": "0 EUR/MWh", + "site-power-capacity": "2 MW", + }, + ) + schedule = scheduler.compute() + + atol = 1e-3 + for v in schedule.values: + if np.isnan(v): + continue + assert np.isclose(v, 0, atol=atol) or ( + 0.4 - atol <= v <= 0.5 + atol + ), f"Scheduled power {v} MW violates the operation-mode band {{0}} U [0.4, 0.5] MW" + + # Sanity: the target is still met, and at least one step actually lands in the + # non-zero band (i.e. the constraint was exercised, not just trivially satisfied). + assert np.isclose( + schedule.sum() * (resolution / timedelta(hours=1)), 1.2, atol=atol + ) + assert any( + 0.4 - atol <= v <= 0.5 + atol for v in schedule.values if not np.isnan(v) + ) + + +def test_battery_solver_passes_device_power_bands_to_device_scheduler( + db, add_battery_assets +): + """Regression guard for severed operation-modes wiring. + + Spies on ``device_scheduler`` (as imported into + ``flexmeasures.data.models.planning.storage``) to assert that, when the flex-model + declares "operation-modes" for a device, StorageScheduler.compute() actually + forwards a non-empty ``device_power_bands`` entry for that device. This is a + direct trap for the specific regression where StorageScheduler stops reading + "operation-modes" from the flex-model and/or stops passing device_power_bands + into device_scheduler(), even if such a change happened to not alter the schedule + values in a particular test's price/target scenario. + """ + _epex_da, battery = get_sensors_from_db(db, add_battery_assets) + + resolution = timedelta(hours=1) + start = pd.Timestamp("2020-03-03T00:00:00", tz="Europe/Amsterdam") + end = start + 4 * resolution + + flex_model = { + "soc-at-start": "0 MWh", + "soc-min": "0 MWh", + "soc-max": "5 MWh", + "power-capacity": "0.5 MW", + "operation-modes": [ + {"consumption-range": ["0 MW", "0 MW"]}, + {"consumption-range": ["0.5 MW", "0.5 MW"]}, + ], + } + + scheduler: Scheduler = StorageScheduler( + battery, + start, + end, + resolution, + flex_model=flex_model, + flex_context={ + "consumption-price": "10 EUR/MWh", + "production-price": "0 EUR/MWh", + "site-power-capacity": "2 MW", + }, + ) + + with mock.patch( + "flexmeasures.data.models.planning.storage.device_scheduler", + wraps=device_scheduler, + ) as mocked_device_scheduler: + scheduler.compute() + + assert mocked_device_scheduler.called + _, call_kwargs = mocked_device_scheduler.call_args + device_power_bands = call_kwargs.get("device_power_bands") + assert device_power_bands is not None + assert any(band for band in device_power_bands), ( + "device_scheduler() was called without any device_power_bands, even though " + "the flex-model declared operation-modes. This indicates the operation-modes " + "wiring inside StorageScheduler has been severed." + )