Skip to content
Open
2 changes: 2 additions & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ New features
* CLI support for adding/editing account attributes [see `PR #2242 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <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>`_]
* 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 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <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
3 changes: 3 additions & 0 deletions documentation/features/scheduling.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions flexmeasures/data/models/planning/linear_optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
64 changes: 64 additions & 0 deletions flexmeasures/data/models/planning/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down Expand Up @@ -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
]
Expand Down Expand Up @@ -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"
):
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading