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/5] 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/5] 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 0346ec85e2590fbaeec8cb8329fadd36b1233556 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sat, 18 Jul 2026 10:14:39 +0200 Subject: [PATCH 3/5] feat: fixed (no-load / commitment) cost per operation mode Based on #2278 (operation-mode power bands). Operation modes constrain a device's power to signed bands but carry no fixed cost. Real unit-commitment has a no-load / commitment cost incurred only while a unit is on (e.g. a cogeneration unit's full-speed-no-load fuel burn). Without it, an LP/MILP that can idle a unit under-represents the cost of staying on at low output. Adds an optional per-mode `fixed-cost` (per timestep, in the flex-context currency), incurred whenever that mode's binary is active: - schema: OperationModeSchema.fixed-cost, parsed to a currency Quantity; - objective: sum over (d,b,j) of device_band[d,b,j] * fixed_cost[d,b], threaded through device_scheduler as device_band_fixed_costs, mirroring the power bands; - tests: a generator idles when marginal benefit < fixed cost, and the objective includes the fixed cost exactly when on (hand-computed); - docs: changelog + metadata field. Min-up / min-down time is out of scope (follow-up). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B --- documentation/changelog.rst | 1 + .../models/planning/linear_optimization.py | 25 ++++++ flexmeasures/data/models/planning/storage.py | 14 +++ .../planning/tests/test_operation_modes.py | 90 +++++++++++++++++++ .../data/schemas/scheduling/metadata.py | 4 +- .../data/schemas/scheduling/storage.py | 38 ++++++++ flexmeasures/ui/static/openapi-specs.json | 11 ++- 7 files changed, 179 insertions(+), 4 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 5c1557ae5b..6bd127f6f5 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -22,6 +22,7 @@ New features * 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 `_] +* Each ``operation-modes`` entry may now carry an optional ``fixed-cost``: a no-load / commitment cost (in the flex-context currency) incurred at every time step during which that mode is active, so that keeping a unit on at low output is correctly priced in unit-commitment scheduling [see `Issue #2113 `_] Infrastructure / Support ---------------------- diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 672b018ad0..17926c3353 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -44,6 +44,7 @@ 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_band_fixed_costs: list[list[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, @@ -86,6 +87,11 @@ 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_band_fixed_costs: optional per-device list of per-band fixed costs (in the commitments' + currency), incurred at every time step during which that band is active. + This models a no-load / commitment cost (e.g. the running cost of keeping a + unit on regardless of its output). Must align with ``device_power_bands``: + one cost per band. Absent entries default to 0, leaving behaviour unchanged. Potentially deprecated arguments: commitment_quantities: amounts of flow specified in commitments (both previously ordered and newly requested) @@ -342,6 +348,19 @@ def convert_commitments_to_subcommitments( if bands is not None and len(bands) > 0 } + # Look up per-band fixed costs (no-load / commitment costs) per device. + # Defaults to 0 for every band of every banded device, so that omitting the + # costs leaves the schedule and objective unchanged. + if device_band_fixed_costs is None: + device_band_fixed_costs = [None] * len(device_constraints) + fixed_cost_lookup: dict[int, list[float]] = {} + for d, bands in band_lookup.items(): + costs = device_band_fixed_costs[d] if d < len(device_band_fixed_costs) else None + if costs is None: + fixed_cost_lookup[d] = [0.0] * len(bands) + else: + fixed_cost_lookup[d] = [float(c) if c is not None else 0.0 for c in costs] + # 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( @@ -846,6 +865,12 @@ def cost_function(m): } for c in m.c: costs += m.commitment_costs[c] + # No-load / commitment costs: a fixed cost per active operation-mode band + # per time step (see S2 operation modes and device_band_fixed_costs). + for d, b in m.db: + fixed_cost = fixed_cost_lookup[d][b] + if fixed_cost: + costs += sum(m.device_band[d, b, j] * fixed_cost for j in m.j) return costs model.costs = Objective(rule=cost_function, sense=minimize) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 0e63b1099c..521e13839e 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -991,6 +991,17 @@ def device_list_series( ) for mode in operation_modes[d] ] + # Per-mode no-load / commitment cost, converted to the flex-context's + # shared currency (per time step). Absent fixed-cost defaults to 0. + shared_currency_unit = self.flex_context["shared_currency_unit"] + device_constraints[d].attrs["operation_mode_fixed_costs"] = [ + ( + float(mode["fixed_cost"].to(shared_currency_unit).magnitude) + if mode.get("fixed_cost") is not None + else 0.0 + ) + for mode in operation_modes[d] + ] if sensor_d is not None and sensor_d.get_attribute( "is_strictly_non_positive" @@ -2668,6 +2679,9 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: device_power_bands=[ dc.attrs.get("operation_modes") for dc in device_constraints ], + device_band_fixed_costs=[ + dc.attrs.get("operation_mode_fixed_costs") 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 index 119615d44f..ecea2439a8 100644 --- a/flexmeasures/data/models/planning/tests/test_operation_modes.py +++ b/flexmeasures/data/models/planning/tests/test_operation_modes.py @@ -65,6 +65,96 @@ def _schedule(device_power_bands=None, stock_target: float = 1.2): return schedule[0].values, costs +def _generator_setup(benefit_per_step: float): + """One on/off "generator" device over 2 hourly steps. + + Running (consuming 0.5 MW) yields a fixed marginal benefit per step, modelled + as a negative consumption price. There is no stock target, so the device is + free to stay off. This isolates the trade-off between the per-step marginal + benefit of running and a no-load / commitment (fixed) cost. + """ + start = pd.Timestamp("2026-01-01T00:00+01") + end = pd.Timestamp("2026-01-01T02:00+01") + resolution = pd.Timedelta("PT1H") + index = initialize_index(start=start, end=end, resolution=resolution) + + device_constraints = [ + pd.DataFrame( + { + "min": 0, + "max": 10, + "equals": np.nan, + "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, + ) + # Negative consumption price: each MW consumed for a step yields this benefit. + energy_commitment = FlowCommitment( + name="energy", + index=index, + quantity=0, + upwards_deviation_price=pd.Series(-benefit_per_step, index=index), + downwards_deviation_price=0, + device=pd.Series(0, index=index), + ) + return device_constraints, ems_constraints, energy_commitment + + +def test_operation_mode_fixed_cost_keeps_unit_idle(): + """A generator idles when its per-step marginal benefit is below the fixed cost. + + On band [0.5, 0.5] MW yields a benefit of 0.5 * 3 = 1.5 per step, but running + costs a fixed 2.0 per step (no-load / commitment cost). Since 1.5 < 2.0, the + unit-commitment optimum is to stay off. Objective is then exactly 0. + """ + device_constraints, ems_constraints, energy_commitment = _generator_setup( + benefit_per_step=3 + ) + schedule, costs, results, model = device_scheduler( + device_constraints, + ems_constraints, + commitments=[energy_commitment], + device_power_bands=[[(0, 0), (0.5, 0.5)]], + device_band_fixed_costs=[[0.0, 2.0]], + ) + assert "optimal" in str(results.solver.termination_condition) + assert np.isclose(schedule[0].values, [0, 0]).all() + assert np.isclose(costs, 0.0) + + +def test_operation_mode_fixed_cost_runs_and_is_charged(): + """A generator runs when its per-step benefit exceeds the fixed cost. + + On band [0.5, 0.5] MW yields a benefit of 0.5 * 3 = 1.5 per step, and running + costs a fixed 1.0 per step. Since 1.5 > 1.0, the unit runs both steps. + + Hand-computed objective over 2 steps: + energy benefit: 2 * (0.5 MW * -3) = -3.0 + fixed cost: 2 * (+1.0) = +2.0 + total: -1.0 + """ + device_constraints, ems_constraints, energy_commitment = _generator_setup( + benefit_per_step=3 + ) + schedule, costs, results, model = device_scheduler( + device_constraints, + ems_constraints, + commitments=[energy_commitment], + device_power_bands=[[(0, 0), (0.5, 0.5)]], + device_band_fixed_costs=[[0.0, 1.0]], + ) + assert "optimal" in str(results.solver.termination_condition) + assert np.isclose(schedule[0].values, [0.5, 0.5]).all() + assert np.isclose(costs, -1.0) + + 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) diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index 786e670279..5c97592baa 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -398,9 +398,11 @@ def to_dict(self): 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. +Each operation mode may optionally declare a ``fixed-cost``: a no-load / commitment cost (in the flex-context currency) that is incurred at every time step during which that mode is active. +This models the running cost of keeping a unit on regardless of its output (e.g. a generator's full-speed-no-load fuel burn, or a boiler's standing cost). When omitted, the fixed cost is 0. """, example=[ {"power-range": ["0 W", "0 W"]}, - {"power-range": ["883.7 W", "883.7 W"]}, + {"power-range": ["883.7 W", "883.7 W"], "fixed-cost": "1200 EUR"}, ], ) diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 69ad1935bf..cb8a3eef58 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -27,6 +27,7 @@ ur, is_power_unit, is_energy_unit, + is_currency_unit, ) ALLOWED_COMMODITIES = {"electricity", "gas"} @@ -85,6 +86,15 @@ 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"]}] + + A mode may also carry an optional ``fixed-cost``: a no-load / commitment cost + (in the flex-context currency) incurred at every time step during which the + mode is active. This models the running cost of keeping a unit on regardless + of its output (e.g. a generator's full-speed-no-load fuel burn). When absent, + the fixed cost is 0, leaving existing behaviour unchanged: + + [{"power-range": ["0 MW", "0 MW"]}, + {"power-range": ["4 MW", "55 MW"], "fixed-cost": "1200 EUR"}] """ power_range = fields.List( @@ -102,6 +112,34 @@ class OperationModeSchema(Schema): ), ) + # A currency amount, kept in its native currency unit here (agnostic to the + # flex-context currency) and converted to the shared currency by the scheduler. + fixed_cost = fields.Str( + data_key="fixed-cost", + required=False, + metadata=dict( + description="Optional no-load / commitment cost (in the flex-context " + "currency) incurred at every time step during which this operation " + "mode is active. Defaults to 0.", + ), + ) + + @post_load + def parse_fixed_cost(self, data: dict, **kwargs): + if data.get("fixed_cost") is not None: + try: + quantity = ur.Quantity(data["fixed_cost"]) + except Exception as e: + raise ValidationError( + f"Could not parse an operation mode's fixed-cost as a quantity: {e}" + ) + if not is_currency_unit(quantity.units): + raise ValidationError( + "An operation mode's fixed-cost must be a currency amount, e.g. '1200 EUR'." + ) + data["fixed_cost"] = quantity + return data + @validates_schema def check_range_order(self, data: dict, **kwargs): if data["power_range"][0] > data["power_range"][1]: diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index fc266663f9..b245c21aff 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -7,7 +7,7 @@ }, "termsOfService": null, "title": "FlexMeasures", - "version": "0.33.2" + "version": "1.0.0" }, "externalDocs": { "description": "FlexMeasures runs on the open source FlexMeasures technology. Read the docs here.", @@ -6130,6 +6130,10 @@ "items": { "type": "string" } + }, + "fixed-cost": { + "type": "string", + "description": "Optional no-load / commitment cost (in the flex-context currency) incurred at every time step during which this operation mode is active. Defaults to 0." } }, "required": [ @@ -6199,7 +6203,7 @@ "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 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.\nEach operation mode may optionally declare a fixed-cost: a no-load / commitment cost (in the flex-context currency) that is incurred at every time step during which that mode is active.\nThis models the running cost of keeping a unit on regardless of its output (e.g. a generator's full-speed-no-load fuel burn, or a boiler's standing cost). When omitted, the fixed cost is 0.\n", "example": [ { "power-range": [ @@ -6211,7 +6215,8 @@ "power-range": [ "883.7 W", "883.7 W" - ] + ], + "fixed-cost": "1200 EUR" } ], "items": { From 25b2bb41b23c22cacb4565b66996f4e83ee7f689 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sun, 19 Jul 2026 15:27:46 +0200 Subject: [PATCH 4/5] refactor: rename operation-mode fixed-cost to running-cost (S2-aligned rate) Align the per-operation-mode cost with the S2 standard and remove its resolution dependence. Previously an operation mode could carry a `fixed-cost` charged once per time step, so the same on-duration cost 4x more at 15-min than at 1-h resolution. It is now a per-time RATE, `running-cost` (e.g. "1200 EUR/h"), and the objective charges rate * timestep-duration, making the total cost of a given wall-clock on-duration resolution-independent. Renamed everywhere: schema field `fixed-cost` -> `running-cost` (parsed to a currency-per-time Quantity, validated as a currency/time rate), the scheduler attr `operation_mode_fixed_costs` -> `operation_mode_running_costs` (converted to shared-currency-per-hour) and the device_scheduler arg `device_band_fixed_costs` -> `device_band_running_costs`, objective term, tests, changelog, metadata and OpenAPI specs. Reframed to S2 semantics (FRBC.OperationModeElement.running_costs): an additional per-time cost while a mode is active, excluding commodity cost (wear / O&M / standing cost). Noted that no-load fuel is better modelled as a commodity requirement than as a running cost (possible follow-up). Tests: kept the idle-vs-run behavioural tests (objective updated to the duration-scaled value), added a cross-resolution equality test asserting an identical running-cost contribution at 1-h and 15-min, and a schema test for the currency-per-time rate validation. 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 +- .../models/planning/linear_optimization.py | 51 ++++++----- flexmeasures/data/models/planning/storage.py | 22 +++-- .../planning/tests/test_operation_modes.py | 88 ++++++++++++++++--- .../data/schemas/scheduling/metadata.py | 7 +- .../data/schemas/scheduling/storage.py | 55 +++++++----- .../data/schemas/tests/test_scheduling.py | 25 ++++++ flexmeasures/ui/static/openapi-specs.json | 8 +- 8 files changed, 189 insertions(+), 69 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index fdaddcddb0..89016b9640 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -27,7 +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 `_] -* Each ``operation-modes`` entry may now carry an optional ``fixed-cost``: a no-load / commitment cost (in the flex-context currency) incurred at every time step during which that mode is active, so that keeping a unit on at low output is correctly priced in unit-commitment scheduling [see `Issue #2113 `_] +* Each ``operation-modes`` entry may now carry an optional ``running-cost``: an additional per-time cost (a rate in the flex-context currency per hour, e.g. ``"1200 EUR/h"``) incurred while that mode is active, excluding commodity cost, following the S2 standard's ``FRBC.OperationModeElement.running_costs``, so that keeping a unit on at low output is correctly priced in unit-commitment scheduling; the per-timestep charge is scaled by the timestep duration, so the total cost is resolution-independent [see `Issue #2113 `_] * 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 `_] * Extended the scheduling job ``result`` field with a ``num-beliefs`` field reporting the total number of beliefs (scheduled values) saved to the database [see `PR #2280 `_] diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 87826e7e9e..554d21032a 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -45,7 +45,7 @@ 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_band_fixed_costs: list[list[float] | None] | None = None, + device_band_running_costs: list[list[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, @@ -88,11 +88,13 @@ 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_band_fixed_costs: optional per-device list of per-band fixed costs (in the commitments' - currency), incurred at every time step during which that band is active. - This models a no-load / commitment cost (e.g. the running cost of keeping a - unit on regardless of its output). Must align with ``device_power_bands``: - one cost per band. Absent entries default to 0, leaving behaviour unchanged. + :param device_band_running_costs: optional per-device list of per-band running costs, as a rate in the + commitments' currency per hour (see S2 FRBC.OperationModeElement.running_costs), + incurred while that band is active. This models an additional per-time cost of + keeping a unit on, excluding commodity cost (wear / O&M / standing cost). The + per-timestep charge is the rate scaled by the timestep duration, so the total + cost of a given on-duration is resolution-independent. Must align with + ``device_power_bands``: one rate per band. Absent entries default to 0. Potentially deprecated arguments: commitment_quantities: amounts of flow specified in commitments (both previously ordered and newly requested) @@ -394,18 +396,20 @@ def convert_commitments_to_subcommitments( if bands is not None and len(bands) > 0 } - # Look up per-band fixed costs (no-load / commitment costs) per device. - # Defaults to 0 for every band of every banded device, so that omitting the - # costs leaves the schedule and objective unchanged. - if device_band_fixed_costs is None: - device_band_fixed_costs = [None] * len(device_constraints) - fixed_cost_lookup: dict[int, list[float]] = {} + # Look up per-band running costs (per-hour rates) per device. Defaults to 0 + # for every band of every banded device, so that omitting the costs leaves + # the schedule and objective unchanged. + if device_band_running_costs is None: + device_band_running_costs = [None] * len(device_constraints) + running_cost_lookup: dict[int, list[float]] = {} for d, bands in band_lookup.items(): - costs = device_band_fixed_costs[d] if d < len(device_band_fixed_costs) else None + costs = ( + device_band_running_costs[d] if d < len(device_band_running_costs) else None + ) if costs is None: - fixed_cost_lookup[d] = [0.0] * len(bands) + running_cost_lookup[d] = [0.0] * len(bands) else: - fixed_cost_lookup[d] = [float(c) if c is not None else 0.0 for c in costs] + running_cost_lookup[d] = [float(c) if c is not None else 0.0 for c in costs] # Add indices for devices (d), datetimes (j) and commitments (c) model.d = RangeSet(0, len(device_constraints) - 1, doc="Set of devices") @@ -867,12 +871,19 @@ def cost_function(m): } for c in m.c: costs += m.commitment_costs[c] - # No-load / commitment costs: a fixed cost per active operation-mode band - # per time step (see S2 operation modes and device_band_fixed_costs). + # Running costs (S2 FRBC.OperationModeElement.running_costs): an + # additional per-time cost incurred while an operation-mode band is + # active, excluding commodity cost. Stored as a per-hour rate and scaled + # by the timestep duration, so the total cost of a given on-duration is + # resolution-independent. + resolution_hours = resolution.total_seconds() / 3600.0 for d, b in m.db: - fixed_cost = fixed_cost_lookup[d][b] - if fixed_cost: - costs += sum(m.device_band[d, b, j] * fixed_cost for j in m.j) + running_cost = running_cost_lookup[d][b] + if running_cost: + costs += sum( + m.device_band[d, b, j] * running_cost * resolution_hours + for j in m.j + ) return costs model.costs = Objective(rule=cost_function, sense=minimize) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 82efaf1f5e..699e9c3273 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1002,13 +1002,20 @@ def device_list_series( ) for mode in operation_modes[d] ] - # Per-mode no-load / commitment cost, converted to the flex-context's - # shared currency (per time step). Absent fixed-cost defaults to 0. + # Per-mode running cost (S2 running_costs), converted to the + # flex-context's shared currency per hour. The objective scales + # this rate by the timestep duration, so the total cost of a + # given on-duration is resolution-independent. Absent + # running-cost defaults to 0. shared_currency_unit = self.flex_context["shared_currency_unit"] - device_constraints[d].attrs["operation_mode_fixed_costs"] = [ + device_constraints[d].attrs["operation_mode_running_costs"] = [ ( - float(mode["fixed_cost"].to(shared_currency_unit).magnitude) - if mode.get("fixed_cost") is not None + float( + mode["running_cost"] + .to(f"{shared_currency_unit}/h") + .magnitude + ) + if mode.get("running_cost") is not None else 0.0 ) for mode in operation_modes[d] @@ -3200,8 +3207,9 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: device_power_bands=[ dc.attrs.get("operation_modes") for dc in device_constraints ], - device_band_fixed_costs=[ - dc.attrs.get("operation_mode_fixed_costs") for dc in device_constraints + device_band_running_costs=[ + dc.attrs.get("operation_mode_running_costs") + for dc in device_constraints ], ) if "infeasible" in (tc := scheduler_results.solver.termination_condition): diff --git a/flexmeasures/data/models/planning/tests/test_operation_modes.py b/flexmeasures/data/models/planning/tests/test_operation_modes.py index ecea2439a8..a61164d933 100644 --- a/flexmeasures/data/models/planning/tests/test_operation_modes.py +++ b/flexmeasures/data/models/planning/tests/test_operation_modes.py @@ -71,7 +71,7 @@ def _generator_setup(benefit_per_step: float): Running (consuming 0.5 MW) yields a fixed marginal benefit per step, modelled as a negative consumption price. There is no stock target, so the device is free to stay off. This isolates the trade-off between the per-step marginal - benefit of running and a no-load / commitment (fixed) cost. + benefit of running and a per-time running cost. """ start = pd.Timestamp("2026-01-01T00:00+01") end = pd.Timestamp("2026-01-01T02:00+01") @@ -107,12 +107,13 @@ def _generator_setup(benefit_per_step: float): return device_constraints, ems_constraints, energy_commitment -def test_operation_mode_fixed_cost_keeps_unit_idle(): - """A generator idles when its per-step marginal benefit is below the fixed cost. +def test_operation_mode_running_cost_keeps_unit_idle(): + """A generator idles when its per-step marginal benefit is below the running cost. - On band [0.5, 0.5] MW yields a benefit of 0.5 * 3 = 1.5 per step, but running - costs a fixed 2.0 per step (no-load / commitment cost). Since 1.5 < 2.0, the - unit-commitment optimum is to stay off. Objective is then exactly 0. + On band [0.5, 0.5] MW yields a benefit of 0.5 * 3 = 1.5 per step. The running + cost is a rate of 2.0 per hour; over an hourly step that is 2.0 per step. + Since 1.5 < 2.0, the unit-commitment optimum is to stay off. Objective is then + exactly 0. """ device_constraints, ems_constraints, energy_commitment = _generator_setup( benefit_per_step=3 @@ -122,22 +123,23 @@ def test_operation_mode_fixed_cost_keeps_unit_idle(): ems_constraints, commitments=[energy_commitment], device_power_bands=[[(0, 0), (0.5, 0.5)]], - device_band_fixed_costs=[[0.0, 2.0]], + device_band_running_costs=[[0.0, 2.0]], ) assert "optimal" in str(results.solver.termination_condition) assert np.isclose(schedule[0].values, [0, 0]).all() assert np.isclose(costs, 0.0) -def test_operation_mode_fixed_cost_runs_and_is_charged(): - """A generator runs when its per-step benefit exceeds the fixed cost. +def test_operation_mode_running_cost_runs_and_is_charged(): + """A generator runs when its per-step benefit exceeds the running cost. - On band [0.5, 0.5] MW yields a benefit of 0.5 * 3 = 1.5 per step, and running - costs a fixed 1.0 per step. Since 1.5 > 1.0, the unit runs both steps. + On band [0.5, 0.5] MW yields a benefit of 0.5 * 3 = 1.5 per step. The running + cost is a rate of 1.0 per hour; over an hourly step that is 1.0 per step. + Since 1.5 > 1.0, the unit runs both steps. - Hand-computed objective over 2 steps: + Hand-computed objective over 2 hourly steps (rate * duration = 1.0 * 1 h): energy benefit: 2 * (0.5 MW * -3) = -3.0 - fixed cost: 2 * (+1.0) = +2.0 + running cost: 2 * (1.0/h * 1 h) = +2.0 total: -1.0 """ device_constraints, ems_constraints, energy_commitment = _generator_setup( @@ -148,13 +150,71 @@ def test_operation_mode_fixed_cost_runs_and_is_charged(): ems_constraints, commitments=[energy_commitment], device_power_bands=[[(0, 0), (0.5, 0.5)]], - device_band_fixed_costs=[[0.0, 1.0]], + device_band_running_costs=[[0.0, 1.0]], ) assert "optimal" in str(results.solver.termination_condition) assert np.isclose(schedule[0].values, [0.5, 0.5]).all() assert np.isclose(costs, -1.0) +def _forced_on_running_cost(resolution: pd.Timedelta) -> float: + """Objective for a unit forced on for 2 wall-clock hours at a given resolution. + + The single band [0.5, 0.5] MW forces the unit on every step, there is no + energy price, so the whole objective is the running cost. With a rate of + 1200/h over 2 hours the total must be 2400 regardless of resolution. + """ + start = pd.Timestamp("2026-01-01T00:00+01") + end = pd.Timestamp("2026-01-01T02:00+01") + index = initialize_index(start=start, end=end, resolution=resolution) + device_constraints = [ + pd.DataFrame( + { + "min": 0, + "max": 10, + "equals": np.nan, + "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=0, + downwards_deviation_price=0, + device=pd.Series(0, index=index), + ) + schedule, costs, results, model = device_scheduler( + device_constraints, + ems_constraints, + commitments=[energy_commitment], + device_power_bands=[[(0.5, 0.5)]], # single band: forced on + device_band_running_costs=[[1200.0]], # 1200 currency/hour + ) + assert "optimal" in str(results.solver.termination_condition) + return costs + + +def test_operation_mode_running_cost_is_resolution_independent(): + """The running cost of a fixed on-duration is identical across resolutions. + + A rate of 1200/h applied to a unit forced on for 2 hours totals 2400, + whether the horizon is discretised hourly or in quarter-hours. + """ + hourly = _forced_on_running_cost(pd.Timedelta("PT1H")) + quarter_hourly = _forced_on_running_cost(pd.Timedelta("PT15M")) + assert np.isclose(hourly, 2400.0) + assert np.isclose(quarter_hourly, 2400.0) + assert np.isclose(hourly, quarter_hourly) + + 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) diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index a03fe2bcdd..8cd7d020a3 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -398,12 +398,13 @@ def to_dict(self): 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. -Each operation mode may optionally declare a ``fixed-cost``: a no-load / commitment cost (in the flex-context currency) that is incurred at every time step during which that mode is active. -This models the running cost of keeping a unit on regardless of its output (e.g. a generator's full-speed-no-load fuel burn, or a boiler's standing cost). When omitted, the fixed cost is 0. +Each operation mode may optionally declare a ``running-cost``: an additional per-time cost (a rate in the flex-context currency per hour, e.g. ``"1200 EUR/h"``) incurred while that mode is active, excluding commodity cost, following the S2 standard's `FRBC.OperationModeElement.running_costs `_. +This models the wear / O&M / standing cost of keeping a unit on regardless of its output (e.g. a boiler's standing cost). The per-timestep charge is the rate scaled by the timestep duration, so the total cost of a given on-duration is resolution-independent. When omitted, the running cost is 0. +Note: a unit's no-load *fuel* consumption is more faithfully modelled as a commodity requirement (a fuel/gas power flow in the operation mode) than as a running cost; that is a possible follow-up. """, example=[ {"power-range": ["0 W", "0 W"]}, - {"power-range": ["883.7 W", "883.7 W"], "fixed-cost": "1200 EUR"}, + {"power-range": ["883.7 W", "883.7 W"], "running-cost": "1200 EUR/h"}, ], ) GROUP = MetaData( diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 321fa85afa..56b0bbbddf 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -136,14 +136,22 @@ class OperationModeSchema(Schema): [{"power-range": ["0 W", "0 W"]}, {"power-range": ["883.7 W", "883.7 W"]}] - A mode may also carry an optional ``fixed-cost``: a no-load / commitment cost - (in the flex-context currency) incurred at every time step during which the - mode is active. This models the running cost of keeping a unit on regardless - of its output (e.g. a generator's full-speed-no-load fuel burn). When absent, - the fixed cost is 0, leaving existing behaviour unchanged: + A mode may also carry an optional ``running-cost``: an additional per-time + cost (a rate in the flex-context currency per hour, e.g. ``"1200 EUR/h"``) + incurred while the mode is active, excluding commodity cost. This mirrors the + S2 standard's ``FRBC.OperationModeElement.running_costs`` and models the wear + / O&M / standing cost of keeping a unit on regardless of its output. The + per-timestep charge is the rate scaled by the timestep duration, so the total + cost of a given on-duration is resolution-independent. When absent, the + running cost is 0, leaving existing behaviour unchanged: [{"power-range": ["0 MW", "0 MW"]}, - {"power-range": ["4 MW", "55 MW"], "fixed-cost": "1200 EUR"}] + {"power-range": ["4 MW", "55 MW"], "running-cost": "1200 EUR/h"}] + + Note: a unit's no-load *fuel* consumption is more faithfully modelled as a + commodity requirement (a fuel/gas power flow declared in the operation mode) + than as a running cost; running-cost is for non-commodity costs. That is a + possible follow-up. """ power_range = fields.List( @@ -161,32 +169,39 @@ class OperationModeSchema(Schema): ), ) - # A currency amount, kept in its native currency unit here (agnostic to the - # flex-context currency) and converted to the shared currency by the scheduler. - fixed_cost = fields.Str( - data_key="fixed-cost", + # A currency-per-time rate, kept in its native currency unit here (agnostic + # to the flex-context currency) and converted to the shared currency per hour + # by the scheduler. + running_cost = fields.Str( + data_key="running-cost", required=False, metadata=dict( - description="Optional no-load / commitment cost (in the flex-context " - "currency) incurred at every time step during which this operation " - "mode is active. Defaults to 0.", + description="Optional running cost (a rate in the flex-context " + "currency per hour, e.g. '1200 EUR/h') incurred while this operation " + "mode is active, excluding commodity cost (see S2 " + "FRBC.OperationModeElement.running_costs). Defaults to 0.", ), ) @post_load - def parse_fixed_cost(self, data: dict, **kwargs): - if data.get("fixed_cost") is not None: + def parse_running_cost(self, data: dict, **kwargs): + if data.get("running_cost") is not None: try: - quantity = ur.Quantity(data["fixed_cost"]) + quantity = ur.Quantity(data["running_cost"]) except Exception as e: raise ValidationError( - f"Could not parse an operation mode's fixed-cost as a quantity: {e}" + f"Could not parse an operation mode's running-cost as a quantity: {e}" ) - if not is_currency_unit(quantity.units): + # Require a currency-per-time rate (e.g. EUR/h), so that the cost is + # resolution-independent once scaled by the timestep duration. A rate + # times a duration reduces to a plain currency amount. + reduced = (quantity * ur.Quantity("1 hour")).to_reduced_units() + if not is_currency_unit(reduced.units): raise ValidationError( - "An operation mode's fixed-cost must be a currency amount, e.g. '1200 EUR'." + "An operation mode's running-cost must be a currency-per-time " + "rate, e.g. '1200 EUR/h'." ) - data["fixed_cost"] = quantity + data["running_cost"] = quantity return data @validates_schema diff --git a/flexmeasures/data/schemas/tests/test_scheduling.py b/flexmeasures/data/schemas/tests/test_scheduling.py index d3db401fcd..28b60cfa98 100644 --- a/flexmeasures/data/schemas/tests/test_scheduling.py +++ b/flexmeasures/data/schemas/tests/test_scheduling.py @@ -1455,3 +1455,28 @@ def test_asset_trigger_schema_rejects_malformed_flex_context(app): with pytest.raises(ValidationError) as e_info: schema.normalize_flex_context_format({"flex-context": "not-a-dict-or-list"}) assert "flex-context" in str(e_info.value) + + +@pytest.mark.parametrize( + ["running_cost", "fails", "expected_per_hour"], + [ + ("1200 EUR/h", False, 1200.0), + ("1200 EUR/hour", False, 1200.0), + ("0.5 kEUR/h", False, 500.0), + ("1200 EUR", True, None), # a plain amount is not a rate + ("50 EUR/MWh", True, None), # an energy price is not a per-time rate + ("not-a-quantity", True, None), + ], +) +def test_operation_mode_running_cost(running_cost, fails, expected_per_hour): + """OperationModeSchema.running-cost must be a currency-per-time rate.""" + from flexmeasures.data.schemas.scheduling.storage import OperationModeSchema + + schema = OperationModeSchema() + data = {"power-range": ["4 MW", "55 MW"], "running-cost": running_cost} + if fails: + with pytest.raises(ValidationError): + schema.load(data) + else: + loaded = schema.load(data) + assert loaded["running_cost"].to("EUR/hour").magnitude == expected_per_hour diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 24aa94eb27..eb01c8b131 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -6216,9 +6216,9 @@ "type": "string" } }, - "fixed-cost": { + "running-cost": { "type": "string", - "description": "Optional no-load / commitment cost (in the flex-context currency) incurred at every time step during which this operation mode is active. Defaults to 0." + "description": "Optional running cost (a rate in the flex-context currency per hour, e.g. '1200 EUR/h') incurred while this operation mode is active, excluding commodity cost (see S2 FRBC.OperationModeElement.running_costs). Defaults to 0." } }, "required": [ @@ -6300,7 +6300,7 @@ "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.\nEach operation mode may optionally declare a fixed-cost: a no-load / commitment cost (in the flex-context currency) that is incurred at every time step during which that mode is active.\nThis models the running cost of keeping a unit on regardless of its output (e.g. a generator's full-speed-no-load fuel burn, or a boiler's standing cost). When omitted, the fixed cost is 0.\n", + "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.\nEach operation mode may optionally declare a running-cost: an additional per-time cost (a rate in the flex-context currency per hour, e.g. \"1200 EUR/h\") incurred while that mode is active, excluding commodity cost, following the S2 standard's `FRBC.OperationModeElement.running_costs `_.\nThis models the wear / O&M / standing cost of keeping a unit on regardless of its output (e.g. a boiler's standing cost). The per-timestep charge is the rate scaled by the timestep duration, so the total cost of a given on-duration is resolution-independent. When omitted, the running cost is 0.\nNote: a unit's no-load fuel consumption is more faithfully modelled as a commodity requirement (a fuel/gas power flow in the operation mode) than as a running cost; that is a possible follow-up.\n", "example": [ { "power-range": [ @@ -6313,7 +6313,7 @@ "883.7 W", "883.7 W" ], - "fixed-cost": "1200 EUR" + "running-cost": "1200 EUR/h" } ], "items": { From b407fe2bf2f7e48a3737a9ad7f8bd6d01fe94e67 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 22 Jul 2026 12:05:06 +0200 Subject: [PATCH 5/5] 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" ]