Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ New features
* Improve chart axis domain for event values not around zero, with a per-sub-chart ``y-axis`` option in ``sensors_to_show`` (default ``zero``, which pads the axis out to include zero) that can be set to ``data`` to fit a sub-chart's y-axis to the values shown, to an explicit ``[min, max]`` domain that the axis will cover at least (expanding to fit data beyond it), or to a strict ``{"min": min, "max": max}`` domain that the axis will never exceed (clamping data beyond it, with a warning when that happens), editable from the graph editor [see `PR #2244 <https://www.github.com/FlexMeasures/flexmeasures/pull/2244>`_]
* Extended ``GET /api/v3_0/jobs/<uuid>`` 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 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <https://www.github.com/FlexMeasures/flexmeasures/issues/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 <https://www.github.com/FlexMeasures/flexmeasures/pull/2283>`_]
* Add support for intermediate power constraints on groups of devices, via a new ``group`` field in the storage flex-model [see `PR #2276 <https://www.github.com/FlexMeasures/flexmeasures/pull/2276>`_ and `issue #2092 <https://github.com/FlexMeasures/flexmeasures/issues/2092>`_]
* The ``group`` field now also accepts a ``{"asset": <id>}`` reference (in addition to ``{"sensor": <id>}``), 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 <https://github.com/FlexMeasures/flexmeasures/issues/2092>`_]
Expand Down
119 changes: 119 additions & 0 deletions flexmeasures/data/models/planning/linear_optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ def device_scheduler( # noqa C901
stock_groups: dict[int, list[int]] | None = None,
ems_constraint_groups: list[list[int]] | None = None,
device_power_bands: list[list[tuple[float, float]] | None] | None = None,
device_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,
Expand Down Expand Up @@ -126,6 +129,26 @@ def device_scheduler( # noqa C901
one of its bands at every time step (see S2 operation modes); this introduces
binary variables (one per device per band per time step). Use None (per device
or for the whole argument) for devices without band restrictions.
:param device_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)
Expand Down Expand Up @@ -427,6 +450,35 @@ def convert_commitments_to_subcommitments(
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(
Expand Down Expand Up @@ -877,6 +929,73 @@ def device_band_power_upper(m, d, b, j):
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
Expand Down
64 changes: 54 additions & 10 deletions flexmeasures/data/models/planning/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,20 @@
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]``.
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).
"""
cons = mode.get("consumption_range")
prod = mode.get("production_range")
if cons and prod:
return (
-float(prod[1].to("MW").magnitude),
Expand All @@ -90,9 +94,49 @@ def _operation_mode_signed_band(mode: dict) -> tuple[float, float]:
float(cons[1].to("MW").magnitude),
)
return (
-float(prod[1].to("MW").magnitude),
-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):
Expand Down
Loading
Loading