diff --git a/documentation/changelog.rst b/documentation/changelog.rst index b609e225d5..8e354c749d 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -26,6 +26,8 @@ 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 `_] +* 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 `PR #2327 `_] * 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/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 2627eb01f9..a681295631 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -83,6 +83,8 @@ 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, + 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, @@ -120,6 +122,18 @@ 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. + :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) @@ -412,6 +426,30 @@ 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 + } + + # 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_running_costs[d] if d < len(device_band_running_costs) else None + ) + if costs is None: + running_cost_lookup[d] = [0.0] * len(bands) + else: + 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") model.j = RangeSet( @@ -819,6 +857,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 @@ -829,6 +910,19 @@ def cost_function(m): } for c in m.c: costs += m.commitment_costs[c] + # 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: + 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 2c2211e439..0338b94bf2 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""" @@ -347,6 +373,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 +1018,34 @@ 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. + # 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"] = [ + _operation_mode_signed_band(mode) for mode in operation_modes[d] + ] + # 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_running_costs"] = [ + ( + 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] + ] + if sensor_d is not None and sensor_d.get_attribute( "is_strictly_non_positive" ): @@ -3172,6 +3229,13 @@ 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 + ], + 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): 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..76eca7f9d4 --- /dev/null +++ b/flexmeasures/data/models/planning/tests/test_operation_modes.py @@ -0,0 +1,303 @@ +"""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 _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 per-time running 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_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. 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 + ) + schedule, costs, results, model = device_scheduler( + device_constraints, + ems_constraints, + commitments=[energy_commitment], + device_power_bands=[[(0, 0), (0.5, 0.5)]], + 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_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. 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 hourly steps (rate * duration = 1.0 * 1 h): + energy benefit: 2 * (0.5 MW * -3) = -3.0 + running cost: 2 * (1.0/h * 1 h) = +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_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) + # 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, +) +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 be4e9d5452..ee555dfcbd 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -392,6 +392,21 @@ 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 `_; 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. +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=[ + {"consumption-range": ["0 W", "0 W"]}, + {"consumption-range": ["883.7 W", "883.7 W"], "running-cost": "1200 EUR/h"}, + ], +) 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..5c7f6e1342 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -29,6 +29,7 @@ ur, is_power_unit, is_energy_unit, + is_currency_unit, ) ALLOWED_COMMODITIES = {"electricity", "gas"} @@ -125,6 +126,125 @@ 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'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"]}] + + 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: + + [{"consumption-range": ["0 MW", "0 MW"]}, + {"consumption-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. + """ + + 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.", + ), + ) + 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).", + ), + ) + + # 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 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_running_cost(self, data: dict, **kwargs): + if data.get("running_cost") is not None: + try: + quantity = ur.Quantity(data["running_cost"]) + except Exception as e: + raise ValidationError( + f"Could not parse an operation mode's running-cost as a quantity: {e}" + ) + # 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 running-cost must be a currency-per-time " + "rate, e.g. '1200 EUR/h'." + ) + data["running_cost"] = quantity + return data + + @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: + 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( + "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 +317,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/data/schemas/tests/test_scheduling.py b/flexmeasures/data/schemas/tests/test_scheduling.py index d3db401fcd..fc5298f105 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 = {"consumption-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 854f57305b..acea593a59 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -6204,6 +6204,34 @@ ], "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 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" + } + }, + "running-cost": { + "type": "string", + "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." + } + }, + "additionalProperties": false + }, "GroupReference": { "type": "object", "properties": { @@ -6275,6 +6303,29 @@ "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 `_; 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.\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": [ + { + "consumption-range": [ + "0 W", + "0 W" + ] + }, + { + "consumption-range": [ + "883.7 W", + "883.7 W" + ], + "running-cost": "1200 EUR/h" + } + ], + "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": {