diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 6944adb53f..b0f93b1118 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -32,6 +32,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 `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/documentation/features/scheduling.rst b/documentation/features/scheduling.rst index 08e62e9267..20ef5f4d86 100644 --- a/documentation/features/scheduling.rst +++ b/documentation/features/scheduling.rst @@ -286,6 +286,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 * - ``group`` - |GROUP.example| - .. include:: ../_autodoc/GROUP.rst diff --git a/flexmeasures/api/v3_0/__init__.py b/flexmeasures/api/v3_0/__init__.py index 98d4c4a22c..7256f02834 100644 --- a/flexmeasures/api/v3_0/__init__.py +++ b/flexmeasures/api/v3_0/__init__.py @@ -215,6 +215,15 @@ def create_openapi_specs(app: Flask): collapse_schema_to_field(spec, QuantitySchema, "quantity") collapse_schema_to_field(spec, TimeSeriesSchema, "timeseries") + # An operation mode must declare at least one of its two ranges + # (checked by OperationModeSchema.check_ranges); express that in the + # published contract, which apispec cannot derive from the validator. + if "OperationMode" in spec.components.schemas: + spec.components.schemas["OperationMode"]["anyOf"] = [ + {"required": ["consumption-range"]}, + {"required": ["production-range"]}, + ] + output_path = Path("flexmeasures/ui/static/openapi-specs.json") output_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 2627eb01f9..6a5f4fe80d 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -83,6 +83,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, @@ -120,6 +121,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) @@ -412,6 +418,27 @@ 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) + elif len(device_power_bands) != len(device_constraints): + raise ValueError( + f"device_power_bands lists {len(device_power_bands)} devices, " + f"while device_constraints lists {len(device_constraints)} devices." + ) + 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 + } + for d, bands in band_lookup.items(): + for band in bands: + if len(band) != 2 or band[0] > band[1]: + raise ValueError( + f"Invalid power band {band} for device {d}: " + f"expected a (min, max) pair with min <= max." + ) + # 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( @@ -819,6 +846,57 @@ 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. + # Which band it runs in (per time step) is a free binary decision variable; + # the constraints below only tie the device's power to the chosen band. + model.bd = Set( + initialize=sorted(band_lookup.keys()), + doc="Set of devices with power bands", + ) + 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, j): + """Each banded device runs in exactly one band per time step.""" + return sum(m.device_band[d, b, j] for b in range(len(band_lookup[d]))) == 1 + + def device_band_power_lower(m, d, j): + """Device power at least the chosen band's minimum. + + Exactly one band binary is 1 (see device_band_choice), so the sum + selects the minimum of the chosen band. + """ + 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, j): + """Device power at most the chosen band's maximum. + + Exactly one band binary is 1 (see device_band_choice), so the sum + selects the maximum of the chosen band. + """ + 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.bd, model.j, rule=device_band_choice) + model.device_band_power_lower = Constraint( + model.bd, model.j, rule=device_band_power_lower + ) + model.device_band_power_upper = Constraint( + model.bd, 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 e04281ec9a..734ceb506d 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -35,7 +35,10 @@ get_continuous_series_sensor_or_quantity, ) from flexmeasures.data.models.planning.exceptions import InfeasibleProblemException -from flexmeasures.data.schemas.scheduling.storage import StorageFlexModelSchema +from flexmeasures.data.schemas.scheduling.storage import ( + OperationModeSchema, + StorageFlexModelSchema, +) from flexmeasures.data.schemas.scheduling import ( CommodityFlexContextSchema, FlexContextSchema, @@ -347,6 +350,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 ] @@ -989,6 +995,13 @@ 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"] = [ + OperationModeSchema.signed_band(mode) for mode in operation_modes[d] + ] + if sensor_d is not None and sensor_d.get_attribute( "is_strictly_non_positive" ): @@ -3195,6 +3208,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..aaa6a3a7e4 --- /dev/null +++ b/flexmeasures/data/models/planning/tests/test_operation_modes.py @@ -0,0 +1,150 @@ +"""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) + + +# --- 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, +) + + +def _band(payload): + return OperationModeSchema.signed_band(OperationModeSchema().load(payload)) + + +def test_consumption_range_maps_to_positive_band(): + # The non-negative part of an 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/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 0804c35a9e..bcc2d8443c 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 @@ -4216,3 +4217,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." + ) diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index 6a84e0fcfe..0e7bfd623e 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -400,6 +400,19 @@ 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 ``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 `_. +S2 fixes one sign convention for power (positive is consumption), whereas FM leaves it to the user; an S2 signed power-range therefore maps onto these fields by sign: its non-negative part corresponds to the FM ``consumption-range``, negative S2 power values (production) correspond to the FM ``production-range`` (with their sign flipped to non-negative), and an S2 range spanning zero maps to a combination of both. +Declaring operation modes introduces binary decision variables into the optimization problem (making it a mixed-integer linear program), which may increase solve times. +""", + example=[ + {"consumption-range": ["0 W", "0 W"]}, + {"consumption-range": ["883.7 W", "883.7 W"]}, + ], +) GROUP = MetaData( description="""Reference to a group of devices whose aggregate power is constrained, given as either a power sensor (``{"sensor": }``) or an asset (``{"asset": }``) - exactly one of the two. The referenced sensor or asset should itself get its own flex-model entry defining the group's ``power-capacity`` (hard constraint) and/or ``consumption-capacity``/``production-capacity`` (soft constraints with default breach prices). diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 006a68ee28..9b797da42a 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -125,6 +125,128 @@ 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 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 fixes one sign convention for power + (positive means consumption), whereas FM leaves it to the user; an S2 + power-range therefore maps onto these fields by sign: its non-negative part + corresponds to the FM ``consumption-range``, negative S2 power values + (production) correspond to the FM ``production-range`` (with their sign + flipped to non-negative), and an S2 range spanning zero maps to a + combination of both. 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"]}] + """ + + 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 non-negative part of an " + "S2 power-range maps to this field.", + ), + ) + production_range = fields.List( + QuantityField(to_unit="MW", default_src_unit="MW", return_magnitude=False), + data_key="production-range", + required=False, + validate=validate.Length(equal=2), + metadata=dict( + description="Production power range [min, max] of this operation mode " + "(non-negative; positive is production). Negative (production) values " + "of an S2 power-range map to this field, with their sign flipped.", + ), + ) + + @staticmethod + def signed_band(mode: dict) -> tuple[float, float]: + """Convert one deserialized operation mode into a signed ``(min, max)`` + power band in MW (positive is consumption), as used by the device scheduler. + + The ``consumption-range`` maps to the positive side and the + ``production-range`` to the negative side; combining both (each validated + to start at 0) yields one band through zero ``[-production_max, +consumption_max]``. + + >>> schema = OperationModeSchema() + >>> OperationModeSchema.signed_band(schema.load({"consumption-range": ["500 kW", "2 MW"]})) + (0.5, 2.0) + >>> OperationModeSchema.signed_band(schema.load({"production-range": ["500 kW", "1 MW"]})) + (-1.0, -0.5) + >>> OperationModeSchema.signed_band(schema.load( + ... {"consumption-range": ["0 MW", "2 MW"], "production-range": ["0 MW", "1 MW"]} + ... )) + (-1.0, 2.0) + """ + 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), + ) + if prod is None: + # check_ranges guarantees at least one range upon deserialization + raise ValueError( + "An operation mode must declare a consumption-range and/or a production-range." + ) + return ( + -float(prod[1].to("MW").magnitude), + -float(prod[0].to("MW").magnitude), + ) + + @validates_schema + def check_ranges(self, data: dict, **kwargs): + cons = data.get("consumption_range") + prod = data.get("production_range") + if cons is None and prod is None: + raise ValidationError( + "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: + other = ( + "production-range" + if name == "consumption-range" + else "consumption-range" + ) + raise ValidationError( + f"An operation mode's {name} must be non-negative. " + f"To express power flowing in the opposite direction, move the negative part " + f"(with its sign flipped) to the mode's {other}; a range spanning zero " + f"becomes a combination of both, each starting at 0." + ) + 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( + "When an operation mode combines consumption-range and production-range, " + "both must start at 0 (so they form one contiguous band through zero)." + ) + + class StorageFlexModelSchema(Schema): """ This schema lists fields we require when scheduling storage assets. @@ -197,6 +319,13 @@ 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=metadata.OPERATION_MODES.to_dict(), + ) group = fields.Nested( GroupReferenceSchema, data_key="group", diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 912924aba9..78b36540ed 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -1265,7 +1265,7 @@ "/api/v3_0/sensors/{id}/annotations": { "post": { "summary": "Creates a new sensor annotation.", - "description": "This endpoint creates a new annotation on a sensor.\n", + "description": "This endpoint creates a new annotation on a sensor.\n", "security": [ { "ApiKeyAuth": [] @@ -2068,7 +2068,7 @@ "/api/v3_0/accounts/{id}/annotations": { "post": { "summary": "Creates a new account annotation.", - "description": "This endpoint creates a new annotation on an account.\n", + "description": "This endpoint creates a new annotation on an account.\n", "security": [ { "ApiKeyAuth": [] @@ -3921,7 +3921,7 @@ "/api/v3_0/assets/{id}/annotations": { "post": { "summary": "Creates a new asset annotation.", - "description": "This endpoint creates a new annotation on an asset.\n", + "description": "This endpoint creates a new annotation on an asset.\n", "security": [ { "ApiKeyAuth": [] @@ -4874,7 +4874,7 @@ "example": true }, "commitments": { - "description": "Prior commitments. Each commitment needs a name and a baseline, plus at least one deviation price (up-price and/or down-price); its commodity defaults to electricity.\nYou can find more information in the docs.\n", + "description": "Prior commitments. Each commitment needs a name and a baseline, plus at least one deviation price (up-price and/or down-price); its commodity defaults to electricity.\nYou can find more information in the docs.\n", "example": [ { "name": "capacity contract", @@ -6378,6 +6378,42 @@ ], "additionalProperties": false }, + "OperationMode": { + "type": "object", + "properties": { + "consumption-range": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "description": "Consumption power range [min, max] of this operation mode (non-negative; positive is consumption). The non-negative part of an 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). Negative (production) values of an S2 power-range map to this field, with their sign flipped.", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "consumption-range" + ] + }, + { + "required": [ + "production-range" + ] + } + ] + }, "GroupReference": { "type": "object", "properties": { @@ -6449,6 +6485,28 @@ "example": "0 kW", "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, + "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 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.\nS2 fixes one sign convention for power (positive is consumption), whereas FM leaves it to the user; an S2 signed power-range therefore maps onto these fields by sign: its non-negative part corresponds to the FM consumption-range, negative S2 power values (production) correspond to the FM production-range (with their sign flipped to non-negative), and an S2 range spanning zero maps to a combination of both.\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": [ + { + "consumption-range": [ + "0 W", + "0 W" + ] + }, + { + "consumption-range": [ + "883.7 W", + "883.7 W" + ] + } + ], + "items": { + "$ref": "#/components/schemas/OperationMode" + } + }, "group": { "description": "Reference to a group of devices whose aggregate power is constrained, given as either a power sensor ({\"sensor\": }) or an asset ({\"asset\": }) - exactly one of the two.\nThe referenced sensor or asset should itself get its own flex-model entry defining the group's power-capacity (hard constraint) and/or consumption-capacity/production-capacity (soft constraints with default breach prices).\nWhen the group is referenced by sensor, the group's scheduled aggregate power is saved to that group sensor.\nWhen the group is referenced by asset (e.g. a sub-EMS asset in the tree), the group entry defines no power sensor of its own; the group's aggregate power is instead saved via that entry's own consumption and/or production output sensors, following the usual output-sensor conventions.\n", "example": { diff --git a/flexmeasures/utils/doc_utils.py b/flexmeasures/utils/doc_utils.py index 8676c8b711..c11efb4d61 100644 --- a/flexmeasures/utils/doc_utils.py +++ b/flexmeasures/utils/doc_utils.py @@ -11,6 +11,11 @@ def rst_to_openapi(text: str) -> str: - Replaces :ref:`to some section` with "the docs" and links in the docs with a search link - Replaces :ref:`section A ` with "section A", also adding the search link for "anchor" - Removes any RST footnote references like [#]_ or [1]_ or [label]_ + - Converts external hyperlinks like `text `_ to HTML anchors: + + >>> rst_to_openapi("see `the S2 standard `_ for details") + 'see the S2 standard for details' + - Replaces :abbr:`X (Y)` with X - Converts :math:`base^{exp}` into HTML sup/sub notation for OpenAPI - Converts ``inline code`` to @@ -33,13 +38,22 @@ def ref_repl(match): search_term = title link_text = "the docs" url = DOCS_SEARCH_PATH + quote_plus(search_term) - return f'{link_text}' + return ( + f'{link_text}' + ) text = re.sub(r":ref:`([^`]+)`", ref_repl, text) # Remove footnote references text = re.sub(r"\s*\[[^\]]+?\]_", "", text) + # Convert external hyperlinks `text `_ to HTML anchors + text = re.sub( + r"(?\s\"']+)>`__?", + r'\1', + text, + ) + # Handle abbreviations def abbr_repl(match): content = match.group(1)