diff --git a/documentation/changelog.rst b/documentation/changelog.rst index b609e225d5..9335cca73d 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 `_] +* Generalise operation modes across commodities: an operation mode may carry per-commodity power ranges (``commodity-power-ranges``, following S2's ``FRBC.OperationModeElement.power_ranges``, with the same sign-explicit ``consumption-range``/``production-range`` keys), and the device scheduler ties the commodities affinely through a single operation-mode factor gated by the mode's binary, so a unit-committed affine cogeneration unit (with a per-commodity no-load flow plus a minimum level when on) can be expressed natively [see `Issue #2113 `_] * New ``FLEXMEASURES_LP_SOLVER_OPTIONS`` config setting to pass solver options to the scheduling solver, validated against the installed HiGHS build so that unknown or unsupported options raise instead of being silently ignored [see `PR #2283 `_] * Add support for intermediate power constraints on groups of devices, via a new ``group`` field in the storage flex-model [see `PR #2276 `_ and `issue #2092 `_] * The ``group`` field now also accepts a ``{"asset": }`` reference (in addition to ``{"sensor": }``), allowing intermediate power constraints to be defined entirely from flex-models stored on the asset tree, with results saved via the group's ``consumption``/``production`` output sensors, without needing any flex-model in the scheduling trigger [see `issue #2092 `_] diff --git a/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..b5afa4ad70 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -83,6 +83,10 @@ 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_mode_commodity_ranges: ( + list[list[dict[int, tuple[float, float]]] | None] | None + ) = None, ) -> tuple[list[pd.Series], float, SolverResults, ConcreteModel]: """This generic device scheduler is able to handle an EMS with multiple devices, with various types of constraints on the EMS level and on the device level, @@ -120,6 +124,31 @@ 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_mode_commodity_ranges: + optional per-device generalisation of ``device_power_bands`` to + *multiple commodities* (S2 ``FRBC.OperationModeElement.power_ranges``). + For a device ``d`` that carries operation modes, this is a list parallel + to ``device_power_bands[d]`` (one entry per mode/band). Each entry maps + *other* device indices (the coupled commodities, e.g. the fuel and heat + flows of a cogeneration unit) to a signed pair ``(c0, c1)`` in flow units, + giving that commodity's flow at the mode's two factor extremes: ``c0`` at + factor 0 (the banded device's own band minimum) and ``c1`` at factor 1 (its + band maximum). Sign is resolved upstream from the mode's + ``consumption-range``/``production-range`` keys, so these values are already + signed and need not be ordered. When mode ``b`` is active, a single + operation-mode factor ``lambda in [0, 1]`` (shared across all commodities of + that mode) sets the banded device's own power to + ``pmin_b + lambda * (pmax_b - pmin_b)`` and each coupled commodity's power to + ``c0 + lambda * (c1 - c0)``. This ties the commodities affinely: the factor-0 + endpoints are the per-commodity fixed no-load flows (gated by the mode's + binary), and the shared factor makes the marginal (proportional) part move in + lockstep. Use None (per device or for the whole argument) for single-commodity + operation modes, which behave exactly as before. Potentially deprecated arguments: commitment_quantities: amounts of flow specified in commitments (both previously ordered and newly requested) @@ -412,6 +441,44 @@ 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 + } + + # Multi-commodity operation modes (S2 FRBC.OperationModeElement.power_ranges): + # per banded device, per band, a mapping of coupled device (commodity) -> (min, max). + # Only kept for banded devices, and only for bands that actually declare coupled + # commodity ranges, so single-commodity operation modes stay untouched. + if device_mode_commodity_ranges is None: + device_mode_commodity_ranges = [None] * len(device_constraints) + mode_commodity_lookup: dict[int, list[dict[int, tuple[float, float]]]] = {} + for d, per_band in enumerate(device_mode_commodity_ranges): + if per_band is None or d not in band_lookup: + continue + if len(per_band) != len(band_lookup[d]): + raise ValueError( + "device_mode_commodity_ranges[%d] must have one entry per power band" + " (got %d entries for %d bands)." + % (d, len(per_band), len(band_lookup[d])) + ) + cleaned = [dict(entry) if entry else {} for entry in per_band] + if any(cleaned): + mode_commodity_lookup[d] = cleaned + # (device, coupled-commodity-device) pairs whose flow is set by an operation-mode factor. + mode_commodity_pairs = sorted( + { + (d, dc) + for d, per_band in mode_commodity_lookup.items() + for entry in per_band + for dc in entry + } + ) + # Add indices for devices (d), datetimes (j) and commitments (c) model.d = RangeSet(0, len(device_constraints) - 1, doc="Set of devices") model.j = RangeSet( @@ -819,6 +886,116 @@ 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 + ) + + # Multi-commodity operation modes: a single, continuous operation-mode factor per + # (banded device, band, time step) interpolates every commodity of that mode at once. + # The factor is gated by the band's binary (0 <= factor <= device_band), so it is + # zero for any band that is not selected, leaving only the selected band's factor free + # in [0, 1]. Summed over bands it interpolates the banded device's own power between + # its band minimum and maximum, and each coupled commodity between its own (min, max). + # This is the affine tie between commodities (S2's operation_mode_factor). + model.dbf = Set( + dimen=2, + initialize=lambda m: ( + (d, b) for d in mode_commodity_lookup for b in range(len(band_lookup[d])) + ), + doc="(device, band) pairs carrying a multi-commodity operation-mode factor", + ) + model.device_mode_factor = Var( + model.dbf, model.j, domain=NonNegativeReals, bounds=(0, 1), initialize=0 + ) + + def device_mode_factor_gate(m, d, b, j): + """The operation-mode factor is only non-zero for the selected band.""" + return m.device_mode_factor[d, b, j] <= m.device_band[d, b, j] + + def device_mode_primary_power(m, d, b, j): + """Tie the banded device's own power to its bands via the shared factor. + + Only added once per (device, time step), anchored on band 0. Combined with the + gate and the exactly-one-band choice, this both fixes the power to the selected + band's interpolation and makes the band-power lower/upper bounds redundant-but-safe. + """ + if b != 0: + return Constraint.Skip + return m.device_power_down[d, j] + m.device_power_up[d, j] == sum( + m.device_band[d, b_, j] * band_lookup[d][b_][0] + + m.device_mode_factor[d, b_, j] + * (band_lookup[d][b_][1] - band_lookup[d][b_][0]) + for b_ in range(len(band_lookup[d])) + ) + + def device_mode_commodity_power(m, d, dc, j): + """Tie each coupled commodity's flow to the same shared operation-mode factor. + + cmin is the commodity's fixed no-load flow (gated by the mode binary); the factor + term adds the proportional part, in lockstep with the banded device's own power. + """ + return m.device_power_down[dc, j] + m.device_power_up[dc, j] == sum( + m.device_band[d, b_, j] + * mode_commodity_lookup[d][b_].get(dc, (0.0, 0.0))[0] + + m.device_mode_factor[d, b_, j] + * ( + mode_commodity_lookup[d][b_].get(dc, (0.0, 0.0))[1] + - mode_commodity_lookup[d][b_].get(dc, (0.0, 0.0))[0] + ) + for b_ in range(len(band_lookup[d])) + ) + + model.device_mode_factor_gate = Constraint( + model.dbf, model.j, rule=device_mode_factor_gate + ) + model.device_mode_primary_power = Constraint( + model.dbf, model.j, rule=device_mode_primary_power + ) + if mode_commodity_pairs: + model.dc_pairs = Set(dimen=2, initialize=mode_commodity_pairs) + model.device_mode_commodity_power = Constraint( + model.dc_pairs, model.j, rule=device_mode_commodity_power + ) + # Add objective def cost_function(m): costs = 0 diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 2c2211e439..457cecfa30 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -69,6 +69,76 @@ SCHEDULING_RESULT_KEY = "scheduling_result" +def _signed_range_endpoints(cons, prod) -> tuple[float, float]: + """Signed ``(low-running, high-running)`` power endpoints in MW for one range spec. + + Applies the sign convention shared by an operation mode's own band and each of its + per-commodity ranges: ``consumption-range`` maps to the positive side and + ``production-range`` to the negative side. The two endpoints are returned ordered by + the mode's *running level* (the operation-mode factor): the first is the flow when the + device runs least (lowest magnitude), the second when it runs most. + + - consumption only ``[a, b]`` (0 <= a <= b): ``(+a, +b)`` — more consumption is more running. + - production only ``[a, b]``: ``(-a, -b)`` — more production (more negative) is more running. + - both (each validated to start at 0): a band through zero, returned as + ``(-production_max, +consumption_max)`` (used for a device's own band, not for coupling). + """ + 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[0].to("MW").magnitude), + -float(prod[1].to("MW").magnitude), + ) + + +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. + """ + lo, hi = _signed_range_endpoints( + mode.get("consumption_range"), mode.get("production_range") + ) + return (min(lo, hi), max(lo, hi)) + + +def _operation_mode_commodity_bands(mode: dict) -> dict[str, tuple[float, float]]: + """Per-commodity signed power endpoints for one operation mode, keyed by commodity. + + Each value is ``(value_at_factor_0, value_at_factor_1)`` in signed MW, aligned with the + device scheduler's operation-mode factor: factor 0 sits at the device's own band minimum + and factor 1 at its band maximum (see ``device_mode_commodity_ranges`` in + ``device_scheduler``). We derive the alignment from the shared running-level ordering of + ``_signed_range_endpoints``: if the mode's own band minimum is its low-running endpoint + (a consumption-led device) factor 0 is low running, otherwise (a production-led device) + factor 0 is high running, and each commodity's endpoints are ordered to match. This ties + every commodity affinely to the device's own power through the single shared factor. + """ + ranges = mode.get("commodity_power_ranges") + if not ranges: + return {} + p_low, p_high = _signed_range_endpoints( + mode.get("consumption_range"), mode.get("production_range") + ) + factor_0_is_low_running = p_low <= p_high + bands: dict[str, tuple[float, float]] = {} + for entry in ranges: + c_low, c_high = _signed_range_endpoints( + entry.get("consumption_range"), entry.get("production_range") + ) + bands[entry["commodity"]] = ( + (c_low, c_high) if factor_0_is_low_running else (c_high, c_low) + ) + return bands + + 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 +417,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 +1062,16 @@ 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] + ] + if sensor_d is not None and sensor_d.get_attribute( "is_strictly_non_positive" ): @@ -3172,6 +3255,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..3784061c0b --- /dev/null +++ b/flexmeasures/data/models/planning/tests/test_operation_modes.py @@ -0,0 +1,368 @@ +"""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 _cogeneration_modes(): + """The two operation modes of an affine cogeneration unit, sign-explicit (S2 shape). + + The unit produces electricity (its own, banded flow) and heat, while consuming gas: + + - off: everything 0 (no no-load fuel) + - on: electricity produced in [0.4, 0.5] MW and, tied by the shared operation-mode + factor, gas = 0.2 + 1.0 * |electricity| (consumed; no-load base 0.2) and + heat = 0.1 + 0.5 * |electricity| (produced; no-load base 0.1). + + Electricity and heat therefore use ``production-range`` and gas uses ``consumption-range``. + Loaded through ``OperationModeSchema`` and reduced to signed scheduler bands via the shared + ``_operation_mode_signed_band`` / ``_operation_mode_commodity_bands`` helpers. + """ + return [ + { + "production-range": ["0 MW", "0 MW"], + "commodity-power-ranges": [ + {"commodity": "gas", "consumption-range": ["0 MW", "0 MW"]}, + {"commodity": "heat", "production-range": ["0 MW", "0 MW"]}, + ], + }, + { + # electricity produced between 0.4 and 0.5 MW when on + "production-range": ["0.4 MW", "0.5 MW"], + "commodity-power-ranges": [ + # gas consumed: 0.6 at 0.4 output, 0.7 at 0.5 output (base 0.2, slope 1.0) + {"commodity": "gas", "consumption-range": ["0.6 MW", "0.7 MW"]}, + # heat produced: 0.3 at 0.4 output, 0.35 at 0.5 output (base 0.1, slope 0.5) + {"commodity": "heat", "production-range": ["0.3 MW", "0.35 MW"]}, + ], + }, + ] + + +def _cogeneration_setup( + delivery_target: float | None, + electricity_price, + gas_price: float, +): + """A unit-committed affine cogeneration unit as a multi-commodity operation mode. + + Three scheduler devices share one operation-mode binary carried by the (banded) + electricity device: electricity (device 0, the primary/banded device, producing so its + signed power is non-positive), gas (device 1, consumed) and heat (device 2, produced). + The sign-explicit modes are turned into signed scheduler bands via the storage helpers, + exactly as ``StorageScheduler`` would. + """ + from flexmeasures.data.schemas.scheduling.storage import OperationModeSchema + from flexmeasures.data.models.planning.storage import ( + _operation_mode_signed_band, + _operation_mode_commodity_bands, + ) + + 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) + + modes = [OperationModeSchema().load(mode) for mode in _cogeneration_modes()] + # Primary (electricity) signed bands and per-commodity (gas=1, heat=2) signed endpoints. + device_power_bands = [ + [_operation_mode_signed_band(mode) for mode in modes], + None, + None, + ] + commodity_index = {"gas": 1, "heat": 2} + device_mode_commodity_ranges = [ + [ + { + commodity_index[name]: band + for name, band in _operation_mode_commodity_bands(mode).items() + } + for mode in modes + ], + None, + None, + ] + + equals = pd.Series(np.nan, index=index) + if delivery_target is not None: + # Negative stock target: the unit must net-produce ``delivery_target`` MWh. + equals.iloc[-1] = -delivery_target + electricity = pd.DataFrame( + { + "min": -10, + "max": 10, + "equals": equals, + "derivative min": -0.5, # production only + "derivative max": 0, + "derivative equals": np.nan, + }, + index=index, + ) + # Gas and heat carry only a flow; their power is fully set by the operation-mode factor. + free_commodity = pd.DataFrame( + { + "min": np.nan, + "max": np.nan, + "equals": np.nan, + "derivative min": -10, + "derivative max": 10, + "derivative equals": np.nan, + }, + index=index, + ) + device_constraints = [electricity, free_commodity.copy(), free_commodity.copy()] + ems_constraints = pd.DataFrame( + {"derivative min": -100, "derivative max": 100}, index=index + ) + # Producing electricity earns revenue: a positive price on the deviation from a zero + # quantity makes the (negative) electricity flow lower the objective. Pricing both + # directions equally keeps the commitment convex and pins the deviation to the flow + # (the electricity device is production-only, so only the downward side is ever active). + energy_commitment = FlowCommitment( + name="energy", + index=index, + quantity=0, + upwards_deviation_price=pd.Series(electricity_price, index=index), + downwards_deviation_price=pd.Series(electricity_price, index=index), + device=pd.Series(0, index=index), + ) + # Consuming gas costs money: a positive price on the upward (consumption) deviation. + gas_commitment = FlowCommitment( + name="gas", + index=index, + quantity=0, + upwards_deviation_price=gas_price, + downwards_deviation_price=0, + device=pd.Series(1, index=index), + ) + return ( + device_constraints, + ems_constraints, + [energy_commitment, gas_commitment], + device_power_bands, + device_mode_commodity_ranges, + ) + + +def _run_cogen(delivery_target, electricity_price, gas_price): + ( + device_constraints, + ems_constraints, + commitments, + device_power_bands, + device_mode_commodity_ranges, + ) = _cogeneration_setup(delivery_target, electricity_price, gas_price) + schedule, costs, results, model = device_scheduler( + device_constraints, + ems_constraints, + commitments=commitments, + device_power_bands=device_power_bands, + device_mode_commodity_ranges=device_mode_commodity_ranges, + ) + assert "optimal" in str(results.solver.termination_condition) + electricity = schedule[0].values + gas = schedule[1].values + heat = schedule[2].values + return electricity, gas, heat, costs + + +def test_cogeneration_idles_fully_when_unprofitable(): + """When running never pays off, the unit idles: every commodity is 0, no no-load fuel. + + There is no delivery target, so nothing forces the unit on. Running a step earns only + 0.4 * 1 in electricity revenue but burns gas worth 2 * (0.2 + 0.4) = 1.2, a net cost of + 0.8. Idling (cost 0) therefore dominates. The unit *could* run (it has an "on" band) but + must not spuriously do so, and in particular must not burn the no-load gas base while off. + """ + electricity, gas, heat, costs = _run_cogen( + delivery_target=None, + electricity_price=1.0, + gas_price=2.0, + ) + assert np.allclose(electricity, 0) + assert np.allclose(gas, 0) # no no-load fuel while off + assert np.allclose(heat, 0) + assert np.isclose(costs, 0.0) + + +def test_cogeneration_runs_at_minimum_with_affine_commodities(): + """The unit must deliver a small amount of electricity, forcing minimum-level running. + + A 1.2 MWh net-production target cannot be met below the 0.4 MW mode minimum, so exactly + three steps run at 0.4 MW output. Gas (consumed) and heat (produced) follow the affine + tie including their no-load bases, and the hand-computed objective is revenue + gas cost. + Revenue is maximised by producing in the three highest-priced steps (prices [10, 1, 10, 1], + so both 10-steps and one 1-step): + + revenue = -(0.4*10 + 0.4*10 + 0.4*1) = -8.4 (negative == earned) + gas cost = 2 * (0.6 + 0.6 + 0.6) = 3.6 (0.6 gas consumed per step at 0.4) + total = -4.8 + """ + electricity, gas, heat, costs = _run_cogen( + delivery_target=1.2, + electricity_price=[10, 1, 10, 1], + gas_price=2.0, + ) + # Three steps producing at the 0.4 MW minimum (negative == production), one idle step. + assert np.isclose(sorted(electricity), [-0.4, -0.4, -0.4, 0]).all() + assert np.isclose(electricity.sum(), -1.2) + # Both high-priced steps (0 and 2) are used. + assert np.isclose(electricity[0], -0.4) and np.isclose(electricity[2], -0.4) + # Affine commodities incl. no-load base; both are zero exactly when the unit is off. + for p, g, h in zip(electricity, gas, heat): + output = -p # electricity produced (>= 0) + if np.isclose(output, 0): + assert np.isclose(g, 0) and np.isclose(h, 0) + else: + assert np.isclose(g, 0.2 + 1.0 * output) # gas consumed (positive) + assert np.isclose(h, -(0.1 + 0.5 * output)) # heat produced (negative) + assert np.isclose(costs, -4.8) + + +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..4cb8085659 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -392,6 +392,18 @@ 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. +""", + 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..b9edb0bcd0 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -125,6 +125,172 @@ def __init__(self, *args, **kwargs): ) +class CommodityPowerRangeSchema(Schema): + """A single commodity's power range within one operation mode. + + This mirrors one entry of an S2 ``FRBC.OperationModeElement.power_ranges`` list: the + power range that this commodity's flow spans as the mode's operation-mode factor sweeps + from 0 to 1. As on the mode itself, the sign is given explicitly via ``consumption-range`` + (non-negative, positive means consumption) and/or ``production-range`` (non-negative, + positive means production). The factor sweeps all commodities in lockstep with the mode's + own power, so the endpoints are magnitude-ordered like the mode's own range (the device + scheduler resolves which endpoint pairs with which extreme of the mode's power); the + lower-magnitude endpoint is the commodity's fixed no-load flow and the difference to the + other endpoint is its proportional (marginal) part. Sharing one factor across commodities + ties them affinely, which lets a unit-committed affine cogeneration unit be expressed + natively. + """ + + commodity = fields.Str( + required=True, + metadata=dict( + description="Name of the commodity this power range applies to " + "(e.g. 'gas' or 'heat')." + ), + ) + 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 commodity within the " + "mode (non-negative; positive is consumption)." + ), + ) + 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 commodity within the " + "mode (non-negative; positive is production)." + ), + ) + + @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( + "A commodity power range 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"A commodity's {name} must be non-negative.") + if rng[0] > rng[1]: + raise ValidationError( + f"The minimum of a commodity'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 a commodity combines consumption-range and production-range, both " + "must start at 0 (so they form one contiguous band through zero)." + ) + + +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"]}] + + An operation mode may additionally carry per-commodity power ranges + (``commodity-power-ranges``), generalising it across commodities in the sense of + S2's ``FRBC.OperationModeElement.power_ranges``. A single operation-mode factor then + interpolates the device's own power and every listed commodity's power in lockstep, + tying them affinely. For example, a cogeneration unit's "on" mode carries electricity + as its own (production) ``consumption-range``/``production-range`` and gas and heat as + ``commodity-power-ranges``, each with a no-load base plus a proportional part. + """ + + 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).", + ), + ) + commodity_power_ranges = fields.List( + fields.Nested(CommodityPowerRangeSchema()), + data_key="commodity-power-ranges", + required=False, + metadata=dict( + description="Optional per-commodity signed power ranges tied to this mode's " + "operation-mode factor (S2 FRBC.OperationModeElement.power_ranges)." + ), + ) + + @validates_schema + def check_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)." + ) + + @validates("commodity_power_ranges") + def check_unique_commodities(self, value: list, **kwargs): + commodities = [entry["commodity"] for entry in value] + if len(commodities) != len(set(commodities)): + raise ValidationError( + "Each commodity may appear at most once in an operation mode's " + "commodity-power-ranges." + ) + + class StorageFlexModelSchema(Schema): """ This schema lists fields we require when scheduling storage assets. @@ -197,6 +363,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..9d054647e1 100644 --- a/flexmeasures/data/schemas/tests/test_scheduling.py +++ b/flexmeasures/data/schemas/tests/test_scheduling.py @@ -1455,3 +1455,60 @@ def test_asset_trigger_schema_rejects_malformed_flex_context(app): with pytest.raises(ValidationError) as e_info: schema.normalize_flex_context_format({"flex-context": "not-a-dict-or-list"}) assert "flex-context" in str(e_info.value) + + +def test_operation_mode_schema_with_commodity_power_ranges(app): + """A multi-commodity operation mode loads sign-explicit per-commodity ranges (S2 shape).""" + from flexmeasures.data.schemas.scheduling.storage import OperationModeSchema + from flexmeasures.data.models.planning.storage import ( + _operation_mode_signed_band, + _operation_mode_commodity_bands, + ) + + mode = OperationModeSchema().load( + { + "production-range": ["0.4 MW", "0.5 MW"], + "commodity-power-ranges": [ + {"commodity": "gas", "consumption-range": ["0.6 MW", "0.7 MW"]}, + {"commodity": "heat", "production-range": ["0.3 MW", "0.35 MW"]}, + ], + } + ) + assert len(mode["commodity_power_ranges"]) == 2 + assert mode["commodity_power_ranges"][0]["commodity"] == "gas" + # The electricity device produces, so its band is negative and factor 0 is at full output. + assert _operation_mode_signed_band(mode) == (-0.5, -0.4) + bands = _operation_mode_commodity_bands(mode) + # gas consumed: 0.7 at factor 0 (0.5 MW output), 0.6 at factor 1 (0.4 MW output) + assert bands["gas"] == (0.7, 0.6) + # heat produced (negative): -0.35 at factor 0, -0.3 at factor 1 + assert bands["heat"] == (-0.35, -0.3) + + +def test_operation_mode_schema_rejects_duplicate_commodities(app): + """A commodity may appear at most once in an operation mode's commodity-power-ranges.""" + from flexmeasures.data.schemas.scheduling.storage import OperationModeSchema + + with pytest.raises(ValidationError): + OperationModeSchema().load( + { + "consumption-range": ["0 MW", "1 MW"], + "commodity-power-ranges": [ + {"commodity": "gas", "consumption-range": ["0 MW", "1 MW"]}, + {"commodity": "gas", "consumption-range": ["0 MW", "2 MW"]}, + ], + } + ) + + +def test_commodity_power_range_requires_a_sign_explicit_range(app): + """A commodity range must declare a consumption-range and/or production-range.""" + from flexmeasures.data.schemas.scheduling.storage import OperationModeSchema + + with pytest.raises(ValidationError): + OperationModeSchema().load( + { + "consumption-range": ["0 MW", "1 MW"], + "commodity-power-ranges": [{"commodity": "gas"}], + } + ) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 854f57305b..2ef8f779e3 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -6204,6 +6204,68 @@ ], "additionalProperties": false }, + "CommodityPowerRange": { + "type": "object", + "properties": { + "commodity": { + "type": "string", + "description": "Name of the commodity this power range applies to (e.g. 'gas' or 'heat')." + }, + "consumption-range": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "description": "Consumption power range [min, max] of this commodity within the mode (non-negative; positive is consumption).", + "items": { + "type": "string" + } + }, + "production-range": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "description": "Production power range [min, max] of this commodity within the mode (non-negative; positive is production).", + "items": { + "type": "string" + } + } + }, + "required": [ + "commodity" + ], + "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" + } + }, + "commodity-power-ranges": { + "type": "array", + "description": "Optional per-commodity signed power ranges tied to this mode's operation-mode factor (S2 FRBC.OperationModeElement.power_ranges).", + "items": { + "$ref": "#/components/schemas/CommodityPowerRange" + } + } + }, + "additionalProperties": false + }, "GroupReference": { "type": "object", "properties": { @@ -6275,6 +6337,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 `_; 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": [ + { + "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": {