diff --git a/documentation/features/scheduling.rst b/documentation/features/scheduling.rst
index 1643330a6d..aacfa2db11 100644
--- a/documentation/features/scheduling.rst
+++ b/documentation/features/scheduling.rst
@@ -5,8 +5,8 @@ Scheduling
Scheduling is the main value-drive of FlexMeasures. We have two major types of schedulers built-in, for storage devices (usually batteries or hot water storage) and processes (usually in industry).
-FlexMeasures computes schedules for energy systems that consist of multiple devices that consume and/or produce electricity.
-We model a device as an asset with a power sensor, and compute schedules only for flexible devices, while taking into account inflexible devices.
+FlexMeasures computes schedules for energy systems that consist of multiple devices that consume and/or produce a commodity (e.g. electricity or gas).
+We model a device as an asset with a consumption/production sensor recording power values, and compute schedules only for flexible devices, while taking into account inflexible devices.
.. contents::
:local:
@@ -58,6 +58,9 @@ And if the asset belongs to a larger system (a hierarchy of assets), the schedul
* - Field
- Example value
- Description
+ * - ``commodity``
+ - |COMMODITY_FLEX_CONTEXT.example|
+ - .. include:: ../_autodoc/COMMODITY_FLEX_CONTEXT.rst
* - ``inflexible-device-sensors``
- |INFLEXIBLE_DEVICE_SENSORS.example|
- .. include:: ../_autodoc/INFLEXIBLE_DEVICE_SENSORS.rst
@@ -183,6 +186,9 @@ For more details on the possible formats for field values, see :ref:`variable_qu
* - Field
- Example value
- Description
+ * - ``commodity``
+ - |COMMODITY_FLEX_MODEL.example|
+ - .. include:: ../_autodoc/COMMODITY_FLEX_MODEL.rst
* - ``consumption``
- |CONSUMPTION.example|
- .. include:: ../_autodoc/CONSUMPTION.rst
diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py
index 997df93e4d..954f08960b 100644
--- a/flexmeasures/data/models/planning/__init__.py
+++ b/flexmeasures/data/models/planning/__init__.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+from collections import defaultdict
from collections.abc import Iterable
from dataclasses import dataclass, field
from datetime import datetime, timedelta
@@ -53,6 +54,7 @@ class Scheduler:
flex_model: list[dict] | dict | None = None
flex_context: dict | None = None
+ stock_groups: dict | None = None
fallback_scheduler_class: "Type[Scheduler] | None" = None
info: dict | None = None
@@ -65,6 +67,103 @@ class Scheduler:
return_multiple: bool = False
+ @staticmethod
+ def _build_stock_groups(flex_model: list[dict]) -> dict:
+ """
+ Build stock groups where devices sharing the same state-of-charge sensor are grouped together.
+ """
+ groups = defaultdict(list)
+ soc_usage = defaultdict(list)
+
+ for d, fm in enumerate(flex_model):
+ soc = fm.get("state_of_charge")
+ if soc is not None:
+ if hasattr(soc, "id"):
+ soc_id = soc.id
+ elif isinstance(soc, dict) and "sensor" in soc:
+ sensor = soc["sensor"]
+ soc_id = sensor.id if hasattr(sensor, "id") else sensor
+ else:
+ soc_id = soc
+
+ soc_usage[soc_id].append(d)
+
+ for soc_id, device_list in soc_usage.items():
+ groups[soc_id] = device_list
+
+ already_grouped = {dev for group in groups.values() for dev in group}
+ missing_soc_sensor_i = -len(flex_model)
+ for d in range(len(flex_model)):
+ if d not in already_grouped:
+ groups[missing_soc_sensor_i].append(d)
+ missing_soc_sensor_i += 1
+
+ return dict(groups)
+
+ @staticmethod
+ def _build_coupling_groups(
+ flex_model: list[dict],
+ ) -> dict[str, list[tuple[int, float]]]:
+ """Build coupling groups from the 'coupling' and 'coupling_coefficient' fields
+ of each device model.
+
+ Devices sharing the same coupling name form a coupling group.
+ The optimization model introduces a decision variable ``alpha`` per group per time
+ step, and constrains every device by ``P[d] == coeff_d * alpha``.
+
+ Coupling coefficients in flex-models are user-facing positive magnitudes.
+ The internal sign is inferred from directional capacities:
+
+ - ``consumption_capacity == 0`` -> output device -> internally negative coefficient
+ - ``production_capacity == 0`` -> input device -> internally positive coefficient
+
+ If neither direction is explicitly blocked, the coefficient stays positive.
+
+ Example — a CHP with 50% heat efficiency and 30% power efficiency:
+
+ [
+ {"coupling": "chp", "coupling_coefficient": 1.0}, # gas input (alpha = P_gas)
+ {"coupling": "chp", "coupling_coefficient": 0.5}, # heat output (50% of gas)
+ {"coupling": "chp", "coupling_coefficient": 0.3}, # power output (30% of gas)
+ ]
+
+ :param flex_model: List of deserialized device flex-model dicts.
+ :returns: Mapping from coupling-group name to a list of
+ ``(device_index, internal_signed_coefficient)`` tuples suitable for
+ passing to ``device_scheduler(coupling_groups=...)``. Returns an empty dict
+ when no device defines a ``coupling`` field.
+ """
+
+ def _is_zero_capacity(value: Any) -> bool:
+ """Return True if the capacity value is numerically zero."""
+
+ if value is None:
+ return False
+
+ # Pint quantities expose ``magnitude``.
+ magnitude = getattr(value, "magnitude", value)
+ try:
+ return bool(np.isclose(float(magnitude), 0.0))
+ except (TypeError, ValueError):
+ return False
+
+ groups: dict[str, list[tuple[int, float]]] = defaultdict(list)
+ for d, fm in enumerate(flex_model):
+ coupling_name = fm.get("coupling")
+ if coupling_name is None:
+ continue
+ coefficient = abs(float(fm.get("coupling_coefficient", 1.0)))
+
+ is_output = _is_zero_capacity(fm.get("consumption_capacity"))
+ is_input = _is_zero_capacity(fm.get("production_capacity"))
+
+ if is_output and not is_input:
+ coefficient = -coefficient
+
+ groups[coupling_name].append((d, coefficient))
+
+ return dict(groups)
+
def __init__(
self,
sensor: Sensor | None = None, # deprecated
@@ -203,12 +302,19 @@ def collect_flex_config(self):
# Listify the flex-model for the next code block, which actually does the merging with the db_flex_model
flex_model = [flex_model]
+ # Find which asset is relevant for a given device model in the flex-model from the trigger message
for flex_model_d in flex_model:
asset_id = flex_model_d.get("asset")
if asset_id is None:
- sensor_id = flex_model_d["sensor"]
- sensor = db.session.get(Sensor, sensor_id)
- asset_id = sensor.asset_id
+ sensor_id = flex_model_d.get("sensor")
+ if sensor_id is not None:
+ sensor = db.session.get(Sensor, sensor_id)
+ asset_id = sensor.asset_id
+ else:
+ soc_sensor_ref = flex_model_d.get("state-of-charge")
+ if soc_sensor_ref is not None:
+ soc_sensor = db.session.get(Sensor, soc_sensor_ref["sensor"])
+ asset_id = soc_sensor.asset_id
if asset_id in db_flex_model:
flex_model_d = {**db_flex_model[asset_id], **flex_model_d}
amended_flex_model.append(flex_model_d)
@@ -289,14 +395,27 @@ class Commitment:
quantity: pd.Series = 0
upwards_deviation_price: pd.Series = 0
downwards_deviation_price: pd.Series = 0
+ commodity: str | pd.Series | None = None
def __post_init__(self):
# device_group is a device→label lookup table, not a time series;
# exclude it from automatic time-series index coercion.
+ if (
+ isinstance(self, FlowCommitment)
+ and isinstance(self.commodity, pd.Series)
+ and self.device is not None
+ ):
+ devices = extract_devices(self.device)
+ missing = set(devices) - set(self.commodity.index)
+ if missing:
+ raise ValueError(f"commodity mapping missing for devices: {missing}")
+
series_attributes = [
attr
for attr, _type in self.__annotations__.items()
- if _type == "pd.Series" and hasattr(self, attr) and attr != "device_group"
+ if _type == "pd.Series"
+ and hasattr(self, attr)
+ and attr not in ("device_group", "commodity")
]
for series_attr in series_attributes:
val = getattr(self, series_attr)
@@ -389,6 +508,10 @@ def _init_device_group(self):
range(len(devices)), index=devices, name="device_group"
)
else:
+ if not isinstance(self.device_group, pd.Series):
+ self.device_group = pd.Series(
+ self.device_group, index=devices, name="device_group"
+ )
# Validate custom grouping
missing = set(devices) - set(self.device_group.index)
if missing:
@@ -450,6 +573,19 @@ def to_frame(self) -> pd.DataFrame:
np.nan
) # EMS-level: handled by ems_flow_commitment_equalities
+ # commodity
+ if getattr(self, "commodity", None) is None:
+ df["commodity"] = None
+ elif isinstance(self.commodity, pd.Series):
+ # commodity is a device→commodity mapping, like device_group
+ if self.device is None:
+ df["commodity"] = None
+ else:
+ df["commodity"] = map_device_to_group(self.device, self.commodity)
+ else:
+ # scalar commodity
+ df["commodity"] = self.commodity
+
return df
diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py
index 1b85452f9d..111cbb1576 100644
--- a/flexmeasures/data/models/planning/linear_optimization.py
+++ b/flexmeasures/data/models/planning/linear_optimization.py
@@ -35,12 +35,15 @@
def device_scheduler( # noqa C901
device_constraints: list[pd.DataFrame],
- ems_constraints: pd.DataFrame,
+ ems_constraints: pd.DataFrame | list[pd.DataFrame],
commitment_quantities: list[pd.Series] | None = None,
commitment_downwards_deviation_price: list[pd.Series] | list[float] | None = None,
commitment_upwards_deviation_price: list[pd.Series] | list[float] | None = None,
commitments: list[pd.DataFrame] | list[Commitment] | None = None,
initial_stock: float | list[float] = 0,
+ stock_groups: dict[int, list[int]] | None = None,
+ coupling_groups: dict[str, list[tuple[int, float]]] | None = None,
+ ems_constraint_groups: list[list[int]] | 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,
@@ -63,6 +66,13 @@ def device_scheduler( # noqa C901
:param ems_constraints: EMS constraints are on an EMS level. Handled constraints (listed by column name):
derivative max: maximum flow
derivative min: minimum flow
+ May be a single DataFrame (the constraint is applied to the summed flow of all devices),
+ or a list of DataFrames (one per device group). In the latter case, ``ems_constraint_groups``
+ lists the device indices each DataFrame applies to. The StorageScheduler uses one device
+ group per commodity, so each commodity gets its own EMS-level capacity constraint.
+ :param ems_constraint_groups: For each EMS constraint DataFrame, the list of device indices it applies to. When omitted,
+ each EMS constraint is applied to the summed flow of all devices (legacy behaviour). A device
+ may appear in more than one group.
:param commitments: Commitments are on an EMS level by default. Handled parameters (listed by column name):
quantity: for example, 5.5
downwards deviation price: 10.1
@@ -71,6 +81,15 @@ 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 coupling_groups: Hard flow-coupling constraints between devices. Each entry maps a group name to a list of
+ ``(device_index, coefficient)`` tuples. A decision variable ``alpha`` is introduced per group
+ per time step and every device ``d`` in the group is constrained by ``P[d, j] == coeff_d * alpha[group, j]``.
+ Sign convention: positive coefficient for input devices (consuming, positive ``ems_power``),
+ negative coefficient for output devices (producing, negative ``ems_power``).
+ Example — a CHP with gas input (d=0, coeff 1.0), heat output (d=1, coeff −0.5) and
+ power output (d=2, coeff −0.3)::
+
+ coupling_groups={"chp": [(0, 1.0), (1, -0.5), (2, -0.3)]}
Potentially deprecated arguments:
commitment_quantities: amounts of flow specified in commitments (both previously ordered and newly requested)
@@ -100,6 +119,59 @@ def device_scheduler( # noqa C901
resolution = pd.to_timedelta(device_constraints[0].index.freq).to_pytimedelta()
end = device_constraints[0].index.to_pydatetime()[-1] + resolution
+ # Normalise EMS constraints to a list of (DataFrame, device-group) pairs.
+ # A single DataFrame (legacy behaviour) applies to the summed flow of all devices;
+ # a list of DataFrames applies one EMS-level constraint per device group, as set up
+ # per commodity by the StorageScheduler.
+ all_devices = list(range(len(device_constraints)))
+ if isinstance(ems_constraints, pd.DataFrame):
+ ems_constraints_list = [ems_constraints]
+ ems_constraint_device_groups = [all_devices]
+ else:
+ ems_constraints_list = ems_constraints
+ if ems_constraint_groups is None:
+ if len(ems_constraints_list) > 1:
+ raise ValueError(
+ "When passing multiple EMS constraint DataFrames, you must also specify ems_constraint_groups."
+ )
+ ems_constraint_device_groups = [all_devices for _ in ems_constraints_list]
+ else:
+ ems_constraint_device_groups = ems_constraint_groups
+
+ # map device -> primary stock group (used for per-device stock bounds)
+ # and map stock group -> all member devices (used for stock accumulation).
+ device_to_group = {}
+ group_to_devices = {}
+
+ if stock_groups:
+ for g, devices in stock_groups.items():
+ group_to_devices[g] = list(devices)
+ for d in devices:
+ # Keep first assignment as the primary group. A device can still
+ # participate in multiple groups via ``group_to_devices``.
+ if d not in device_to_group:
+ device_to_group[d] = g
+ # Devices not in any stock group are treated as single-device groups.
+ for d in range(len(device_constraints)):
+ if d not in device_to_group:
+ g = f"_device_{d}"
+ device_to_group[d] = g
+ group_to_devices[g] = [d]
+ else:
+ for d in range(len(device_constraints)):
+ g = f"_device_{d}"
+ device_to_group[d] = g
+ group_to_devices[g] = [d]
+
+ # Collect (group_index, device_index, coefficient) triples for coupling constraints.
+ # Each device in each group will be constrained: P[d, j] == coeff * alpha[group, j]
+ # where alpha is a free variable representing the common normalised flow.
+ coupling_device_specs: list[tuple[int, int, float]] = []
+ if coupling_groups:
+ for g_idx, (_group_name, members) in enumerate(coupling_groups.items()):
+ for d_idx, coeff in members:
+ coupling_device_specs.append((g_idx, d_idx, coeff))
+
# Move commitments from old structure to new
if commitments is None:
commitments = []
@@ -135,6 +207,20 @@ def device_scheduler( # noqa C901
df["group"] = group
commitments.append(df)
+ # commodity → set(device indices)
+ commodity_devices = {}
+
+ for df in commitments:
+ if "commodity" not in df.columns or "device" not in df.columns:
+ continue
+
+ for _, row in df[["commodity", "device"]].dropna().iterrows():
+ devices = row["device"]
+ if not isinstance(devices, (list, tuple, set)):
+ devices = [devices]
+
+ commodity_devices.setdefault(row["commodity"], set()).update(devices)
+
# Check if commitments have the same time window and resolution as the constraints
for commitment in commitments:
start_c = commitment.index.to_pydatetime()[0]
@@ -358,15 +444,15 @@ def device_derivative_min_select(m, d, j):
else:
return np.nanmax([min_v, equal_v])
- def ems_derivative_max_select(m, j):
- v = ems_constraints["derivative max"].iloc[j]
+ def ems_derivative_max_select(m, g, j):
+ v = ems_constraints_list[g]["derivative max"].iloc[j]
if np.isnan(v):
return infinity
else:
return v
- def ems_derivative_min_select(m, j):
- v = ems_constraints["derivative min"].iloc[j]
+ def ems_derivative_min_select(m, g, j):
+ v = ems_constraints_list[g]["derivative min"].iloc[j]
if np.isnan(v):
return -infinity
else:
@@ -452,8 +538,15 @@ def grouped_commitment_equalities(m, c, j, g):
model.device_derivative_min = Param(
model.d, model.j, initialize=device_derivative_min_select
)
- model.ems_derivative_max = Param(model.j, initialize=ems_derivative_max_select)
- model.ems_derivative_min = Param(model.j, initialize=ems_derivative_min_select)
+ model.eg = RangeSet(
+ 0, len(ems_constraints_list) - 1, doc="Set of EMS constraint (device) groups"
+ )
+ model.ems_derivative_max = Param(
+ model.eg, model.j, initialize=ems_derivative_max_select
+ )
+ model.ems_derivative_min = Param(
+ model.eg, model.j, initialize=ems_derivative_min_select
+ )
model.device_efficiency = Param(model.d, model.j, initialize=device_efficiency)
model.device_derivative_down_efficiency = Param(
model.d, model.j, initialize=device_derivative_down_efficiency
@@ -484,33 +577,77 @@ def grouped_commitment_equalities(m, c, j, g):
)
model.commitment_sign = Var(model.c, domain=Binary, initialize=0)
+ # def _get_stock_change(m, d, j):
+ # """Determine final stock change of device d until time j.
+ #
+ # Apply conversion efficiencies to conversion from flow to stock change and vice versa,
+ # and apply storage efficiencies to stock levels from one datetime to the next.
+ # """
+ # if isinstance(initial_stock, list):
+ # # No initial stock defined for inflexible device
+ # initial_stock_d = initial_stock[d] if d < len(initial_stock) else 0
+ # else:
+ # initial_stock_d = initial_stock
+ #
+ # stock_changes = [
+ # (
+ # m.device_power_down[d, k] / m.device_derivative_down_efficiency[d, k]
+ # + m.device_power_up[d, k] * m.device_derivative_up_efficiency[d, k]
+ # + m.stock_delta[d, k]
+ # )
+ # for k in range(0, j + 1)
+ # ]
+ # efficiencies = [m.device_efficiency[d, k] for k in range(0, j + 1)]
+ # final_stock_change = [
+ # stock - initial_stock_d
+ # for stock in apply_stock_changes_and_losses(
+ # initial_stock_d, stock_changes, efficiencies
+ # )
+ # ][-1]
+ # return final_stock_change
+
def _get_stock_change(m, d, j):
- """Determine final stock change of device d until time j.
- Apply conversion efficiencies to conversion from flow to stock change and vice versa,
- and apply storage efficiencies to stock levels from one datetime to the next.
- """
+ # determine the stock group of this device
+ group = device_to_group[d]
+
+ # all devices belonging to this stock
+ devices = group_to_devices[group]
+
+ # initial stock
if isinstance(initial_stock, list):
- # No initial stock defined for inflexible device
- initial_stock_d = initial_stock[d] if d < len(initial_stock) else 0
+ initial_stock_g = initial_stock[d] if d < len(initial_stock) else 0
else:
- initial_stock_d = initial_stock
+ initial_stock_g = initial_stock
+
+ stock_changes = []
+
+ for k in range(0, j + 1):
+
+ change = 0
+
+ for dev in devices:
+ change += (
+ m.device_power_down[dev, k]
+ / m.device_derivative_down_efficiency[dev, k]
+ + m.device_power_up[dev, k]
+ * m.device_derivative_up_efficiency[dev, k]
+ + m.stock_delta[dev, k]
+ )
+
+ stock_changes.append(change)
- stock_changes = [
- (
- m.device_power_down[d, k] / m.device_derivative_down_efficiency[d, k]
- + m.device_power_up[d, k] * m.device_derivative_up_efficiency[d, k]
- + m.stock_delta[d, k]
- )
- for k in range(0, j + 1)
- ]
efficiencies = [m.device_efficiency[d, k] for k in range(0, j + 1)]
+
final_stock_change = [
- stock - initial_stock_d
+ stock - initial_stock_g
for stock in apply_stock_changes_and_losses(
- initial_stock_d, stock_changes, efficiencies
+ initial_stock_g,
+ stock_changes,
+ efficiencies,
)
][-1]
+
return final_stock_change
# Add constraints as a tuple of (lower bound, value, upper bound)
@@ -553,8 +690,15 @@ def device_down_derivative_sign(m, d, j):
"""Derivative down if sign points down, derivative not down if sign points up."""
return -m.device_power_down[d, j] <= Md * (1 - m.device_power_sign[d, j])
- def ems_derivative_bounds(m, j):
- return m.ems_derivative_min[j], sum(m.ems_power[:, j]), m.ems_derivative_max[j]
+ def ems_derivative_bounds(m, g, j):
+ devices = ems_constraint_device_groups[g]
+ if not devices:
+ return Constraint.Skip
+ return (
+ m.ems_derivative_min[g, j],
+ sum(m.ems_power[d, j] for d in devices),
+ m.ems_derivative_max[g, j],
+ )
def commitment_up_derivative_sign(m, c):
"""Up deviation active only if sign points up."""
@@ -593,45 +737,30 @@ def device_stock_commitment_equalities(m, c, j, d):
)
def ems_flow_commitment_equalities(m, c, j):
- """Couple EMS flows (sum over devices) to each commitment.
+ """Couple EMS flow commitments to device flows, optionally filtered by commodity."""
- - Creates an inequality for one-sided commitments.
- - Creates an equality for two-sided commitments and for groups of size 1.
- """
- if (
- "device" in commitments[c].columns
- and not pd.isnull(commitments[c]["device"]).all()
- ) or m.commitment_quantity[c, j] == -infinity:
- # Commitment c does not concern EMS
+ if commitments[c]["class"].iloc[0] != FlowCommitment:
return Constraint.Skip
- if (
- "class" in commitments[c].columns
- and not (
- commitments[c]["class"].apply(lambda cl: cl == FlowCommitment)
- ).all()
- ):
- raise NotImplementedError(
- "StockCommitment on an EMS level has not been implemented. Please file a GitHub ticket explaining your use case."
- )
+
+ # Legacy behavior: no commodity → sum over all devices
+ if "commodity" not in commitments[c].columns:
+ devices = m.d
+ else:
+ commodity = commitments[c]["commodity"].iloc[0]
+ if pd.isna(commodity):
+ devices = m.d
+ else:
+ devices = commodity_devices.get(commodity, set())
+ if not devices:
+ return Constraint.Skip
+
return (
- (
- 0
- if len(commitments[c]) == 1
- or "upwards deviation price" in commitments[c].columns
- else None
- ),
- # 0 if "upwards deviation price" in commitments[c].columns else None, # todo: possible simplification
+ None,
m.commitment_quantity[c, j]
+ m.commitment_downwards_deviation[c]
+ m.commitment_upwards_deviation[c]
- - sum(m.ems_power[:, j]),
- (
- 0
- if len(commitments[c]) == 1
- or "downwards deviation price" in commitments[c].columns
- else None
- ),
- # 0 if "downwards deviation price" in commitments[c].columns else None, # todo: possible simplification
+ - sum(m.ems_power[d, j] for d in devices),
+ None,
)
def device_derivative_equalities(m, d, j):
@@ -662,7 +791,7 @@ def device_derivative_equalities(m, d, j):
model.device_power_down_sign = Constraint(
model.d, model.j, rule=device_down_derivative_sign
)
- model.ems_power_bounds = Constraint(model.j, rule=ems_derivative_bounds)
+ model.ems_power_bounds = Constraint(model.eg, model.j, rule=ems_derivative_bounds)
if not convex_cost_curve:
model.commitment_up_derivative_sign_con = Constraint(
model.c, rule=commitment_up_derivative_sign
@@ -678,6 +807,29 @@ def device_derivative_equalities(m, d, j):
model.d, model.j, rule=device_derivative_equalities
)
+ if coupling_device_specs:
+ n_coupling_groups = len(coupling_groups)
+
+ # One free variable per group per time step: the common normalised flow.
+ model.coupling_group_range = RangeSet(0, n_coupling_groups - 1)
+ model.coupling_alpha = Var(model.coupling_group_range, model.j, domain=Reals)
+
+ model.coupling_device_range = RangeSet(0, len(coupling_device_specs) - 1)
+
+ def flow_coupling_rule(m, c, j):
+ """Enforce P[d, j] == coeff * alpha[group, j] for each coupled device.
+
+ This pins every device's flow to the same normalised level ``alpha``,
+ scaled by its coupling coefficient. The coefficient sign indicates direction:
+ positive for inputs (consuming), negative for outputs (producing).
+ """
+ g, d, coeff = coupling_device_specs[c]
+ return m.ems_power[d, j] == coeff * m.coupling_alpha[g, j]
+
+ model.flow_coupling_constraints = Constraint(
+ model.coupling_device_range, model.j, rule=flow_coupling_rule
+ )
+
# Add objective
def cost_function(m):
costs = 0
@@ -751,6 +903,22 @@ def cost_function(m):
)
model.commitment_costs = commitment_costs
+ commodity_costs = {}
+ for c in model.c:
+ commodity = None
+ if "commodity" in commitments[c].columns:
+ commodity = commitments[c]["commodity"].iloc[0]
+ if commodity is None or (isinstance(commodity, float) and np.isnan(commodity)):
+ continue
+
+ cost = value(
+ model.commitment_downwards_deviation[c] * model.down_price[c]
+ + model.commitment_upwards_deviation[c] * model.up_price[c]
+ )
+ commodity_costs[commodity] = commodity_costs.get(commodity, 0) + cost
+
+ model.commodity_costs = commodity_costs
+
# model.pprint()
# model.display()
# print(results.solver.termination_condition)
diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py
index b166ff0913..98a3e56f62 100644
--- a/flexmeasures/data/models/planning/storage.py
+++ b/flexmeasures/data/models/planning/storage.py
@@ -3,7 +3,8 @@
import re
import copy
from datetime import datetime, timedelta
-from typing import Type
+from itertools import chain
+from typing import Any, Type
import pandas as pd
import numpy as np
@@ -78,6 +79,31 @@ def compute_schedule(self) -> pd.Series | None:
return self.compute()
+ def _get_commodity_contexts(self) -> dict[str, dict]:
+ """Return commodity-specific flex-contexts.
+
+ Supports the new format:
+
+ "commodities": [
+ {"commodity": "electricity", ...},
+ {"commodity": "gas", ...},
+ ]
+
+ and keeps backwards compatibility with old top-level fields.
+ """
+
+ commodity_contexts = {}
+
+ for commodity_context in self.flex_context.get("commodity_contexts", []):
+ commodity = commodity_context["commodity"]
+ commodity_contexts[commodity] = commodity_context
+
+ # Backwards-compatible electricity defaults from old top-level fields.
+ if "electricity" not in commodity_contexts:
+ commodity_contexts["electricity"] = self.flex_context
+
+ return commodity_contexts
+
def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901
"""This function prepares the required data to compute the schedule:
- price data
@@ -96,13 +122,104 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901
resolution = self.resolution
belief_time = self.belief_time
+ # For backwards compatibility with the single asset scheduler
+ # Track whether we started with a single dict (single-sensor mode) or a list
+ is_single_sensor_mode = not isinstance(self.flex_model, list)
+ flex_model = self.flex_model.copy()
+ if not isinstance(flex_model, list):
+ flex_model = [flex_model]
+
+ # Identify stock models: entries not defining a power sensor, but only a (state-of-charge) sensor
+ self.stock_models = {}
+
+ device_models = [] # everything except stock models
+ stock_models = {} # stock models only
+
+ missing_soc_sensor_i = -len(flex_model)
+ for fm in flex_model:
+
+ # stock model: entry in the flex-model list where the sensor key is the state-of-charge sensor of the device (e.g. a stock)
+ # Only apply this detection in multi-device mode; in single-sensor mode the power sensor is self.sensor (not in the fm dict)
+ if (
+ not is_single_sensor_mode
+ and fm.get("sensor") is None
+ and (soc_sensor := fm.get("state_of_charge"))
+ ):
+ stock_models[
+ soc_sensor.id if isinstance(soc_sensor, Sensor) else soc_sensor
+ ] = fm
+ continue
+
+ """
+ [
+ {
+ "sensor": 1,
+ "charging-efficiency": 0.9,
+ "state-of-charge": {"sensor": 2},
+ },
+ {
+ "sensor": 3,
+ "charging-efficiency": 0.9,
+ "state-of-charge": {"sensor": 2},
+ },
+ {
+ "state-of-charge": {"sensor": 2},
+ "storage-efficiency": 0.99,
+ },
+ ]
+ """
+
+ # Check if this is a stock-only model (no power sensor)
+ # Stock-only entries have SOC parameters but no power sensor
+ # Only apply in multi-device mode; single-sensor mode devices have no "sensor" key by design
+ soc_sensor = fm.get("state_of_charge")
+ if (
+ not is_single_sensor_mode
+ and fm.get("sensor") is None
+ and soc_sensor is not None
+ ):
+ # This is a stock-only entry, add to stock_models only
+ soc_id = soc_sensor.id if isinstance(soc_sensor, Sensor) else soc_sensor
+ stock_models[soc_id] = fm
+ continue
+
+ # device model: entry in the flex-model list where the sensor key is the power sensor of the device (e.g. a feeder)
+ device_models.append(fm)
+
+ # If this device has state-of-charge parameters (soc-at-start, soc-min, etc.),
+ # also create a stock model entry so those parameters are properly captured
+ if soc_sensor is not None:
+ soc_id = soc_sensor.id if isinstance(soc_sensor, Sensor) else soc_sensor
+ # Check if there are SOC parameters in this device entry
+ has_soc_params = any(
+ param in fm
+ for param in ["soc_at_start", "soc_min", "soc_max", "soc_targets"]
+ )
+ if has_soc_params:
+ stock_models[soc_id] = fm
+ elif fm.get("state_of_charge") is None:
+ stock_models[missing_soc_sensor_i] = fm
+ missing_soc_sensor_i += 1
+
+ flex_model = device_models
+ self.stock_models = stock_models
+ self._device_models = (
+ device_models # Store filtered model for later use in _build_soc_schedule
+ )
+
+ # Rebuild stock_groups using only device_models (which have sensors)
+ # This ensures the mapping aligns with the device indices
+ self.stock_groups = self._build_stock_groups(device_models)
+
+ # Build coupling_groups from the 'coupling' and 'coupling_coefficient' fields
+ # of each device model. Devices sharing the same coupling name form a group.
+ self.coupling_groups = self._build_coupling_groups(device_models)
+
# List the asset(s) and sensor(s) being scheduled
if self.asset is not None:
if not isinstance(self.flex_model, list):
self.flex_model = [self.flex_model]
- sensors: list[Sensor | None] = [
- flex_model_d.get("sensor") for flex_model_d in self.flex_model
- ]
+ sensors: list[Sensor | None] = [fm.get("sensor") for fm in device_models]
assets: list[Asset | None] = [ # noqa: F841
s.asset if s is not None else flex_model_d.get("asset")
for s, flex_model_d in zip(sensors, self.flex_model)
@@ -120,31 +237,44 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901
asset = self.sensor.generic_asset
assets = [asset] # noqa: F841
- # For backwards compatibility with the single asset scheduler
- flex_model = self.flex_model.copy()
- if not isinstance(flex_model, list):
- flex_model = [flex_model]
+ num_flexible_devices = len(device_models)
- # total number of flexible devices D described in the flex-model
- num_flexible_devices = len(flex_model)
+ soc_at_start = [None] * num_flexible_devices
+ soc_targets = [None] * num_flexible_devices
+ soc_min = [None] * num_flexible_devices
+ soc_max = [None] * num_flexible_devices
+ soc_minima = [None] * num_flexible_devices
+ soc_maxima = [None] * num_flexible_devices
+ soc_gain = [None] * num_flexible_devices
+ soc_usage = [None] * num_flexible_devices
+ prefer_charging_sooner = [None] * num_flexible_devices
+ prefer_curtailing_later = [None] * num_flexible_devices
+
+ # Assign SOC constraints from stock model to the first device in each group
+ for stock_id, devices in self.stock_groups.items():
- soc_at_start = [flex_model_d.get("soc_at_start") for flex_model_d in flex_model]
- soc_targets = [flex_model_d.get("soc_targets") for flex_model_d in flex_model]
- soc_min = [flex_model_d.get("soc_min") for flex_model_d in flex_model]
- soc_max = [flex_model_d.get("soc_max") for flex_model_d in flex_model]
- soc_minima = [flex_model_d.get("soc_minima") for flex_model_d in flex_model]
- soc_maxima = [flex_model_d.get("soc_maxima") for flex_model_d in flex_model]
+ stock_model = self.stock_models.get(stock_id)
+
+ if stock_model is None:
+ continue
+
+ d0 = devices[0]
+
+ soc_at_start[d0] = stock_model.get("soc_at_start")
+ soc_targets[d0] = stock_model.get("soc_targets")
+ soc_min[d0] = stock_model.get("soc_min")
+ soc_max[d0] = stock_model.get("soc_max")
+ soc_minima[d0] = stock_model.get("soc_minima")
+ soc_maxima[d0] = stock_model.get("soc_maxima")
+ soc_gain[d0] = stock_model.get("soc_gain")
+ soc_usage[d0] = stock_model.get("soc_usage")
+ prefer_charging_sooner[d0] = stock_model.get("prefer_charging_sooner")
+ prefer_curtailing_later[d0] = stock_model.get("prefer_curtailing_later")
+
+ # todo: move storage-efficiency into a shared parameter for the first device belonging to a shared storage
storage_efficiency = [
flex_model_d.get("storage_efficiency") for flex_model_d in flex_model
]
- prefer_charging_sooner = [
- flex_model_d.get("prefer_charging_sooner") for flex_model_d in flex_model
- ]
- prefer_curtailing_later = [
- flex_model_d.get("prefer_curtailing_later") for flex_model_d in flex_model
- ]
- soc_gain = [flex_model_d.get("soc_gain") for flex_model_d in flex_model]
- soc_usage = [flex_model_d.get("soc_usage") for flex_model_d in flex_model]
consumption = [flex_model_d.get("consumption") for flex_model_d in flex_model]
production = [flex_model_d.get("production") for flex_model_d in flex_model]
consumption_capacity = [
@@ -161,279 +291,428 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901
]
# Get info from flex-context
- consumption_price_sensor = self.flex_context.get("consumption_price_sensor")
- production_price_sensor = self.flex_context.get("production_price_sensor")
- consumption_price = self.flex_context.get(
- "consumption_price", consumption_price_sensor
- )
- production_price = self.flex_context.get(
- "production_price", production_price_sensor
- )
- # fallback to using the consumption price, for backwards compatibility
- if production_price is None:
- production_price = consumption_price
- inflexible_device_sensors = self.flex_context.get(
+ inflexible_device_sensors: list[Sensor] = self.flex_context.get(
"inflexible_device_sensors", []
)
- # Fetch the device's power capacity (required Sensor attribute)
+ # Fetch the device's power capacity required by the device constraints.
power_capacity_in_mw = self._get_device_power_capacity(flex_model, assets)
- # Check for known prices or price forecasts
- up_deviation_prices = get_continuous_series_sensor_or_quantity(
- variable_quantity=consumption_price,
- unit=self.flex_context["shared_currency_unit"] + "/MWh",
- query_window=(start, end),
- resolution=resolution,
- beliefs_before=belief_time,
- fill_sides=True,
- ).to_frame(name="event_value")
- ensure_prices_are_not_empty(up_deviation_prices, consumption_price)
- down_deviation_prices = get_continuous_series_sensor_or_quantity(
- variable_quantity=production_price,
- unit=self.flex_context["shared_currency_unit"] + "/MWh",
- query_window=(start, end),
- resolution=resolution,
- beliefs_before=belief_time,
- fill_sides=True,
- ).to_frame(name="event_value")
- ensure_prices_are_not_empty(down_deviation_prices, production_price)
-
+ # Convert to UTC before fetching time series.
start = pd.Timestamp(start).tz_convert("UTC")
end = pd.Timestamp(end).tz_convert("UTC")
- # Create Series with EMS capacities
- ems_power_capacity_in_mw = get_continuous_series_sensor_or_quantity(
- variable_quantity=self.flex_context.get("ems_power_capacity_in_mw"),
- unit="MW",
- query_window=(start, end),
- resolution=resolution,
- beliefs_before=belief_time,
- resolve_overlaps="min",
- )
- ems_consumption_capacity = get_continuous_series_sensor_or_quantity(
- variable_quantity=self.flex_context.get("ems_consumption_capacity_in_mw"),
- unit="MW",
- query_window=(start, end),
- resolution=resolution,
- beliefs_before=belief_time,
- max_value=ems_power_capacity_in_mw,
- resolve_overlaps="min",
- )
- ems_production_capacity = -1 * get_continuous_series_sensor_or_quantity(
- variable_quantity=self.flex_context.get("ems_production_capacity_in_mw"),
- unit="MW",
+ # Set up commitments to optimise for.
+ commitments = self.convert_to_commitments(
query_window=(start, end),
resolution=resolution,
beliefs_before=belief_time,
- max_value=ems_power_capacity_in_mw,
- resolve_overlaps="min",
- )
-
- # Set up commitments to optimise for
- commitments = self.convert_to_commitments(
- query_window=(start, end), resolution=resolution, beliefs_before=belief_time
+ flex_model=flex_model,
)
index = initialize_index(start, end, resolution)
commitment_quantities = initialize_series(0, start, end, resolution)
- # Convert energy prices to EUR/(deviation of commitment, which is in MW)
- commitment_upwards_deviation_price = (
- up_deviation_prices.loc[start : end - resolution]["event_value"]
- * resolution
- / pd.Timedelta("1h")
+ # EMS constraints are kept per commodity (one device group per commodity).
+ #
+ # The site-power / site-consumption / site-production capacities
+ # are enforced as hard EMS-level constraints (derivative max/min). Because each
+ # commodity has its own set of devices, ``ems_constraints`` is a list of
+ # DataFrames and ``ems_constraint_groups`` lists the device indices each
+ # DataFrame applies to. The device_scheduler then bounds the summed flow of each
+ # commodity's devices separately (instead of summing across all commodities).
+ #
+ # The commodity-specific breach/peak penalties below remain modelled as
+ # FlowCommitments on top of these hard constraints.
+ ems_constraints: list[pd.DataFrame] = []
+ ems_constraint_groups: list[list[int]] = []
+
+ def device_list_series(
+ devices: list[int], index: pd.DatetimeIndex
+ ) -> pd.Series:
+ return pd.Series([tuple(devices)] * len(index), index=index, name="device")
+
+ # Set up a mapping between commodities and a list of enumerated flexible and inflexible devices
+ # 1. The enumeration starts with all flexible devices in the order that they are given in the flex-model
+ # 2. The enumeration continues with the inflexible devices referenced in the top-level flex-context, in the order given
+ # 3. Then the enumeration goes through the commodities under the commodity_contexts field, in the order given,
+ # extending the enumeration with the inflexible devices referenced in these commodity contexts.
+ commodity_to_devices = {}
+ # Step 1: enumerate the flexible devices
+ for d, flex_model_d in enumerate(flex_model):
+ commodity = flex_model_d.get("commodity", "electricity")
+ commodity_to_devices.setdefault(commodity, []).append(d)
+
+ # Step 2: enumerate the top-level inflexible devices (electric for backwards compatibility)
+ num_flexible_devices = len(flex_model)
+ commodity_to_devices["electricity"] += list(
+ range(
+ num_flexible_devices,
+ num_flexible_devices + len(inflexible_device_sensors),
+ )
)
- commitment_downwards_deviation_price = (
- down_deviation_prices.loc[start : end - resolution]["event_value"]
- * resolution
- / pd.Timedelta("1h")
+
+ # Step 3: enumerate the inflexible devices per commodity
+ def list_to_map(listing: list, key: Any) -> dict:
+ """Note: the key is retained in the map values."""
+ return {l[key]: l for l in listing}
+
+ # Move inflexible-device-sensors per commodity into the device listing for the commodity
+ commodity_mapping: dict[str, dict] = list_to_map(
+ self.flex_context.get("commodity_contexts", []), key="commodity"
)
+ inflexible_devices_per_commodity = {
+ com: con.get("inflexible_device_sensors", [])
+ for com, con in commodity_mapping.items()
+ }
+ num_devices = num_flexible_devices + len(inflexible_device_sensors)
+ for (
+ commodity,
+ commodity_inflexible_device_sensors,
+ ) in inflexible_devices_per_commodity.items():
+ commodity_to_devices[commodity] += list(
+ range(
+ num_devices,
+ num_devices + len(commodity_inflexible_device_sensors),
+ )
+ )
+ num_devices = num_devices + len(commodity_inflexible_device_sensors)
- # Set up commitments DataFrame
- commitment = FlowCommitment(
- name="energy",
- quantity=commitment_quantities,
- upwards_deviation_price=commitment_upwards_deviation_price,
- downwards_deviation_price=commitment_downwards_deviation_price,
- index=index,
+ inflexible_device_sensors = inflexible_device_sensors + list(
+ chain.from_iterable(inflexible_devices_per_commodity.values())
)
- commitments.append(commitment)
- # Set up peak commitments
- if self.flex_context.get("ems_peak_consumption_price") is not None:
- ems_peak_consumption = get_continuous_series_sensor_or_quantity(
- variable_quantity=self.flex_context.get("ems_peak_consumption_in_mw"),
- unit="MW",
- query_window=(start, end),
- resolution=resolution,
- beliefs_before=belief_time,
- max_value=np.inf, # np.nan -> np.inf to ignore commitment if no quantity is given
- fill_sides=True,
+ commodity_contexts = self._get_commodity_contexts()
+ price_frames_by_commodity = {}
+
+ for commodity, devices in commodity_to_devices.items():
+ commodity_devices = device_list_series(devices, index)
+ commodity_context = commodity_contexts.get(commodity, {})
+
+ # Get info from commodity_context
+ consumption_price_sensor = commodity_context.get("consumption_price_sensor")
+ production_price_sensor = commodity_context.get("production_price_sensor")
+ consumption_price = commodity_context.get(
+ "consumption_price", consumption_price_sensor
)
- ems_peak_consumption_price = self.flex_context.get(
- "ems_peak_consumption_price"
+ production_price = commodity_context.get(
+ "production_price", production_price_sensor
)
- ems_peak_consumption_price = get_continuous_series_sensor_or_quantity(
- variable_quantity=ems_peak_consumption_price,
- unit=self.flex_context["shared_currency_unit"] + "/MW",
+
+ if production_price is None:
+ production_price = consumption_price
+
+ # todo: log info statement if commodity has no associated prices
+ # todo: raise if none of the commodities (or maybe electricity specifically) has prices
+ # if consumption_price is None:
+ # raise ValueError(
+ # f"Missing consumption price for commodity '{commodity}'."
+ # )
+
+ # Energy prices for this commodity.
+ up_deviation_prices = get_continuous_series_sensor_or_quantity(
+ variable_quantity=consumption_price,
+ unit=self.flex_context["shared_currency_unit"] + "/MWh",
query_window=(start, end),
resolution=resolution,
beliefs_before=belief_time,
fill_sides=True,
- )
+ ).to_frame(name="event_value")
+ # todo: see above todo
+ # ensure_prices_are_not_empty(up_deviation_prices, consumption_price)
- # Set up commitments DataFrame
- commitment = FlowCommitment(
- name="consumption peak",
- quantity=ems_peak_consumption,
- # positive price because breaching in the upwards (consumption) direction is penalized
- upwards_deviation_price=ems_peak_consumption_price,
- _type="any",
- index=index,
- )
- commitments.append(commitment)
- if self.flex_context.get("ems_peak_production_price") is not None:
- ems_peak_production = get_continuous_series_sensor_or_quantity(
- variable_quantity=self.flex_context.get("ems_peak_production_in_mw"),
- unit="MW",
+ down_deviation_prices = get_continuous_series_sensor_or_quantity(
+ variable_quantity=production_price,
+ unit=self.flex_context["shared_currency_unit"] + "/MWh",
query_window=(start, end),
resolution=resolution,
beliefs_before=belief_time,
- max_value=np.inf, # np.nan -> np.inf to ignore commitment if no quantity is given
fill_sides=True,
+ ).to_frame(name="event_value")
+ # todo: see above todo
+ # ensure_prices_are_not_empty(down_deviation_prices, production_price)
+
+ price_frames_by_commodity[commodity] = up_deviation_prices
+
+ # Convert energy prices to price per MW deviation for one resolution step.
+ up_price = (
+ up_deviation_prices.loc[start : end - resolution]["event_value"]
+ * resolution
+ / pd.Timedelta("1h")
)
- ems_peak_production_price = self.flex_context.get(
- "ems_peak_production_price"
+ down_price = (
+ down_deviation_prices.loc[start : end - resolution]["event_value"]
+ * resolution
+ / pd.Timedelta("1h")
+ )
+
+ commitments.append(
+ FlowCommitment(
+ name=f"{commodity} net energy",
+ quantity=commitment_quantities,
+ upwards_deviation_price=up_price,
+ downwards_deviation_price=down_price,
+ commodity=commodity,
+ index=index,
+ device=commodity_devices,
+ device_group=commodity,
+ )
)
- ems_peak_production_price = get_continuous_series_sensor_or_quantity(
- variable_quantity=ems_peak_production_price,
- unit=self.flex_context["shared_currency_unit"] + "/MW",
+
+ # Commodity-specific site capacities.
+ # These are not written into ems_constraints. Instead, they are added as
+ # FlowCommitments that only aggregate the devices of this commodity.
+ ems_power_capacity = get_continuous_series_sensor_or_quantity(
+ variable_quantity=commodity_context.get("ems_power_capacity_in_mw"),
+ unit="MW",
query_window=(start, end),
resolution=resolution,
beliefs_before=belief_time,
- fill_sides=True,
- )
-
- # Set up commitments DataFrame
- commitment = FlowCommitment(
- name="production peak",
- quantity=-ems_peak_production, # production is negative quantity
- # negative price because peaking in the downwards (production) direction is penalized
- downwards_deviation_price=-ems_peak_production_price,
- _type="any",
- index=index,
+ resolve_overlaps="min",
)
- commitments.append(commitment)
-
- # Set up capacity breach commitments and EMS capacity constraints
- ems_consumption_breach_price = self.flex_context.get(
- "ems_consumption_breach_price"
- )
-
- ems_production_breach_price = self.flex_context.get(
- "ems_production_breach_price"
- )
- ems_constraints = initialize_df(
- StorageScheduler.COLUMNS, start, end, resolution
- )
- if ems_consumption_breach_price is not None:
-
- # Convert to Series
- any_ems_consumption_breach_price = get_continuous_series_sensor_or_quantity(
- variable_quantity=ems_consumption_breach_price,
- unit=self.flex_context["shared_currency_unit"] + "/MW",
+ ems_consumption_capacity = get_continuous_series_sensor_or_quantity(
+ variable_quantity=commodity_context.get(
+ "ems_consumption_capacity_in_mw"
+ ),
+ unit="MW",
query_window=(start, end),
resolution=resolution,
beliefs_before=belief_time,
- fill_sides=True,
+ max_value=ems_power_capacity,
+ resolve_overlaps="min",
)
- all_ems_consumption_breach_price = get_continuous_series_sensor_or_quantity(
- variable_quantity=ems_consumption_breach_price,
- unit=self.flex_context["shared_currency_unit"]
- + "/MW*h", # from EUR/MWh to EUR/MW/resolution
+
+ ems_production_capacity = -1 * get_continuous_series_sensor_or_quantity(
+ variable_quantity=commodity_context.get(
+ "ems_production_capacity_in_mw"
+ ),
+ unit="MW",
query_window=(start, end),
resolution=resolution,
beliefs_before=belief_time,
- fill_sides=True,
+ max_value=ems_power_capacity,
+ resolve_overlaps="min",
)
- # Set up commitments DataFrame to penalize any breach
- commitment = FlowCommitment(
- name="any consumption breach",
- quantity=ems_consumption_capacity,
- # positive price because breaching in the upwards (consumption) direction is penalized
- upwards_deviation_price=any_ems_consumption_breach_price,
- _type="any",
- index=index,
- )
- commitments.append(commitment)
-
- # Set up commitments DataFrame to penalize each breach
- commitment = FlowCommitment(
- name="all consumption breaches",
- quantity=ems_consumption_capacity,
- # positive price because breaching in the upwards (consumption) direction is penalized
- upwards_deviation_price=all_ems_consumption_breach_price,
- index=index,
- )
- commitments.append(commitment)
+ # Commodity-specific peak consumption commitment.
+ if commodity_context.get("ems_peak_consumption_price") is not None:
+ ems_peak_consumption = get_continuous_series_sensor_or_quantity(
+ variable_quantity=commodity_context.get(
+ "ems_peak_consumption_in_mw"
+ ),
+ unit="MW",
+ query_window=(start, end),
+ resolution=resolution,
+ beliefs_before=belief_time,
+ max_value=np.inf, # np.nan -> np.inf to ignore commitment if no quantity is given
+ fill_sides=True,
+ )
+ ems_peak_consumption_price = get_continuous_series_sensor_or_quantity(
+ variable_quantity=commodity_context.get(
+ "ems_peak_consumption_price"
+ ),
+ unit=self.flex_context["shared_currency_unit"] + "/MW",
+ query_window=(start, end),
+ resolution=resolution,
+ beliefs_before=belief_time,
+ fill_sides=True,
+ )
- # Take the physical capacity as a hard constraint
- ems_constraints["derivative max"] = ems_power_capacity_in_mw
- else:
- # Take the contracted capacity as a hard constraint
- ems_constraints["derivative max"] = ems_consumption_capacity
+ commitments.append(
+ FlowCommitment(
+ name=f"{commodity} consumption peak",
+ quantity=ems_peak_consumption,
+ upwards_deviation_price=ems_peak_consumption_price,
+ _type="any",
+ index=index,
+ device=commodity_devices,
+ device_group=commodity,
+ commodity=commodity,
+ )
+ )
- if ems_production_breach_price is not None:
+ # Commodity-specific peak production commitment.
+ if commodity_context.get("ems_peak_production_price") is not None:
+ ems_peak_production = get_continuous_series_sensor_or_quantity(
+ variable_quantity=commodity_context.get(
+ "ems_peak_production_in_mw"
+ ),
+ unit="MW",
+ query_window=(start, end),
+ resolution=resolution,
+ beliefs_before=belief_time,
+ max_value=np.inf, # np.nan -> np.inf to ignore commitment if no quantity is given
+ fill_sides=True,
+ )
+ ems_peak_production_price = get_continuous_series_sensor_or_quantity(
+ variable_quantity=commodity_context.get(
+ "ems_peak_production_price"
+ ),
+ unit=self.flex_context["shared_currency_unit"] + "/MW",
+ query_window=(start, end),
+ resolution=resolution,
+ beliefs_before=belief_time,
+ fill_sides=True,
+ )
- # Convert to Series
- any_ems_production_breach_price = get_continuous_series_sensor_or_quantity(
- variable_quantity=ems_production_breach_price,
- unit=self.flex_context["shared_currency_unit"] + "/MW",
- query_window=(start, end),
- resolution=resolution,
- beliefs_before=belief_time,
- fill_sides=True,
- )
- all_ems_production_breach_price = get_continuous_series_sensor_or_quantity(
- variable_quantity=ems_production_breach_price,
- unit=self.flex_context["shared_currency_unit"]
- + "/MW*h", # from EUR/MWh to EUR/MW/resolution
- query_window=(start, end),
- resolution=resolution,
- beliefs_before=belief_time,
- fill_sides=True,
- )
+ commitments.append(
+ FlowCommitment(
+ name=f"{commodity} production peak",
+ quantity=-ems_peak_production, # production is negative quantity
+ # negative price because peaking in the downwards (production) direction is penalized
+ downwards_deviation_price=-ems_peak_production_price,
+ _type="any",
+ index=index,
+ device=commodity_devices,
+ device_group=commodity,
+ commodity=commodity,
+ )
+ )
- # Set up commitments DataFrame to penalize any breach
- commitment = FlowCommitment(
- name="any production breach",
- quantity=ems_production_capacity,
- # negative price because breaching in the downwards (production) direction is penalized
- downwards_deviation_price=-any_ems_production_breach_price,
- _type="any",
- index=index,
+ # Set up capacity breach commitments and EMS capacity constraints
+ ems_consumption_breach_price = commodity_context.get(
+ "ems_consumption_breach_price"
)
- commitments.append(commitment)
-
- # Set up commitments DataFrame to penalize each breach
- commitment = FlowCommitment(
- name="all production breaches",
- quantity=ems_production_capacity,
- # negative price because breaching in the downwards (production) direction is penalized
- downwards_deviation_price=-all_ems_production_breach_price,
- index=index,
+ ems_production_breach_price = commodity_context.get(
+ "ems_production_breach_price"
)
- commitments.append(commitment)
- # Take the physical capacity as a hard constraint
- ems_constraints["derivative min"] = -ems_power_capacity_in_mw
- else:
- # Take the contracted capacity as a hard constraint
- ems_constraints["derivative min"] = ems_production_capacity
+ # Commodity-specific site consumption breach.
+ if ems_consumption_breach_price is not None:
+ # Convert to Series
+ any_ems_consumption_breach_price = (
+ get_continuous_series_sensor_or_quantity(
+ variable_quantity=ems_consumption_breach_price,
+ unit=self.flex_context["shared_currency_unit"] + "/MW",
+ query_window=(start, end),
+ resolution=resolution,
+ beliefs_before=belief_time,
+ fill_sides=True,
+ )
+ )
+ all_ems_consumption_breach_price = (
+ get_continuous_series_sensor_or_quantity(
+ variable_quantity=ems_consumption_breach_price,
+ unit=self.flex_context["shared_currency_unit"]
+ + "/MW*h", # from EUR/MWh to EUR/MW/resolution
+ query_window=(start, end),
+ resolution=resolution,
+ beliefs_before=belief_time,
+ fill_sides=True,
+ )
+ )
+
+ commitments.append(
+ FlowCommitment(
+ name=f"{commodity} any consumption breach",
+ quantity=ems_consumption_capacity,
+ # positive price because breaching in the upwards (consumption) direction is penalized
+ upwards_deviation_price=any_ems_consumption_breach_price,
+ _type="any",
+ index=index,
+ device=commodity_devices,
+ device_group=commodity,
+ commodity=commodity,
+ )
+ )
+
+ commitments.append(
+ FlowCommitment(
+ name=f"{commodity} all consumption breaches",
+ quantity=ems_consumption_capacity,
+ # positive price because breaching in the upwards (consumption) direction is penalized
+ upwards_deviation_price=all_ems_consumption_breach_price,
+ index=index,
+ device=commodity_devices,
+ device_group=commodity,
+ commodity=commodity,
+ )
+ )
+
+ # Commodity-specific site production breach.
+ if ems_production_breach_price is not None:
+
+ # Convert to Series
+ any_ems_production_breach_price = (
+ get_continuous_series_sensor_or_quantity(
+ variable_quantity=ems_production_breach_price,
+ unit=self.flex_context["shared_currency_unit"] + "/MW",
+ query_window=(start, end),
+ resolution=resolution,
+ beliefs_before=belief_time,
+ fill_sides=True,
+ )
+ )
+ all_ems_production_breach_price = (
+ get_continuous_series_sensor_or_quantity(
+ variable_quantity=ems_production_breach_price,
+ unit=self.flex_context["shared_currency_unit"]
+ + "/MW*h", # from EUR/MWh to EUR/MW/resolution
+ query_window=(start, end),
+ resolution=resolution,
+ beliefs_before=belief_time,
+ fill_sides=True,
+ )
+ )
+
+ # Set up commitments DataFrame to penalize any breach
+ commitments.append(
+ FlowCommitment(
+ name=f"{commodity} any production breach",
+ quantity=ems_production_capacity,
+ # negative price because breaching in the downwards (production) direction is penalized
+ downwards_deviation_price=-any_ems_production_breach_price,
+ _type="any",
+ index=index,
+ device=commodity_devices,
+ device_group=commodity,
+ commodity=commodity,
+ )
+ )
+ # Set up commitments DataFrame to penalize each breach
+ commitments.append(
+ FlowCommitment(
+ name=f"{commodity} all production breaches",
+ quantity=ems_production_capacity,
+ # negative price because breaching in the downwards (production) direction is penalized
+ downwards_deviation_price=-all_ems_production_breach_price,
+ index=index,
+ device=commodity_devices,
+ device_group=commodity,
+ commodity=commodity,
+ )
+ )
+
+ # Hard EMS-level capacity constraint for this commodity's device group.
+ # If a breach price is set, the physical power capacity is the
+ # hard limit (the contracted capacity is then only softly penalised via the
+ # breach commitments above); otherwise the contracted capacity itself is the
+ # hard limit.
+ commodity_ems_constraints = initialize_df(
+ StorageScheduler.COLUMNS, start, end, resolution
+ )
+ if ems_consumption_breach_price is not None:
+ commodity_ems_constraints["derivative max"] = ems_power_capacity
+ else:
+ commodity_ems_constraints["derivative max"] = ems_consumption_capacity
+ if ems_production_breach_price is not None:
+ commodity_ems_constraints["derivative min"] = -ems_power_capacity
+ else:
+ commodity_ems_constraints["derivative min"] = ems_production_capacity
+ ems_constraints.append(commodity_ems_constraints)
+ ems_constraint_groups.append(list(devices))
+
+ # Keep one price frame for later preference logic.
+ # The existing "prefer charging sooner" code uses `up_deviation_prices`.
+ # Prefer electricity prices if available, otherwise use the first commodity price.
+ if "electricity" in price_frames_by_commodity:
+ up_deviation_prices = price_frames_by_commodity["electricity"]
+ elif price_frames_by_commodity:
+ up_deviation_prices = next(iter(price_frames_by_commodity.values()))
+ else:
+ raise ValueError("No commodity prices were available.")
# Commitments per device
# StockCommitment per device to prefer a full storage by penalizing not being full
@@ -651,7 +930,15 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901
# soc-maxima will become a soft constraint (modelled as stock commitments), so remove hard constraint
soc_maxima[d] = None
- if soc_at_start[d] is not None:
+ # only apply SOC constraints to the first device of a shared stock
+ apply_soc_constraints = True
+
+ for stock_id, devices in self.stock_groups.items():
+ if d in devices and d != devices[0]:
+ apply_soc_constraints = False
+ break
+
+ if soc_at_start[d] is not None and apply_soc_constraints:
device_constraints[d] = add_storage_constraints(
start,
end,
@@ -964,6 +1251,42 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901
+ message
)
+ # --- apply shared stock groups
+ # Store original stock_delta values for use in _build_soc_schedule
+ original_stock_deltas = [
+ device_constraints[d]["stock delta"].copy()
+ for d in range(len(device_constraints))
+ ]
+
+ if hasattr(self, "stock_groups") and self.stock_groups:
+ for stock_id, devices in self.stock_groups.items():
+
+ if len(devices) <= 1:
+ continue
+
+ d0 = devices[0]
+
+ # Combine all stock_deltas on the primary device
+ # This ensures the optimizer sees a single shared stock
+ combined_delta = sum(
+ device_constraints[d]["stock delta"] for d in devices
+ )
+ device_constraints[d0]["stock delta"] = combined_delta
+
+ # Secondary devices: zero out stock_delta (it's now in primary) but keep power contribution
+ for d in devices[1:]:
+ # Zero out stock_delta since it's now in primary device's combined_delta
+ device_constraints[d]["stock delta"] = 0
+
+ # disable stock bounds for secondary devices
+ device_constraints[d]["equals"] = np.nan
+ device_constraints[d]["min"] = np.nan
+ device_constraints[d]["max"] = np.nan
+
+ # Store original stock_deltas for use in _build_soc_schedule
+ self.original_stock_deltas = original_stock_deltas
+ # Device indices each EMS constraint DataFrame applies to (one group per commodity).
+ self.ems_constraint_groups = ems_constraint_groups
return (
sensors,
start,
@@ -977,6 +1300,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901
def convert_to_commitments(
self,
+ flex_model,
**timing_kwargs,
) -> list[FlowCommitment | StockCommitment]:
"""Convert list of commitment specifications (dicts) to a list of FlowCommitments."""
@@ -1014,7 +1338,13 @@ def convert_to_commitments(
commitment_spec["index"] = initialize_index(
start, end, timing_kwargs["resolution"]
)
- commitments.append(FlowCommitment(**commitment_spec))
+ for d, flex_model_d in enumerate(flex_model):
+ commitment = FlowCommitment(
+ device=d,
+ device_group=flex_model_d["commodity"],
+ **commitment_spec,
+ )
+ commitments.append(commitment)
return commitments
@@ -1074,13 +1404,26 @@ def deserialize_flex_config(self):
self.flex_model
)
for d, sensor_flex_model in enumerate(self.flex_model):
- sensor_flex_model["sensor_flex_model"] = self.ensure_soc_at_start(
- flex_model=sensor_flex_model["sensor_flex_model"],
- sensor=sensor_flex_model.get("sensor"),
+ # todo: this fails but I'm not sure about the reason(haven't looked into it deeply yet).
+ # sensor_flex_model["sensor_flex_model"] = self.ensure_soc_at_start(
+ # flex_model=sensor_flex_model["sensor_flex_model"],
+ # sensor=sensor_flex_model.get("sensor"),
+ # )
+ soc_sensor_id = (
+ sensor_flex_model["sensor_flex_model"]
+ .get("state-of-charge", {})
+ .get("sensor", None)
)
+ soc_sensor = None
+ if soc_sensor_id is not None:
+ soc_sensor = Sensor.query.filter_by(id=soc_sensor_id).first()
self.flex_model[d] = StorageFlexModelSchema(
start=self.start,
- sensor=sensor_flex_model.get("sensor"),
+ sensor=(
+ sensor_flex_model.get("sensor")
+ if sensor_flex_model.get("sensor") is not None
+ else soc_sensor
+ ),
default_soc_unit=sensor_flex_model["sensor_flex_model"].get(
"soc-unit"
),
@@ -1093,6 +1436,7 @@ def deserialize_flex_config(self):
soc_targets=self.flex_model[d].get("soc_targets"),
sensor=self.flex_model[d]["sensor"],
)
+ self.stock_groups = self._build_stock_groups(self.flex_model)
else:
raise TypeError(
@@ -1588,61 +1932,114 @@ class StorageScheduler(MetaStorageScheduler):
@staticmethod
def _build_soc_schedule(
flex_model: list[dict],
- ems_schedule: pd.DataFrame,
+ ems_schedule: list[pd.Series],
soc_at_start: list[float],
device_constraints: list,
resolution: timedelta,
+ stock_groups: dict[int, list[int]],
) -> dict:
- """Build the state-of-charge schedule for each device that has a state-of-charge sensor.
+ """Build the state-of-charge schedule for each stock group.
+
+ Supports both:
+ - original logic: one device per stock group
+ - local/shared-stock logic: multiple devices contribute to one shared stock
- Converts the integrated power schedule from MWh to the sensor's unit.
- For sensors with a '%' unit, the soc-max flex-model field is used as capacity.
- If soc-max is missing or zero for a '%' sensor, the schedule is skipped with a warning.
+ For shared stock groups, each device contribution is integrated separately with
+ its own efficiencies and stock delta, then summed on top of the shared initial stock.
- Note: soc-max is a QuantityField (not a VariableQuantityField), so it is always a float
- after deserialization and cannot be a sensor reference. The isinstance guard below is
- therefore a defensive check for forward-compatibility.
+ Converts the integrated stock schedule from MWh to the state-of-charge sensor unit.
+ For '%' sensors, the soc-max flex-model field is used as capacity.
"""
soc_schedule = {}
- for d, flex_model_d in enumerate(flex_model):
- state_of_charge_sensor = flex_model_d.get("state_of_charge", None)
+
+ for stock_id, devices in stock_groups.items():
+ if not devices:
+ continue
+
+ d0 = devices[0]
+ flex_model_d0 = flex_model[d0]
+
+ state_of_charge_sensor = flex_model_d0.get("state_of_charge")
if isinstance(state_of_charge_sensor, SensorReference):
state_of_charge_sensor = state_of_charge_sensor.sensor
if not isinstance(state_of_charge_sensor, Sensor):
continue
+
+ # Build the SoC series for this stock group
+ if len(devices) > 1:
+ soc_contributions = []
+ reference_index = None
+
+ for d in devices:
+ contribution = integrate_time_series(
+ series=ems_schedule[d],
+ initial_stock=0,
+ stock_delta=device_constraints[d]["stock delta"]
+ * resolution
+ / timedelta(hours=1),
+ up_efficiency=device_constraints[d]["derivative up efficiency"],
+ down_efficiency=device_constraints[d][
+ "derivative down efficiency"
+ ],
+ storage_efficiency=device_constraints[d]["efficiency"]
+ .astype(float)
+ .fillna(1),
+ )
+ soc_contributions.append(contribution)
+
+ if reference_index is None:
+ reference_index = contribution.index
+
+ initial_stock = soc_at_start[d0] if soc_at_start[d0] is not None else 0
+ soc = pd.Series(
+ [
+ initial_stock
+ + sum(contrib.iloc[i] for contrib in soc_contributions)
+ for i in range(len(soc_contributions[0]))
+ ],
+ index=reference_index,
+ )
+ else:
+ soc = integrate_time_series(
+ series=ems_schedule[d0],
+ initial_stock=soc_at_start[d0],
+ stock_delta=device_constraints[d0]["stock delta"]
+ * resolution
+ / timedelta(hours=1),
+ up_efficiency=device_constraints[d0]["derivative up efficiency"],
+ down_efficiency=device_constraints[d0][
+ "derivative down efficiency"
+ ],
+ storage_efficiency=device_constraints[d0]["efficiency"]
+ .astype(float)
+ .fillna(1),
+ )
+
+ # Convert to sensor unit
soc_unit = state_of_charge_sensor.unit
capacity = None
if soc_unit == "%":
- soc_max = flex_model_d.get("soc_max")
+ soc_max = flex_model_d0.get("soc_max")
if isinstance(soc_max, (Sensor, SensorReference)):
raise ValueError(
- f"Cannot convert state-of-charge schedule to '%' unit for sensor {state_of_charge_sensor.id}: "
- "soc-max as a sensor reference is not supported for '%' unit conversion. "
- "Skipping state-of-charge schedule."
+ f"Cannot convert state-of-charge schedule to '%' unit for sensor "
+ f"{state_of_charge_sensor.id}: soc-max as a sensor reference is "
+ "not supported for '%' unit conversion."
)
if not soc_max:
raise ValueError(
- f"Cannot convert state-of-charge schedule to '%' unit for sensor {state_of_charge_sensor.id}: "
- "soc-max is missing or zero. Skipping state-of-charge schedule."
+ f"Cannot convert state-of-charge schedule to '%' unit for sensor "
+ f"{state_of_charge_sensor.id}: soc-max is missing or zero."
)
- capacity = f"{soc_max} MWh" # all flex model fields are in MWh by now
+ capacity = f"{soc_max} MWh"
+
soc_schedule[state_of_charge_sensor] = convert_units(
- integrate_time_series(
- series=ems_schedule[d],
- initial_stock=soc_at_start[d],
- stock_delta=device_constraints[d]["stock delta"]
- * resolution
- / timedelta(hours=1),
- up_efficiency=device_constraints[d]["derivative up efficiency"],
- down_efficiency=device_constraints[d]["derivative down efficiency"],
- storage_efficiency=device_constraints[d]["efficiency"]
- .astype(float)
- .fillna(1),
- ),
+ soc,
from_unit="MWh",
to_unit=soc_unit,
capacity=capacity,
)
+
return soc_schedule
@staticmethod
@@ -1748,18 +2145,25 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType:
commitments,
) = self._prepare(skip_validation=skip_validation)
+ initial_stock = [0] * len(soc_at_start)
+
+ for stock_id, devices in self.stock_groups.items():
+ d0 = devices[0]
+ s = soc_at_start[d0]
+
+ value = s * (timedelta(hours=1) / resolution) if s is not None else 0
+
+ for d in devices:
+ initial_stock[d] = value
+
ems_schedule, expected_costs, scheduler_results, model = device_scheduler(
device_constraints=device_constraints,
ems_constraints=ems_constraints,
commitments=commitments,
- initial_stock=[
- (
- soc_at_start_d * (timedelta(hours=1) / resolution)
- if soc_at_start_d is not None
- else 0
- )
- for soc_at_start_d in soc_at_start
- ],
+ initial_stock=initial_stock,
+ stock_groups=self.stock_groups,
+ coupling_groups=self.coupling_groups if self.coupling_groups else None,
+ ems_constraint_groups=self.ems_constraint_groups,
)
if "infeasible" in (tc := scheduler_results.solver.termination_condition):
raise InfeasibleProblemException(tc)
@@ -1792,18 +2196,31 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType:
if sensor is not None
}
- flex_model = self.flex_model.copy()
+ # Use the filtered device_models (stored during _prepare) not self.flex_model
+ # because stock_groups was rebuilt with device indices, not original indices
+ flex_model_for_soc = getattr(self, "_device_models", None)
+ if flex_model_for_soc is None:
+ # Fallback: reconstruct if not available (shouldn't happen in normal flow)
+ flex_model_for_soc = (
+ self.flex_model.copy()
+ if isinstance(self.flex_model, dict)
+ else [fm for fm in self.flex_model if fm.get("sensor") is not None]
+ )
- if not isinstance(self.flex_model, list):
- flex_model["sensor"] = sensors[0]
- flex_model = [flex_model]
+ if not isinstance(flex_model_for_soc, list):
+ flex_model_for_soc = [flex_model_for_soc]
soc_schedule = self._build_soc_schedule(
- flex_model, ems_schedule, soc_at_start, device_constraints, resolution
+ flex_model=flex_model_for_soc,
+ ems_schedule=ems_schedule,
+ soc_at_start=soc_at_start,
+ device_constraints=device_constraints,
+ stock_groups=self.stock_groups,
+ resolution=resolution,
)
consumption_production_schedule = self._build_consumption_production_schedules(
- flex_model, ems_schedule
+ flex_model_for_soc, ems_schedule
)
# Resample each device schedule to the resolution of the device's power sensor
@@ -1875,7 +2292,7 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType:
# Determine which sensors are consumption vs. production output sensors
consumption_output_sensors = {
flex_model_d["consumption"]["sensor"]
- for flex_model_d in flex_model
+ for flex_model_d in flex_model_for_soc
if isinstance(flex_model_d.get("consumption"), dict)
and "sensor" in flex_model_d["consumption"]
}
diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py
index 195409afc5..72c307b669 100644
--- a/flexmeasures/data/models/planning/tests/test_commitments.py
+++ b/flexmeasures/data/models/planning/tests/test_commitments.py
@@ -1,7 +1,8 @@
-import pytest
import pandas as pd
+import pytest
import numpy as np
+from flexmeasures.data.services.utils import get_or_create_model
from flexmeasures.data.models.planning import (
Commitment,
StockCommitment,
@@ -10,7 +11,11 @@
from flexmeasures.data.models.planning.utils import (
initialize_index,
)
+from flexmeasures.data.models.planning.storage import StorageScheduler
+from flexmeasures.data.models.time_series import Sensor
from flexmeasures.data.models.planning.linear_optimization import device_scheduler
+from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType
+from flexmeasures.data.utils import save_to_db
def test_multi_feed_device_scheduler_shared_buffer():
@@ -121,6 +126,7 @@ def test_multi_feed_device_scheduler_shared_buffer():
downwards_deviation_price=prices[device_commodity[d]],
device=pd.Series(d, index=index),
device_group=device_commodity,
+ commodity=device_commodity[d],
)
)
@@ -132,6 +138,8 @@ def test_multi_feed_device_scheduler_shared_buffer():
upwards_deviation_price=0,
downwards_deviation_price=-penalty,
device=pd.Series(d, index=index),
+ device_group=device_commodity,
+ commodity=device_commodity[d],
)
)
@@ -526,3 +534,1682 @@ def test_each_type_assigns_unique_group_per_slot():
device=pd.Series("dev", index=idx),
)
assert list(c.group) == list(range(len(idx)))
+
+
+def test_two_flexible_assets_with_commodity(app, db):
+ """
+ Test scheduling two flexible assets (battery + heat pump)
+ with explicit electricity commodity.
+ """
+ # ---- asset types
+ battery_type = get_or_create_model(GenericAssetType, name="battery")
+ hp_type = get_or_create_model(GenericAssetType, name="heat-pump")
+
+ # ---- time setup
+ start = pd.Timestamp("2024-01-01T00:00:00+01:00")
+ end = pd.Timestamp("2024-01-02T00:00:00+01:00")
+ resolution = pd.Timedelta("1h")
+
+ # ---- assets
+ battery = GenericAsset(
+ name="Battery",
+ generic_asset_type=battery_type,
+ attributes={"energy-capacity": "100 kWh"},
+ )
+ heat_pump = GenericAsset(
+ name="Heat Pump",
+ generic_asset_type=hp_type,
+ attributes={"energy-capacity": "50 kWh"},
+ )
+ db.session.add_all([battery, heat_pump])
+ db.session.commit()
+
+ # ---- sensors
+ battery_power = Sensor(
+ name="battery power",
+ unit="kW",
+ event_resolution=resolution,
+ generic_asset=battery,
+ )
+ hp_power = Sensor(
+ name="heat pump power",
+ unit="kW",
+ event_resolution=resolution,
+ generic_asset=heat_pump,
+ )
+ db.session.add_all([battery_power, hp_power])
+ db.session.commit()
+
+ # ---- flex-model (list = multi-asset)
+ flex_model = [
+ {
+ # Battery as storage
+ "sensor": battery_power.id,
+ "commodity": "electricity",
+ "soc-at-start": 20.0,
+ "soc-min": 0.0,
+ "soc-max": 100.0,
+ "soc-targets": [{"datetime": "2024-01-01T23:00:00+01:00", "value": 80.0}],
+ "power-capacity": "20 kW",
+ "charging-efficiency": 0.95,
+ "discharging-efficiency": 0.95,
+ },
+ {
+ # Heat pump modeled as storage
+ "sensor": hp_power.id,
+ "commodity": "electricity",
+ "soc-at-start": 10.0,
+ "soc-min": 0.0,
+ "soc-max": 50.0,
+ "soc-targets": [{"datetime": "2024-01-01T23:00:00+01:00", "value": 40.0}],
+ "power-capacity": "10 kW",
+ "production-capacity": "0 kW",
+ "charging-efficiency": 0.95,
+ },
+ ]
+
+ # ---- flex-context (single electricity market)
+ flex_context = {
+ "consumption-price": "100 EUR/MWh",
+ "production-price": "100 EUR/MWh",
+ }
+
+ # ---- run scheduler (use one asset as entry point)
+ scheduler = StorageScheduler(
+ asset_or_sensor=battery,
+ start=start,
+ end=end,
+ resolution=resolution,
+ belief_time=start,
+ flex_model=flex_model,
+ flex_context=flex_context,
+ return_multiple=True,
+ )
+
+ schedules = scheduler.compute(skip_validation=True)
+
+ assert isinstance(schedules, list)
+ assert len(schedules) == 3 # 2 storage schedules + 1 commitment costs
+
+ # Extract schedules by type
+ storage_schedules = [
+ entry for entry in schedules if entry.get("name") == "storage_schedule"
+ ]
+ commitment_costs = [
+ entry for entry in schedules if entry.get("name") == "commitment_costs"
+ ]
+
+ assert len(storage_schedules) == 2
+ assert len(commitment_costs) == 1
+
+ # Get battery schedule
+ battery_schedule = next(
+ entry for entry in storage_schedules if entry["sensor"] == battery_power
+ )
+ battery_data = battery_schedule["data"]
+
+ # Get heat pump schedule
+ hp_schedule = next(
+ entry for entry in storage_schedules if entry["sensor"] == hp_power
+ )
+ hp_data = hp_schedule["data"]
+
+ # Verify both devices charge to meet their targets
+ assert (battery_data > 0).any(), "Battery should charge at some point"
+ assert (hp_data > 0).any(), "Heat pump should charge at some point"
+
+ costs_data = commitment_costs[0]["data"]
+
+ # With net commodity-level results, energy costs are aggregated per commodity
+ # Battery: 60kWh Δ (20→80) / 0.95 eff × 100 EUR/MWh ≈ 6.32 EUR (charge) + discharge loss ≈ 4.32 EUR
+ # Heat pump: 30kWh Δ (10→40) / 0.95 eff × 100 EUR/MWh ≈ 3.16 EUR (no discharge, prod-cap=0)
+ # Total: 4.32 + 3.16 = 7.47 EUR
+ electricity_net_energy_cost = costs_data.get("electricity net energy", 0)
+ assert electricity_net_energy_cost == pytest.approx(7.47, rel=1e-2), (
+ f"Total electricity net energy cost (battery 4.32 + heat pump 3.16): "
+ f"= 7.47 EUR, got {electricity_net_energy_cost}"
+ )
+
+ # Battery prefers to charge as early as possible (3h @20kW, 1h@>0kW, then 0kW until the last slot with full discharge)
+ assert all(battery_data[:3] == 20)
+ assert battery_data[3] > 0
+ assert all(battery_data[4:-1] == 0)
+ assert battery_data[-1] == -20
+
+ # HP prefers to charge as early as possible (3h @10kW, 1h@>0kW, then 0kW)
+ assert all(hp_data[:3] == 10)
+ assert hp_data[3] > 0
+ assert all(hp_data[4:] == 0)
+
+ # ---- RELATIVE COSTS: Battery vs Heat Pump
+ # Battery moves 60 kWh, Heat Pump moves 30 kWh (2:1 ratio)
+ # Preference costs should reflect this energy ratio
+ battery_total_pref = costs_data.get("prefer a full storage 0 sooner", 0)
+ hp_total_pref = costs_data.get("prefer a full storage 1 sooner", 0)
+ assert battery_total_pref == pytest.approx(2 * hp_total_pref, rel=1e-2), (
+ f"Battery preference costs ({battery_total_pref:.2e}) should be twice the "
+ f"heat pump ({hp_total_pref:.2e}) preference costs, since battery moves more energy (60 kWh vs 30 kWh)"
+ )
+
+
+def test_mixed_gas_and_electricity_assets(app, db):
+ """
+ Test scheduling with mixed commodities: battery (electricity) and boiler (gas).
+ Verify cost calculations for both commodity types.
+ """
+
+ battery_type = get_or_create_model(GenericAssetType, name="battery")
+ boiler_type = get_or_create_model(GenericAssetType, name="gas-boiler")
+
+ start = pd.Timestamp("2024-01-01T00:00:00+01:00")
+ end = pd.Timestamp("2024-01-02T00:00:00+01:00")
+ resolution = pd.Timedelta("1h")
+
+ battery = GenericAsset(
+ name="Battery",
+ generic_asset_type=battery_type,
+ attributes={"energy-capacity": "100 kWh"},
+ )
+
+ gas_boiler = GenericAsset(
+ name="Gas Boiler",
+ generic_asset_type=boiler_type,
+ )
+
+ db.session.add_all([battery, gas_boiler])
+ db.session.commit()
+
+ battery_power = Sensor(
+ name="battery power",
+ unit="kW",
+ event_resolution=resolution,
+ generic_asset=battery,
+ )
+
+ boiler_power = Sensor(
+ name="boiler power",
+ unit="kW",
+ event_resolution=resolution,
+ generic_asset=gas_boiler,
+ )
+
+ db.session.add_all([battery_power, boiler_power])
+ db.session.commit()
+
+ flex_model = [
+ {
+ # Electricity battery
+ "sensor": battery_power.id,
+ "commodity": "electricity",
+ "soc-at-start": 20.0,
+ "soc-min": 0.0,
+ "soc-max": 100.0,
+ "soc-targets": [{"datetime": "2024-01-01T23:00:00+01:00", "value": 80.0}],
+ "power-capacity": "20 kW",
+ "charging-efficiency": 0.95,
+ "discharging-efficiency": 0.95,
+ },
+ {
+ # Gas-powered device (no storage behavior)
+ "sensor": boiler_power.id,
+ "commodity": "gas",
+ "power-capacity": "30 kW",
+ "consumption-capacity": "30 kW",
+ "production-capacity": "0 kW",
+ "soc-usage": ["1 kW"],
+ "soc-min": 0.0,
+ "soc-max": 0.0,
+ "soc-at-start": 0.0,
+ },
+ ]
+
+ flex_context = {
+ "commodities": [
+ {
+ "commodity": "electricity",
+ "consumption-price": "100 EUR/MWh", # electricity price
+ "production-price": "100 EUR/MWh",
+ },
+ {
+ "commodity": "gas",
+ "consumption-price": "50 EUR/MWh", # gas price
+ "production-price": "50 EUR/MWh",
+ },
+ ]
+ }
+
+ scheduler = StorageScheduler(
+ asset_or_sensor=battery,
+ start=start,
+ end=end,
+ resolution=resolution,
+ belief_time=start,
+ flex_model=flex_model,
+ flex_context=flex_context,
+ return_multiple=True,
+ )
+
+ schedules = scheduler.compute(skip_validation=True)
+
+ assert isinstance(schedules, list)
+ assert len(schedules) == 3 # 2 storage schedules + 1 commitment costs
+
+ # Extract schedules by type
+ storage_schedules = [
+ entry for entry in schedules if entry.get("name") == "storage_schedule"
+ ]
+ commitment_costs = [
+ entry for entry in schedules if entry.get("name") == "commitment_costs"
+ ]
+
+ assert len(storage_schedules) == 2
+ assert len(commitment_costs) == 1
+
+ # Get battery schedule
+ battery_schedule = next(
+ entry for entry in storage_schedules if entry["sensor"] == battery_power
+ )
+ battery_data = battery_schedule["data"]
+
+ early_charging_hours = battery_data.iloc[:3]
+ assert (early_charging_hours > 0).all(), "Battery should charge early"
+
+ assert battery_data.iloc[-1] < 0, "Battery should discharge at the end"
+
+ middle_hours = battery_data.iloc[4:-2]
+ assert (middle_hours == 0).all(), "Battery should be idle during middle hours"
+
+ boiler_schedule = next(
+ entry for entry in storage_schedules if entry["sensor"] == boiler_power
+ )
+ boiler_data = boiler_schedule["data"]
+
+ # ---- Verify both devices operate as expected
+ assert (battery_data > 0).any(), "Battery should charge at some point"
+ assert (boiler_data == 1.0).all(), "Boiler should have constant 1 kW consumption"
+
+ costs_data = commitment_costs[0]["data"]
+
+ # Battery: 60kWh Δ (20→80) / 0.95 eff × 100 EUR/MWh + discharge loss ≈ 4.32 EUR
+ # Boiler: constant 1kW × 24h = 24 kWh = 0.024 MWh × 50 EUR/MWh = 1.20 EUR (no efficiency loss)
+ # Total: 4.32 + 1.20 = 5.52 EUR
+ # With net commodity aggregation, we have separate "electricity net energy" and "gas net energy"
+ electricity_net_energy = costs_data.get("electricity net energy", 0)
+ gas_net_energy = costs_data.get("gas net energy", 0)
+
+ assert electricity_net_energy == pytest.approx(4.32, rel=1e-2), (
+ f"Electricity net energy cost (battery charging phase ~3h at 20kW with 95% efficiency "
+ f"+ discharge at end): 60kWh/0.95 × (100 EUR/MWh) = 4.32 EUR, "
+ f"got {electricity_net_energy}"
+ )
+
+ assert gas_net_energy == pytest.approx(1.20, rel=1e-2), (
+ f"Gas net energy cost (boiler constant 1kW for 24h): "
+ f"1 kW × 24h = 24 kWh = 0.024 MWh × 50 EUR/MWh = 1.20 EUR, "
+ f"got {gas_net_energy}"
+ )
+
+ # Total electricity + gas energy costs: battery (4.32) + boiler (1.20) = 5.52 EUR
+ total_energy_cost = electricity_net_energy + gas_net_energy
+ assert total_energy_cost == pytest.approx(5.52, rel=1e-2), (
+ f"Total energy cost (electricity 4.32 + gas 1.20): "
+ f"= 5.52 EUR, got {total_energy_cost}"
+ )
+
+ # Battery prefers to charge as early as possible (3h @20kW, 1h@>0kW, then 0kW until the last slot with full discharge)
+ assert all(battery_data[:3] == 20)
+ assert battery_data[3] > 0
+ assert all(battery_data[4:-1] == 0)
+ assert battery_data[-1] == -20
+
+ # ---- RELATIVE COSTS: Battery vs Boiler (different commodities)
+ # Battery has storage flexibility; Boiler is pass-through with constant load
+ # Battery preference costs should be higher than boiler's due to flexibility
+ battery_total_pref = costs_data.get("prefer a full storage 0 sooner", 0)
+ boiler_total_pref = costs_data.get("prefer a full storage 1 sooner", 0)
+
+ assert battery_total_pref > boiler_total_pref, (
+ f"Battery preference costs ({battery_total_pref:.2e}) should be greater than "
+ f"boiler ({boiler_total_pref:.2e}) preference costs, since battery has storage flexibility "
+ f"(can shift charging) while boiler has constant load (no flexibility). "
+ f"Ratio: {battery_total_pref / boiler_total_pref:.1f}× (if boiler > 0)"
+ )
+
+ # Verify boiler has zero preference cost since it has no flexibility (constant 1 kW)
+ assert boiler_total_pref == pytest.approx(0, abs=1e-8), (
+ f"Boiler preference cost should be ~0 since it has constant load with no flexibility, "
+ f"got {boiler_total_pref:.2e}"
+ )
+
+ # Verify battery has positive preference cost from optimizing early fill
+ assert battery_total_pref > 0, (
+ f"Battery preference cost should be positive since it can optimize charging timing, "
+ f"got {battery_total_pref:.2e}"
+ )
+
+
+def test_two_devices_shared_stock(app, db):
+ """
+ Two feeders charging a single storage.
+ Consider a single battery with two inverters feeding it, and a single state-of-charge sensor for the battery.
+ - Both inverters can charge the battery, but with different efficiencies.
+ - The battery has a single state of charge that both inverters affect.
+ - The scheduler should recognize the shared stock and optimize accordingly, without duplicating baselines or costs.
+ """
+ # ---- time
+ start = pd.Timestamp("2024-01-01T00:00:00+01:00")
+ end = pd.Timestamp("2024-01-02T00:00:00+01:00")
+ power_sensor_resolution = pd.Timedelta("15m")
+ soc_sensor_resolution = pd.Timedelta(0)
+
+ # ---- assets
+ battery_type = get_or_create_model(GenericAssetType, name="battery")
+ inverter_type = get_or_create_model(GenericAssetType, name="inverter")
+
+ battery = GenericAsset(name="battery", generic_asset_type=battery_type)
+ inverter_1 = GenericAsset(name="inverter 1", generic_asset_type=inverter_type)
+ inverter_2 = GenericAsset(name="inverter 2", generic_asset_type=inverter_type)
+
+ db.session.add_all([battery, inverter_1, inverter_2])
+ db.session.commit()
+
+ power_1 = Sensor(
+ name="power",
+ unit="kW",
+ event_resolution=power_sensor_resolution,
+ generic_asset=inverter_1,
+ )
+ power_2 = Sensor(
+ name="power",
+ unit="kW",
+ event_resolution=power_sensor_resolution,
+ generic_asset=inverter_2,
+ )
+ power_3 = Sensor(
+ name="power",
+ unit="kW",
+ event_resolution=power_sensor_resolution,
+ generic_asset=battery,
+ )
+
+ state_of_charge = Sensor(
+ name="state-of-charge",
+ unit="kWh",
+ event_resolution=soc_sensor_resolution,
+ generic_asset=battery,
+ )
+
+ db.session.add_all([power_1, power_2, power_3, state_of_charge])
+ db.session.commit()
+
+ # ---- shared stock (both batteries charge from same pool)
+ flex_model = [
+ {
+ "sensor": power_1.id,
+ "state-of-charge": {"sensor": state_of_charge.id},
+ "power-capacity": "20 kW",
+ "charging-efficiency": 0.95,
+ "discharging-efficiency": 0.95,
+ },
+ {
+ "sensor": power_2.id,
+ "state-of-charge": {"sensor": state_of_charge.id},
+ "power-capacity": "20 kW",
+ "charging-efficiency": 0.99,
+ "discharging-efficiency": 0.45,
+ },
+ {
+ "state-of-charge": {"sensor": state_of_charge.id},
+ "soc-at-start": 20.0,
+ "soc-min": 10,
+ "soc-max": 200.0,
+ "soc-targets": [{"datetime": "2024-01-01T12:00:00+01:00", "value": 189.0}],
+ },
+ ]
+
+ flex_context = {
+ "consumption-price": "100 EUR/MWh",
+ "production-price": "100 EUR/MWh",
+ }
+
+ scheduler = StorageScheduler(
+ asset_or_sensor=battery,
+ start=start,
+ end=end,
+ resolution=power_sensor_resolution,
+ belief_time=start,
+ flex_model=flex_model,
+ flex_context=flex_context,
+ return_multiple=True,
+ )
+
+ schedules = scheduler.compute(skip_validation=True)
+
+ # ---- verify scheduler returned expected outputs
+ assert isinstance(schedules, list), (
+ "Scheduler should return a list of result objects "
+ "(device schedules, commitment costs, SOC)."
+ )
+
+ assert len(schedules) == 4, (
+ "Expected 4 outputs: two inverter schedules, one commitment_costs "
+ "object, and one state_of_charge schedule."
+ )
+
+ # ---- extract schedules
+ storage_schedules = [s for s in schedules if s["name"] == "storage_schedule"]
+ commitment_costs = [s for s in schedules if s["name"] == "commitment_costs"]
+ soc_schedule = next(s for s in schedules if s["name"] == "state_of_charge")
+
+ assert len(storage_schedules) == 2, (
+ "There should be two storage schedules corresponding to the two "
+ "inverters feeding the shared battery."
+ )
+
+ assert (
+ len(commitment_costs) == 1
+ ), "Commitment costs should be aggregated into a single result."
+
+ power1_schedule = next(s for s in storage_schedules if s["sensor"] == power_1)
+ power2_schedule = next(s for s in storage_schedules if s["sensor"] == power_2)
+
+ power1_data = power1_schedule["data"]
+ power2_data = power2_schedule["data"]
+ soc_data = soc_schedule["data"]
+ costs_data = commitment_costs[0]["data"]
+
+ # ---- charging behaviour
+ assert (power2_data > 0).any(), (
+ "The more efficient inverter should charge the battery at least "
+ "during some periods, showing that the optimizer prefers it."
+ )
+
+ assert (power1_data == 0).sum() > len(power1_data) * 0.5, (
+ "The less efficient inverter should remain idle for most of the "
+ "charging window, confirming that efficiency differences influence "
+ "device selection."
+ )
+
+ # ---- discharge behaviour
+ # Both inverters have zero power in the middle of the horizon
+ # Charging happens through inverter 2 (more efficient) as soon as possible (full SoC is preferred)
+ # Discharging happens through inverter 1 (more efficient) as late as possible (full SoC is preferred)
+ assert (
+ power1_data.iloc[0 : int(96 / 2 + 13)] == 0
+ ).all(), "Inverter 1 should be idle at the beginning of the scheduling period."
+
+ assert (
+ power2_data.iloc[int(96 / 2 - 13) : -1] == 0
+ ).all(), "Inverter 2 should be idle at the end of the scheduling period."
+
+ # Verify that inverter 1 actually discharges
+ assert (power1_data < 0).any(), "Inverter 1 should discharge the battery."
+ # Verify that inverter 1 never charges
+ assert not (power1_data > 0).any(), "Inverter 1 should not charge the battery."
+
+ # Verify that inverter 2 actually charges
+ assert (power2_data > 0).any(), "Inverter 2 should charge the battery."
+ # Verify that inverter 1 never charges
+ assert not (power2_data < 0).any(), "Inverter 2 should not discharge the battery."
+
+ # ---- SOC behaviour
+ assert soc_data.iloc[0] == pytest.approx(
+ 20.0
+ ), "Initial state of charge must match the provided soc-at-start value."
+
+ assert soc_data.max() == pytest.approx(189.0, rel=1e-3), (
+ "SOC should rise to exactly 189.0 kWh (the target value), "
+ "confirming that both inverters contribute to the same shared stock."
+ )
+
+ assert soc_data.iloc[-1] == pytest.approx(
+ 10.0, rel=1e-3
+ ), "SOC should decrease to soc-min (10.0) after the target is reached."
+
+ assert (
+ soc_data.max() > soc_data.iloc[0]
+ ), "SOC must increase during the charging phase."
+
+ # ---- energy cost checks
+ electricity_net_energy_cost = costs_data.get("electricity net energy", 0)
+ assert electricity_net_energy_cost == pytest.approx(0.0657, rel=1e-2), (
+ "Inverter 1 (discharge efficiency 0.95) discharges ~340 kWh (20 kW for ~40 periods) "
+ "from 189 kWh down to 10 kWh (soc-min), incurring discharge losses. "
+ "Inverter 2 (charge efficiency 0.99) charges continuously at 20 kW from start until "
+ "reaching the soc-target of 189 kWh at 07:30, incurring minimal charge losses. "
+ "Net electricity cost of ~0.0657 EUR at 100 EUR/MWh reflects the efficiency difference "
+ "between the two inverters specializing in their respective operations."
+ )
+
+
+def set_up_simulation_assets_and_sensors(app, db):
+ # ---- asset types and assets
+ gas_boiler_type = get_or_create_model(GenericAssetType, name="gas-boiler")
+ buffer_type = get_or_create_model(GenericAssetType, name="heat-buffer")
+ site_type = get_or_create_model(GenericAssetType, name="site")
+
+ site = GenericAsset(
+ name="Test Site",
+ generic_asset_type=site_type,
+ )
+ building = GenericAsset(
+ name="Building", generic_asset_type=site_type, parent_asset_id=site.id
+ )
+
+ gas_boiler = GenericAsset(
+ name="Gas Boiler", generic_asset_type=gas_boiler_type, parent_asset_id=site.id
+ )
+ heat_buffer = GenericAsset(
+ name="Heat Buffer", generic_asset_type=buffer_type, parent_asset_id=site.id
+ )
+ electric_heater = GenericAsset(
+ name="Electric Heater",
+ generic_asset_type=get_or_create_model(
+ GenericAssetType, name="electric-heater"
+ ),
+ parent_asset_id=site.id,
+ )
+
+ db.session.add_all([gas_boiler, heat_buffer, building, electric_heater, site])
+ db.session.commit()
+
+ power_resolution = pd.Timedelta("15m")
+ energy_resolution = pd.Timedelta(0)
+
+ building_raw_power = Sensor(
+ name="building raw power",
+ unit="kW",
+ event_resolution=power_resolution,
+ generic_asset=building,
+ )
+
+ boiler_power = Sensor(
+ name="boiler power",
+ unit="kW",
+ event_resolution=power_resolution,
+ generic_asset=gas_boiler,
+ )
+
+ tank_power = Sensor(
+ name="heat buffer power",
+ unit="kW",
+ event_resolution=power_resolution,
+ generic_asset=heat_buffer,
+ )
+
+ buffer_soc = Sensor(
+ name="buffer state of charge",
+ unit="kWh",
+ event_resolution=energy_resolution, # instantaneous
+ generic_asset=heat_buffer,
+ )
+
+ buffer_soc_usage = Sensor(
+ name="buffer soc usage",
+ unit="kW",
+ event_resolution=power_resolution,
+ generic_asset=heat_buffer,
+ )
+
+ heater_power = Sensor(
+ name="heater power",
+ unit="kW",
+ event_resolution=power_resolution,
+ generic_asset=electric_heater,
+ )
+ soc_targets = Sensor(
+ name="buffer soc targets",
+ unit="kWh",
+ event_resolution=energy_resolution, # instantaneous
+ generic_asset=heat_buffer,
+ )
+ consumption_price = Sensor(
+ name="consumption price",
+ unit="EUR/MWh",
+ event_resolution=energy_resolution,
+ generic_asset=site,
+ )
+ production_price = Sensor(
+ name="production price",
+ unit="EUR/MWh",
+ event_resolution=energy_resolution,
+ generic_asset=site,
+ )
+ gas_price = Sensor(
+ name="gas price",
+ unit="EUR/MWh",
+ event_resolution=energy_resolution,
+ generic_asset=site,
+ )
+ dynamic_consumption_capacity = Sensor(
+ name="dynamic consumption capacity",
+ unit="kW",
+ event_resolution=power_resolution,
+ generic_asset=site,
+ )
+
+ db.session.add_all(
+ [
+ boiler_power,
+ buffer_soc,
+ tank_power,
+ buffer_soc_usage,
+ building_raw_power,
+ heater_power,
+ soc_targets,
+ consumption_price,
+ production_price,
+ gas_price,
+ dynamic_consumption_capacity,
+ ]
+ )
+ db.session.commit()
+ return {
+ "site": site,
+ "building": building,
+ "gas_boiler": gas_boiler,
+ "heat_buffer": heat_buffer,
+ "electric_heater": electric_heater,
+ "building_raw_power": building_raw_power,
+ "boiler_power": boiler_power,
+ "tank_power": tank_power,
+ "buffer_soc": buffer_soc,
+ "buffer_soc_usage": buffer_soc_usage,
+ "heater_power": heater_power,
+ "soc_targets": soc_targets,
+ "power_resolution": power_resolution,
+ "energy_resolution": energy_resolution,
+ "consumption_price": consumption_price,
+ "production_price": production_price,
+ "gas_price": gas_price,
+ "dynamic_consumption_capacity": dynamic_consumption_capacity,
+ }
+
+
+def test_simulation_with_dynamic_consumption_capacity(app, db):
+
+ start = pd.Timestamp("2026-04-07T00:00:00+01:00")
+ end = pd.Timestamp(
+ "2026-04-09T06:00:00+01:00"
+ ) # Extended to allow discharge target on April 8
+ belief_time = pd.Timestamp(
+ "2026-04-05T00:00:00+01:00"
+ ) # 2 days before start for generous planning horizon
+
+ setup_data = set_up_simulation_assets_and_sensors(app, db)
+
+ site = setup_data["site"]
+ building_raw_power = setup_data["building_raw_power"]
+ heater_power = setup_data["heater_power"]
+ boiler_power = setup_data["boiler_power"]
+ buffer_soc = setup_data["buffer_soc"]
+ buffer_soc_usage = setup_data["buffer_soc_usage"]
+ consumption_price = setup_data["consumption_price"]
+ gas_price = setup_data["gas_price"]
+ dynamic_consumption_capacity = setup_data["dynamic_consumption_capacity"]
+
+ import timely_beliefs as tb
+ from flexmeasures import Source
+
+ # add dummy data to building raw power to ensure site-level constraints are respected
+ building_data = pd.Series(
+ 100.0,
+ index=pd.date_range(
+ start, end, freq=setup_data["power_resolution"], name="event_start"
+ ),
+ name="event_value",
+ ).reset_index()
+
+ soc_usage = building_data.copy()
+
+ bdf = tb.BeliefsDataFrame(
+ building_data,
+ belief_horizon=-pd.Timedelta(seconds=1) * np.array(range(len(building_data))),
+ sensor=setup_data["building_raw_power"],
+ source=get_or_create_model(Source, name="Simulation"),
+ )
+ save_to_db(bdf, bulk_save_objects=False, save_changed_beliefs_only=False)
+
+ # Dynamic site consumption capacity:
+ # - 1200 * 0.6 = 720 kW from 12:00 to 18:00
+ # - 1200 kW for the rest of the day
+ dynamic_capacity_data = pd.DataFrame(
+ index=pd.date_range(
+ start, end, freq=setup_data["power_resolution"], name="event_start"
+ )
+ ).reset_index()
+
+ # Dynamic electricity and gas prices:
+ # - Electricity is cheaper than gas from 12:00 to 16:00
+ # - Gas is cheaper for the rest of the day
+ price_index = pd.date_range(
+ start,
+ end,
+ freq=setup_data["power_resolution"],
+ name="event_start",
+ )
+
+ electricity_price_data = pd.DataFrame(index=price_index).reset_index()
+ gas_price_data = pd.DataFrame(index=price_index).reset_index()
+
+ # Default prices: gas cheaper than electricity
+ electricity_price_data["event_value"] = 120.0
+ gas_price_data["event_value"] = 90.0
+
+ # From 12:00 until before 16:00, electricity cheaper than gas
+ cheap_electricity_mask = electricity_price_data["event_start"].dt.hour.between(
+ 12, 15
+ )
+
+ electricity_price_data.loc[
+ cheap_electricity_mask,
+ "event_value",
+ ] = 50.0
+
+ gas_price_data.loc[
+ cheap_electricity_mask,
+ "event_value",
+ ] = 150.0
+
+ bdf = tb.BeliefsDataFrame(
+ electricity_price_data,
+ belief_time=belief_time,
+ sensor=setup_data["consumption_price"],
+ source=get_or_create_model(Source, name="Simulation"),
+ )
+ save_to_db(bdf, bulk_save_objects=False, save_changed_beliefs_only=False)
+
+ bdf = tb.BeliefsDataFrame(
+ gas_price_data,
+ belief_time=belief_time,
+ sensor=setup_data["gas_price"],
+ source=get_or_create_model(Source, name="Simulation"),
+ )
+ save_to_db(bdf, bulk_save_objects=False, save_changed_beliefs_only=False)
+
+ dynamic_capacity_data["event_value"] = 100.0
+
+ dynamic_capacity_data.loc[
+ dynamic_capacity_data["event_start"].dt.hour.between(12, 17),
+ "event_value",
+ ] = (
+ 100.0 * 0.6
+ )
+
+ bdf = tb.BeliefsDataFrame(
+ dynamic_capacity_data,
+ belief_time=belief_time,
+ sensor=setup_data["dynamic_consumption_capacity"],
+ source=get_or_create_model(Source, name="Simulation"),
+ )
+
+ save_to_db(bdf, bulk_save_objects=False, save_changed_beliefs_only=False)
+
+ soc_usage["event_value"] = 100
+ bdf = tb.BeliefsDataFrame(
+ soc_usage,
+ belief_time=belief_time,
+ sensor=setup_data["buffer_soc_usage"],
+ source=get_or_create_model(Source, name="Simulation"),
+ )
+
+ save_to_db(bdf, bulk_save_objects=False, save_changed_beliefs_only=False)
+
+ flex_model = [
+ {
+ "sensor": heater_power.id,
+ "state-of-charge": {"sensor": buffer_soc.id},
+ "power-capacity": "100 kW",
+ "charging-efficiency": 0.9,
+ "commodity": "electricity",
+ "production-capacity": "0 kW",
+ # "storage-efficiency": 0.9, # todo: workaround does not work yet
+ },
+ {
+ "sensor": boiler_power.id,
+ "state-of-charge": {"sensor": buffer_soc.id},
+ "power-capacity": "100 kW",
+ "charging-efficiency": 0.9,
+ "commodity": "gas",
+ "production-capacity": "0 kW",
+ # "storage-efficiency": 0.9, # todo: workaround does not work yet
+ },
+ {
+ # "sensor": tank_power.id,
+ "soc-min": 200.0,
+ "soc-max": 1000.0,
+ "soc-at-start": 200.0,
+ # "soc-targets": [
+ # {"datetime": "2026-04-07T20:00:00+01:00", "value": 700.0},
+ # ],
+ "state-of-charge": {"sensor": buffer_soc.id},
+ "soc-usage": [{"sensor": buffer_soc_usage.id}],
+ "storage-efficiency": 0.9, # todo: does not work yet
+ # todo: consider assigning this to the heat commodity, maybe we can derive some useful (costs?) KPI from it
+ },
+ ]
+
+ flex_context = {
+ "commodities": [
+ {
+ "commodity": "electricity",
+ "consumption-price": {
+ "sensor": consumption_price.id,
+ },
+ "production-price": {
+ "sensor": consumption_price.id,
+ },
+ "site-power-capacity": "1900 kW",
+ "site-consumption-capacity": {
+ "sensor": dynamic_consumption_capacity.id,
+ },
+ "site-production-capacity": "100 kW",
+ "site-consumption-breach-price": "100000 EUR/kW",
+ "site-production-breach-price": "100000 EUR/kW",
+ "inflexible-device-sensors": [building_raw_power.id],
+ },
+ {
+ "commodity": "gas",
+ "consumption-price": {
+ "sensor": gas_price.id,
+ },
+ "production-price": {
+ "sensor": gas_price.id,
+ },
+ # No electricity dynamic capacity here.
+ "site-consumption-capacity": "100000 kW",
+ "inflexible-device-sensors": [building_raw_power.id],
+ },
+ ],
+ "relax-constraints": True,
+ }
+
+ scheduler = StorageScheduler(
+ asset_or_sensor=site,
+ start=start,
+ end=end,
+ resolution=setup_data["power_resolution"],
+ belief_time=belief_time,
+ flex_model=flex_model,
+ flex_context=flex_context,
+ return_multiple=True,
+ )
+
+ schedules = scheduler.compute(skip_validation=True)
+
+ heater_schedule = next(
+ schedule["data"]
+ for schedule in schedules
+ if schedule.get("sensor") == heater_power
+ )
+
+ boiler_schedule = next(
+ schedule["data"]
+ for schedule in schedules
+ if schedule.get("sensor") == boiler_power
+ )
+ # The electric heater should only be active in the cheap-electricity window.
+ # In local time, electricity is cheaper from 12:00 to 16:00.
+ # During this period, the dynamic electricity site capacity is only 60 kW.
+ # Therefore, the electric heater is expected to run at 60 kW, not its full
+ # 100 kW device capacity.
+ pd.testing.assert_series_equal(
+ heater_schedule.loc["2026-04-07T11:00:00+00:00":"2026-04-07T14:45:00+00:00"],
+ pd.Series(
+ 60.0,
+ index=pd.date_range(
+ "2026-04-07T11:00:00+00:00",
+ "2026-04-07T14:45:00+00:00",
+ freq="15min",
+ ),
+ dtype="float64",
+ ),
+ check_names=False,
+ obj=(
+ "electric heater dispatch during cheap-electricity window on day 1; "
+ "expected 60 kW because dynamic electricity capacity limits the heater"
+ ),
+ )
+
+ # When electricity is cheaper than gas, the gas boiler should stay off.
+ # The heat demand is then supplied by the electric heater instead.
+ pd.testing.assert_series_equal(
+ boiler_schedule.loc["2026-04-07T11:00:00+00:00":"2026-04-07T14:45:00+00:00"],
+ pd.Series(
+ 0.0,
+ index=pd.date_range(
+ "2026-04-07T11:00:00+00:00",
+ "2026-04-07T14:45:00+00:00",
+ freq="15min",
+ ),
+ dtype="float64",
+ ),
+ check_names=False,
+ obj=(
+ "gas boiler dispatch during cheap-electricity window on day 1; "
+ "expected 0 kW because electricity is cheaper than gas"
+ ),
+ )
+
+ pd.testing.assert_series_equal(
+ heater_schedule.loc["2026-04-08T11:00:00+00:00":"2026-04-08T14:45:00+00:00"],
+ pd.Series(
+ 60.0,
+ index=pd.date_range(
+ "2026-04-08T11:00:00+00:00",
+ "2026-04-08T14:45:00+00:00",
+ freq="15min",
+ ),
+ dtype="float64",
+ ),
+ check_names=False,
+ obj=(
+ "electric heater dispatch during cheap-electricity window on day 2; "
+ "expected 60 kW because dynamic electricity capacity limits the heater"
+ ),
+ )
+
+ pd.testing.assert_series_equal(
+ boiler_schedule.loc["2026-04-08T11:00:00+00:00":"2026-04-08T14:45:00+00:00"],
+ pd.Series(
+ 0.0,
+ index=pd.date_range(
+ "2026-04-08T11:00:00+00:00",
+ "2026-04-08T14:45:00+00:00",
+ freq="15min",
+ ),
+ dtype="float64",
+ ),
+ check_names=False,
+ obj=(
+ "gas boiler dispatch during cheap-electricity window on day 2; "
+ "expected 0 kW because electricity is cheaper than gas"
+ ),
+ )
+
+ # Outside the cheap-electricity window, gas is cheaper than electricity.
+ # Therefore, the gas boiler should become the preferred heat source and run
+ # at full 100 kW capacity, while the electric heater should remain off.
+ assert boiler_schedule.loc["2026-04-07T15:00:00+00:00"] == pytest.approx(
+ 100.0
+ ), "Gas boiler should run at full capacity after the cheap-electricity window on day 1."
+
+ assert heater_schedule.loc["2026-04-07T15:00:00+00:00"] == pytest.approx(
+ 0.0
+ ), "Electric heater should be off after the cheap-electricity window because gas is cheaper."
+
+ assert boiler_schedule.loc["2026-04-08T15:00:00+00:00"] == pytest.approx(
+ 100.0
+ ), "Gas boiler should run at full capacity after the cheap-electricity window on day 2."
+
+ assert heater_schedule.loc["2026-04-08T15:00:00+00:00"] == pytest.approx(
+ 0.0
+ ), "Electric heater should be off after the cheap-electricity window on day 2 because gas is cheaper."
+
+ # Before the first cheap-electricity window, the optimizer uses a partial
+ # 80 kW electric-heater step to prepare the heat buffer. This is part of the
+ # expected optimal schedule and protects against accidental dispatch changes.
+ assert heater_schedule.loc["2026-04-07T08:00:00+00:00"] == pytest.approx(
+ 80.0
+ ), "Electric heater should have one expected partial 80 kW dispatch step before the first cheap-electricity window."
+
+
+def test_chp_coupling():
+ """Test that coupling_groups enforces fixed flow ratios between CHP devices.
+
+ Models a Combined Heat and Power unit with three pure flow devices:
+
+ - d=0 gas input: can only consume gas (derivative_min=0)
+ - d=1 heat output: can only produce heat (derivative_max=0)
+ - d=2 power output: can only produce electricity (derivative_max=0)
+
+ The coupling group ``"chp"`` is specified with coefficients
+ ``[(0, 1.0), (1, -0.5), (2, -0.3)]``, introducing a decision variable ``alpha``
+ and enforcing ``P[d] == coeff * alpha`` for each device:
+
+ P_gas = 1.0 * alpha (input, coeff = 1.0)
+ P_heat = -0.5 * alpha (output, coeff = -0.5, heat efficiency 50%)
+ P_power = -0.3 * alpha (output, coeff = -0.3, power efficiency 30%)
+
+ Heat production is forced to exactly 10 kW via ``derivative equals = -10``
+ on device 1. Substituting ``P_heat = -10`` gives ``alpha = 20``, so:
+
+ P_gas = 20 kW (gas consumed)
+ P_heat = -10 kW (heat produced, forced)
+ P_power = 20 kW * -0.3
+ ≈ -6 kW (electricity produced)
+
+ """
+ start = pd.Timestamp("2026-01-01T00:00+01:00")
+ end = pd.Timestamp("2026-01-01T04:00+01:00")
+ resolution = pd.Timedelta("1h")
+ index = initialize_index(start=start, end=end, resolution=resolution)
+
+ # d=0: gas input — can only consume (derivative_min=0), capacity 100 kW.
+ # NaN stock bounds mean no cumulative-stock constraint (pure flow device).
+ gas_constraints = pd.DataFrame(
+ {
+ "min": np.nan,
+ "max": np.nan,
+ "equals": np.nan,
+ "derivative min": 0.0,
+ "derivative max": 100.0,
+ "derivative equals": np.nan,
+ "derivative down efficiency": 1.0,
+ "derivative up efficiency": 1.0,
+ },
+ index=index,
+ )
+
+ # d=1: heat output — can only produce (derivative_max=0).
+ # Forced to exactly -10 kW via derivative equals.
+ heat_constraints = pd.DataFrame(
+ {
+ "min": np.nan,
+ "max": np.nan,
+ "equals": np.nan,
+ "derivative min": -100.0,
+ "derivative max": 0.0,
+ "derivative equals": -10.0,
+ "derivative down efficiency": 1.0,
+ "derivative up efficiency": 1.0,
+ },
+ index=index,
+ )
+
+ # d=2: power output — can only produce (derivative_max=0), capacity 100 kW.
+ # Flow is free; the coupling constraint will determine its value.
+ power_constraints = pd.DataFrame(
+ {
+ "min": np.nan,
+ "max": np.nan,
+ "equals": np.nan,
+ "derivative min": -100.0,
+ "derivative max": 0.0,
+ "derivative equals": np.nan,
+ "derivative down efficiency": 1.0,
+ "derivative up efficiency": 1.0,
+ },
+ index=index,
+ )
+
+ ems_constraints = pd.DataFrame(
+ {"derivative min": -200.0, "derivative max": 200.0},
+ index=index,
+ )
+
+ # Coupling group: one reference device (gas, coeff 1.0) and two coupled
+ # devices (heat with coeff -0.5, power with coeff -0.3).
+ coupling_groups = {"chp": [(0, 1.0), (1, -0.5), (2, -0.3)]}
+
+ # Gas-price commitment gives the objective a finite value and models the
+ # cost of consuming gas. With quantity=0 and both prices set the
+ # commitment acts as a two-sided soft equality: any upward deviation
+ # (gas consumption) incurs a cost of 1 EUR/kW.
+ gas_price_commitment = FlowCommitment(
+ name="gas cost",
+ index=index,
+ quantity=pd.Series(0.0, index=index),
+ upwards_deviation_price=pd.Series(1.0, index=index),
+ downwards_deviation_price=pd.Series(0.0, index=index),
+ device=pd.Series(0, index=index),
+ )
+
+ schedules, planned_costs, results, model = device_scheduler(
+ device_constraints=[gas_constraints, heat_constraints, power_constraints],
+ ems_constraints=ems_constraints,
+ commitments=[gas_price_commitment],
+ coupling_groups=coupling_groups,
+ )
+
+ assert (
+ results.solver.termination_condition == "optimal"
+ ), "Solver did not find an optimal solution."
+
+ # Heat is fixed to -10 kW by derivative_equals.
+ pd.testing.assert_series_equal(
+ schedules[1],
+ pd.Series(-10.0, index=index),
+ check_names=False,
+ rtol=1e-4,
+ obj="heat output forced to -10 kW by derivative_equals",
+ )
+
+ # Coupling: P_gas / 1.0 == P_heat / -0.5 → P_gas = -10 / -0.5 = 20 kW
+ pd.testing.assert_series_equal(
+ schedules[0],
+ pd.Series(20.0, index=index),
+ check_names=False,
+ rtol=1e-4,
+ obj="gas consumption determined by coupling (20 kW from 10 kW heat at coeff -0.5)",
+ )
+
+ # Coupling: P_gas / 1.0 == P_power / -0.3 → P_power = 20 / -0.3 = -6 kW
+ pd.testing.assert_series_equal(
+ schedules[2],
+ pd.Series(-6.0, index=index),
+ check_names=False,
+ rtol=1e-4,
+ obj="power output determined by coupling (-0.3 * alpha = -0.3 * 20 = -6 kW)",
+ )
+
+
+def test_dual_fuel_chp_coupling():
+ """Test coupling_groups with two input devices (dual-fuel CHP).
+
+ Models a CHP unit that consumes equal parts natural gas and hydrogen,
+ producing heat and electricity:
+
+ - d=0 gas input: can only consume gas (derivative_min=0)
+ - d=1 hydrogen input: can only consume hydrogen (derivative_min=0)
+ - d=2 heat output: can only produce heat (derivative_max=0)
+ - d=3 power output: can only produce electricity (derivative_max=0)
+
+ Coupling group ``"chp"`` with coefficients
+ ``[(0, 0.5), (1, 0.5), (2, -0.5), (3, -0.3)]`` introduces a free variable
+ ``alpha`` and enforces ``P[d] == coeff * alpha``:
+
+ P_gas = 0.5 * alpha (50% of total fuel from gas)
+ P_hydrogen = 0.5 * alpha (50% of total fuel from hydrogen)
+ P_heat = -0.5 * alpha (heat efficiency 50% of total fuel)
+ P_power = -0.3 * alpha (power efficiency 30% of total fuel)
+
+ Because gas and hydrogen share the same coefficient the two fuel flows are
+ always equal, confirming that device order does not affect the result.
+
+ Heat production is forced to exactly 10 kW via ``derivative equals = -10`` on device 2.
+ Substituting ``P_heat = -10`` gives ``alpha = 20``, so:
+
+ P_gas = 10 kW (equal gas input)
+ P_hydrogen = 10 kW (equal hydrogen input)
+ P_heat = -10 kW (heat produced, forced)
+ P_power = -6 kW (electricity produced)
+ """
+ start = pd.Timestamp("2026-01-01T00:00+01:00")
+ end = pd.Timestamp("2026-01-01T04:00+01:00")
+ resolution = pd.Timedelta("1h")
+ index = initialize_index(start=start, end=end, resolution=resolution)
+
+ def _flow_df(**kwargs) -> pd.DataFrame:
+ defaults = {
+ "min": np.nan,
+ "max": np.nan,
+ "equals": np.nan,
+ "derivative min": 0.0,
+ "derivative max": 0.0,
+ "derivative equals": np.nan,
+ "derivative down efficiency": 1.0,
+ "derivative up efficiency": 1.0,
+ }
+ defaults.update(kwargs)
+ return pd.DataFrame(defaults, index=index)
+
+ # d=0: gas input — can only consume, capacity 100 kW
+ gas_constraints = _flow_df(**{"derivative max": 100.0})
+ # d=1: hydrogen input — can only consume, capacity 100 kW
+ hydrogen_constraints = _flow_df(**{"derivative max": 100.0})
+ # d=2: heat output — can only produce, forced to -10 kW
+ heat_constraints = _flow_df(
+ **{"derivative min": -100.0, "derivative equals": -10.0}
+ )
+ # d=3: power output — can only produce, free (coupling determines value)
+ power_constraints = _flow_df(**{"derivative min": -100.0})
+
+ ems_constraints = pd.DataFrame(
+ {"derivative min": -200.0, "derivative max": 200.0},
+ index=index,
+ )
+
+ # Both fuel inputs share coefficient 0.5, so they receive identical flows.
+ # Outputs have negative coefficients equal to their efficiency fractions.
+ coupling_groups = {"chp": [(0, 0.5), (1, 0.5), (2, -0.5), (3, -0.3)]}
+
+ # Gas-price commitment for device 0 just to give the objective a finite value
+ # Even though hydrogen is free, it will still be used because its consumption is coupled to gas.
+ fuel_cost_commitment = FlowCommitment(
+ name="fuel cost",
+ index=index,
+ quantity=pd.Series(0.0, index=index),
+ upwards_deviation_price=pd.Series(1.0, index=index),
+ downwards_deviation_price=pd.Series(0.0, index=index),
+ device=pd.Series(0, index=index),
+ )
+
+ schedules, _costs, results, _model = device_scheduler(
+ device_constraints=[
+ gas_constraints,
+ hydrogen_constraints,
+ heat_constraints,
+ power_constraints,
+ ],
+ ems_constraints=ems_constraints,
+ commitments=[fuel_cost_commitment],
+ coupling_groups=coupling_groups,
+ )
+
+ assert (
+ results.solver.termination_condition == "optimal"
+ ), "Solver did not find an optimal solution."
+
+ # Heat is fixed to -10 kW; alpha = -10 / -0.5 = 20.
+ pd.testing.assert_series_equal(
+ schedules[2],
+ pd.Series(-10.0, index=index),
+ check_names=False,
+ rtol=1e-4,
+ obj="heat output forced to -10 kW by derivative_equals",
+ )
+
+ # Coupling: P_gas = 0.5 * alpha = 0.5 * 20 = 10 kW
+ pd.testing.assert_series_equal(
+ schedules[0],
+ pd.Series(10.0, index=index),
+ check_names=False,
+ rtol=1e-4,
+ obj="gas input = 0.5 * alpha = 10 kW",
+ )
+
+ # Coupling: P_hydrogen = 0.5 * alpha = 10 kW (equal to gas)
+ pd.testing.assert_series_equal(
+ schedules[1],
+ pd.Series(10.0, index=index),
+ check_names=False,
+ rtol=1e-4,
+ obj="hydrogen input = 0.5 * alpha = 10 kW (equal to gas input)",
+ )
+
+ # Coupling: P_power = -0.3 * alpha = -0.3 * 20 = -6 kW
+ pd.testing.assert_series_equal(
+ schedules[3],
+ pd.Series(-6.0, index=index),
+ check_names=False,
+ rtol=1e-4,
+ obj="power output = -0.3 * alpha = -6 kW",
+ )
+
+
+def _run_factory_scenario(
+ gas_price: float,
+ elec_price: float,
+) -> tuple:
+ """Run the simplified factory scenario and return the 7 device schedules.
+
+ Devices
+ ~~~~~~~
+ d=0 e-heater electricity → heat coupling (ems_power ≥ 0, i.e. consumes electricity)
+ d=1 gas boiler gas → heat coupling (ems_power ≥ 0, i.e. consumes gas)
+ d=2 steamer heat coupling → steam (ems_power ≤ 0, i.e. produces steam)
+ d=3 CHP gas input gas → chp coupling (ems_power ≥ 0, i.e. consumes gas, coupling member = alpha)
+ d=4 CHP heat out chp coupling → steam (ems_power ≤ 0, i.e. produces steam, coupling member = -0.5 alpha)
+ d=5 CHP power out chp coupling → electricity (ems_power ≤ 0, i.e. produces electricity, coupling member = -0.3 alpha)
+ d=6 steam demand steam → fixed flow (ems_power = 15, i.e. consumes steam)
+
+ CHP coupling coefficients
+ ~~~~~~~~~~~~~~~~~~~~~~~~~
+ The coupling constraint introduces a free variable ``alpha`` (the normalised gas flow)
+ and enforces ``P[d_i] == coeff_i * alpha`` for every device in the group.
+ Choosing thermal efficiency η_heat = 0.5 and power efficiency η_power = 0.3,
+ the coefficients simply become the signed efficiency fractions::
+
+ P_gas = 1.0 * alpha (input, coeff = 1.0)
+ P_heat = -0.5 * alpha (output, coeff = η_heat = -0.5)
+ P_power = -0.3 * alpha (output, coeff = η_power = −0.3)
+
+ """
+ ETA_HEAT = 0.5 # fraction of CHP gas input that becomes heat
+ ETA_POWER = 0.3 # fraction of CHP gas input that becomes power
+ STEAM_DEMAND = 15.0 # kW, constant heat drain representing steam production
+ CHP_GAS_MAX = 20.0 # kW, maximum gas input to CHP
+ BOILER_GAS_MAX = 10.0 # kW, maximum gas input to gas boiler
+ HEATER_POWER_MAX = 100.0 # kW, maximum electricity input to e-heater
+
+ start = pd.Timestamp("2026-01-01T00:00+01:00")
+ end = pd.Timestamp("2026-01-01T04:00+01:00")
+ resolution = pd.Timedelta("1h")
+ index = initialize_index(start=start, end=end, resolution=resolution)
+
+ def _df(**kwargs) -> pd.DataFrame:
+ """Build a device-constraints DataFrame with defaults for unused columns."""
+ defaults = {
+ "min": np.nan,
+ "max": np.nan,
+ "equals": np.nan,
+ "derivative min": 0.0,
+ "derivative max": 0.0,
+ "derivative equals": np.nan,
+ "derivative down efficiency": 1.0,
+ "derivative up efficiency": 1.0,
+ "stock delta": 0.0,
+ }
+ defaults.update(kwargs)
+ return pd.DataFrame(defaults, index=index)
+
+ device_constraints = [
+ # d=0 e-heater: heat-node reference device. The min=max=0 forces the heat
+ # node to balance at every step (zero-capacity flow node), making
+ # the per-step dispatch deterministic despite flat prices.
+ _df(min=0.0, max=0.0, **{"derivative max": HEATER_POWER_MAX}),
+ # d=1 gas boiler: up to 100 kW gas → 100 kW heat (efficiency 1 for clean maths in test)
+ _df(**{"derivative max": BOILER_GAS_MAX, "commodity": "gas"}),
+ # d=2 steamer: can only produce steam (negative ems_power).
+ # The lower bound is finite to avoid unbounded model messages while still
+ # being looser than the upstream heat-supply limits.
+ _df(
+ **{
+ "derivative min": -(HEATER_POWER_MAX + BOILER_GAS_MAX),
+ "derivative max": 0.0,
+ "commodity": "steam",
+ }
+ ),
+ # d=3 CHP gas input: up to CHP_GAS_MAX kW gas
+ _df(**{"derivative max": CHP_GAS_MAX, "commodity": "gas"}),
+ # d=4 CHP heat output: positive ems_power adds heat to the steam node.
+ # The min=max=0 forces the steam node to balance at every step.
+ _df(
+ min=0.0,
+ max=0.0,
+ **{
+ "derivative min": -CHP_GAS_MAX * ETA_HEAT,
+ "derivative max": 0.0,
+ "commodity": "steam",
+ },
+ ),
+ # d=5 CHP power output: negative ems_power only (production)
+ _df(**{"derivative min": -CHP_GAS_MAX * ETA_POWER, "derivative max": 0.0}),
+ # d=6 steam demand: fixed steam consumption at STEAM_DEMAND kW.
+ _df(
+ **{
+ "derivative min": STEAM_DEMAND,
+ "derivative max": STEAM_DEMAND,
+ "commodity": "steam",
+ }
+ ),
+ ]
+
+ ems_constraints = pd.DataFrame(
+ {"derivative min": -300.0, "derivative max": 300.0},
+ index=index,
+ )
+
+ # stock group: all heat-buffer devices share the same stock
+ # (key 0 is an arbitrary group id, not a device index)
+ heat_group_id = 0
+ steam_group_id = 1
+ stock_groups = {heat_group_id: [0, 1, 2], steam_group_id: [2, 4, 6]}
+
+ # CHP coupling: coefficients are signed efficiency fractions.
+ # coeff_heat = -η_heat = -0.5 → P_heat = -0.5 * alpha = -0.5 * P_gas
+ # coeff_power = -η_power = -0.3 → P_power = -0.3 * alpha = -0.3 * P_gas
+ coupling_groups = {
+ "chp": [
+ (3, 1.0),
+ (4, -ETA_HEAT), # = -0.5
+ (5, -ETA_POWER), # = -0.3
+ ]
+ }
+
+ # --- energy-price commitments -------------------------------------------
+ # Gas price applies to gas boiler (d=1) and CHP gas input (d=3).
+ # Electricity price applies to e-heater (d=0) and CHP power output (d=5).
+ # Using both upwards and downwards prices makes each commitment a two-sided
+ # soft equality (quantity = 0):
+ # • upward deviation = consuming more than 0 → positive cost
+ # • downward deviation = producing (negative flow) → negative cost (revenue)
+ gas_p = pd.Series(gas_price, index=index)
+ elec_p = pd.Series(elec_price, index=index)
+
+ commitments = []
+ for d, price in [(1, gas_p), (3, gas_p), (0, elec_p), (5, elec_p)]:
+ commitments.append(
+ FlowCommitment(
+ name="gas cost" if d in (1, 3) else "electricity cost",
+ index=index,
+ quantity=pd.Series(0.0, index=index),
+ upwards_deviation_price=price,
+ downwards_deviation_price=price,
+ device=pd.Series(d, index=index),
+ )
+ )
+
+ schedules, _costs, results, _model = device_scheduler(
+ device_constraints=device_constraints,
+ ems_constraints=ems_constraints,
+ commitments=commitments,
+ stock_groups=stock_groups,
+ coupling_groups=coupling_groups,
+ )
+
+ assert results.solver.termination_condition == "optimal", (
+ f"Solver did not find an optimal solution "
+ f"(gas_price={gas_price}, elec_price={elec_price})"
+ )
+ return tuple(schedules)
+
+
+def test_factory_chp_dispatch():
+ """Factory: CHP + gas boiler + e-heater competing to meet a fixed steam demand.
+
+ The shared heat buffer (modelled via ``stock_groups``) is drained at a
+ constant rate of 15 kW by the steam demand device. Two price scenarios
+ verify that the optimizer correctly chooses the cheapest heat source.
+
+ Scenario A — gas cheaper than electricity
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ Prices: gas = 20 EUR/kW, electricity = 50 EUR/kW.
+
+ Effective cost per kW of heat delivered:
+ - CHP: gas_cost − power_revenue = (20·20 − 50·6) / 10 = 10 EUR/kW
+ - gas boiler: 20 EUR/kW (efficiency = 1)
+ - e-heater: 50 EUR/kW (efficiency = 1)
+
+ Merit order: CHP ≪ gas boiler ≪ e-heater.
+
+ With CHP at maximum (20 kW gas → 10 kW heat + 6 kW power):
+ - remaining heat demand = 15 − 10 = 5 kW → gas boiler
+ - e-heater not needed
+
+ Scenario B — electricity cheaper than gas
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ Prices: gas = 100 EUR/kW, electricity = 10 EUR/kW.
+
+ Effective cost per kW of heat:
+ - CHP: (100·20 − 10·6) / 10 = 194 EUR/kW
+ - gas boiler: 100 EUR/kW
+ - e-heater: 10 EUR/kW
+
+ Merit order: e-heater ≪ gas boiler ≪ CHP.
+
+ All 15 kW steam demand is met by the e-heater; CHP and gas boiler are off.
+
+ Scenario C — gas slightly cheaper
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ Prices: gas = 50 EUR/kW, electricity = 55 EUR/kW.
+
+ Effective cost per kW of heat delivered:
+ - CHP: gas_cost − power_revenue = (50·20 − 55·6) / 10 = 67 EUR/kW
+ - gas boiler: 50 EUR/kW
+ - e-heater: 55 EUR/kW
+
+ Merit order: gas boiler ≪ e-heater ≪ CHP.
+
+ With gas boiler at maximum (10 kW gas → 10 kW heat):
+ - remaining heat demand = 15 − 10 = 5 kW → e-heater
+ - CHP not needed
+ """
+ # ------------------------------------------------------------------ #
+ # Scenario A: gas cheaper — CHP at max, gas boiler fills the rest #
+ # ------------------------------------------------------------------ #
+ (e_heater, gas_boiler, steamer, chp_gas, chp_heat, chp_power, demand) = (
+ _run_factory_scenario(gas_price=20.0, elec_price=50.0)
+ )
+
+ expected_chp_gas = pd.Series(20.0, index=e_heater.index)
+ expected_chp_heat = pd.Series(-10.0, index=e_heater.index) # -0.5 * 20
+ expected_chp_power = pd.Series(-6.0, index=e_heater.index) # -0.3 * 20
+ expected_boiler = pd.Series(5.0, index=e_heater.index) # fills 15-10 kW gap
+ expected_steamer = pd.Series(-5.0, index=e_heater.index)
+ expected_demand = pd.Series(15.0, index=e_heater.index)
+ expected_eheater = pd.Series(0.0, index=e_heater.index)
+
+ pd.testing.assert_series_equal(
+ chp_gas,
+ expected_chp_gas,
+ check_names=False,
+ rtol=1e-4,
+ obj="Scenario A: CHP gas input at maximum (20 kW)",
+ )
+ pd.testing.assert_series_equal(
+ chp_heat,
+ expected_chp_heat,
+ check_names=False,
+ rtol=1e-4,
+ obj="Scenario A: CHP heat output = 0.5 × gas input (10 kW)",
+ )
+ pd.testing.assert_series_equal(
+ chp_power,
+ expected_chp_power,
+ check_names=False,
+ rtol=1e-4,
+ obj="Scenario A: CHP power output = −0.3 × gas input (−6 kW)",
+ )
+ pd.testing.assert_series_equal(
+ gas_boiler,
+ expected_boiler,
+ check_names=False,
+ rtol=1e-4,
+ obj="Scenario A: gas boiler fills remaining 5 kW heat demand",
+ )
+ pd.testing.assert_series_equal(
+ steamer,
+ expected_steamer,
+ check_names=False,
+ rtol=1e-4,
+ obj="Scenario A: steamer supplies remaining 5 kW steam",
+ )
+ pd.testing.assert_series_equal(
+ demand,
+ expected_demand,
+ check_names=False,
+ rtol=1e-4,
+ obj="Scenario A: steam demand fixed at 15 kW",
+ )
+ pd.testing.assert_series_equal(
+ e_heater,
+ expected_eheater,
+ check_names=False,
+ atol=1e-4,
+ obj="Scenario A: e-heater not used (gas is cheapest)",
+ )
+
+ # ------------------------------------------------------------------ #
+ # Scenario B: electricity cheaper — e-heater meets all demand #
+ # ------------------------------------------------------------------ #
+ (e_heater, gas_boiler, steamer, chp_gas, chp_heat, chp_power, demand) = (
+ _run_factory_scenario(gas_price=100.0, elec_price=10.0)
+ )
+
+ expected_eheater_b = pd.Series(15.0, index=e_heater.index)
+ expected_zero = pd.Series(0.0, index=e_heater.index)
+ expected_steamer_b = pd.Series(-15.0, index=e_heater.index)
+ expected_demand_b = pd.Series(15.0, index=e_heater.index)
+
+ pd.testing.assert_series_equal(
+ e_heater,
+ expected_eheater_b,
+ check_names=False,
+ rtol=1e-4,
+ obj="Scenario B: e-heater meets all 15 kW steam demand",
+ )
+ pd.testing.assert_series_equal(
+ chp_gas,
+ expected_zero,
+ check_names=False,
+ atol=1e-4,
+ obj="Scenario B: CHP not used (electricity is cheapest)",
+ )
+ pd.testing.assert_series_equal(
+ gas_boiler,
+ expected_zero,
+ check_names=False,
+ atol=1e-4,
+ obj="Scenario B: gas boiler not used (electricity is cheapest)",
+ )
+ pd.testing.assert_series_equal(
+ steamer,
+ expected_steamer_b,
+ check_names=False,
+ rtol=1e-4,
+ obj="Scenario B: steamer supplies all 15 kW steam",
+ )
+ pd.testing.assert_series_equal(
+ demand,
+ expected_demand_b,
+ check_names=False,
+ rtol=1e-4,
+ obj="Scenario B: steam demand fixed at 15 kW",
+ )
+
+ # --------------------------------------------------------------------------------- #
+ # Scenario C: gas slightly cheaper — gas boiler at max, e-heater fills the rest #
+ # --------------------------------------------------------------------------------- #
+ (e_heater, gas_boiler, steamer, chp_gas, chp_heat, chp_power, demand) = (
+ _run_factory_scenario(gas_price=50.0, elec_price=55.0)
+ )
+
+ expected_chp_gas = pd.Series(0.0, index=e_heater.index)
+ expected_chp_heat = pd.Series(0.0, index=e_heater.index)
+ expected_chp_power = pd.Series(0.0, index=e_heater.index)
+ expected_boiler = pd.Series(10.0, index=e_heater.index)
+ expected_steamer = pd.Series(-15.0, index=e_heater.index)
+ expected_demand = pd.Series(15.0, index=e_heater.index)
+ expected_eheater = pd.Series(5.0, index=e_heater.index) # fills 15-10 kW gap
+
+ pd.testing.assert_series_equal(
+ chp_gas,
+ expected_chp_gas,
+ check_names=False,
+ rtol=1e-4,
+ obj="Scenario C: CHP not used",
+ )
+ pd.testing.assert_series_equal(
+ chp_heat,
+ expected_chp_heat,
+ check_names=False,
+ rtol=1e-4,
+ obj="Scenario C: CHP not used",
+ )
+ pd.testing.assert_series_equal(
+ chp_power,
+ expected_chp_power,
+ check_names=False,
+ rtol=1e-4,
+ obj="Scenario C: CHP not used",
+ )
+ pd.testing.assert_series_equal(
+ gas_boiler,
+ expected_boiler,
+ check_names=False,
+ rtol=1e-4,
+ obj="Scenario C: gas boiler at maximum (10 kW)",
+ )
+ pd.testing.assert_series_equal(
+ steamer,
+ expected_steamer,
+ check_names=False,
+ rtol=1e-4,
+ obj="Scenario C: steamer supplies all 15 kW steam",
+ )
+ pd.testing.assert_series_equal(
+ demand,
+ expected_demand,
+ check_names=False,
+ rtol=1e-4,
+ obj="Scenario C: steam demand fixed at 15 kW",
+ )
+ pd.testing.assert_series_equal(
+ e_heater,
+ expected_eheater,
+ check_names=False,
+ atol=1e-4,
+ obj="Scenario C: e-heater fills remaining 5 kW heat demand",
+ )
diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py
index 2bd1321271..c727d357e2 100644
--- a/flexmeasures/data/models/planning/tests/test_solver.py
+++ b/flexmeasures/data/models/planning/tests/test_solver.py
@@ -752,26 +752,23 @@ def test_building_solver_day_2(
# 1) 8 expensive, 8 cheap and 8 expensive hours
# 2) 8 net-consumption, 8 net-production and 8 net-consumption hours
- # Result after 8 hours
- # 1) Sell what you begin with
- # 2) The battery discharged as far as it could during the first 8 net-consumption hours
- assert soc_schedule.loc[start + timedelta(hours=8)] == max(
+ soc_min_value = max(
soc_min, ur.Quantity(battery.get_attribute("soc-min")).to("MWh").magnitude
)
-
- # Result after second 8 hour-interval
- # 1) Buy what you can to sell later, when prices will be high again
- # 2) The battery charged with PV power as far as it could during the middle 8 net-production hours
- assert soc_schedule.loc[start + timedelta(hours=16)] == min(
+ soc_max_value = min(
soc_max, ur.Quantity(battery.get_attribute("soc-max")).to("MWh").magnitude
)
- # Result at end of day
- # 1) The battery sold out at the end of its planning horizon
- # 2) The battery discharged as far as it could during the last 8 net-consumption hours
- assert soc_schedule.iloc[-1] == max(
- soc_min, ur.Quantity(battery.get_attribute("soc-min")).to("MWh").magnitude
- )
+ # In both scenarios the battery should fully discharge in the first 8 hours,
+ # fully charge in the next 8, and fully discharge again in the last 8 (driven by
+ # 1) the dynamic price profile, or 2) the net-consumption/net-production profile of
+ # the inflexible devices, which are part of the electricity commodity device group).
+ # Result after 8 hours: discharged as far as possible.
+ assert soc_schedule.loc[start + timedelta(hours=8)] == soc_min_value
+ # Result after second 8 hour-interval: charged as far as possible.
+ assert soc_schedule.loc[start + timedelta(hours=16)] == soc_max_value
+ # Result at end of day: discharged as far as possible.
+ assert soc_schedule.iloc[-1] == soc_min_value
def test_soc_bounds_timeseries(db, add_battery_assets):
@@ -1386,8 +1383,14 @@ def set_if_not_none(dictionary, key, value):
assert all(device_constraints[0]["derivative min"] == -expected_capacity)
assert all(device_constraints[0]["derivative max"] == expected_capacity)
- assert all(ems_constraints["derivative min"] == expected_site_production_capacity)
- assert all(ems_constraints["derivative max"] == expected_site_consumption_capacity)
+ # EMS constraints are kept per commodity; this single-battery case has only the
+ # default "electricity" commodity, so its constraints are in ems_constraints[0].
+ assert all(
+ ems_constraints[0]["derivative min"] == expected_site_production_capacity
+ )
+ assert all(
+ ems_constraints[0]["derivative max"] == expected_site_consumption_capacity
+ )
@pytest.mark.parametrize(
@@ -1667,7 +1670,8 @@ def test_battery_power_capacity_as_sensor(
data_to_solver = scheduler._prepare()
device_constraints = data_to_solver[5][0]
- ems_constraints = data_to_solver[6]
+ # EMS constraints are kept per commodity; index [0] selects the "electricity" group.
+ ems_constraints = data_to_solver[6][0]
assert all(device_constraints["derivative min"].values == expected_production)
assert all(device_constraints["derivative max"].values == expected_consumption)
@@ -2452,6 +2456,10 @@ def test_unavoidable_capacity_breach():
end,
resolution,
)
+ # All commitments in this test are EMS-level and apply to device 0.
+ # The new grouped_commitment_equalities requires a device column to couple
+ # commitments to device flows.
+ empty_commitment["device"] = 0
commitments = []
commitments.append(empty_commitment.copy())
@@ -2597,6 +2605,8 @@ def test_multiple_commitments_per_group():
end,
resolution,
)
+ # All commitments in this test apply to device 0.
+ empty_commitment["device"] = 0
commitments = []
commitments.append(empty_commitment.copy())
@@ -2769,6 +2779,13 @@ def initialize_combined_commitments(num_devices: int):
resolution=resolution,
market_prices=market_prices,
)
+ # Couple the energy commitment to all devices so grouped_commitment_equalities
+ # can properly link it to device flows. A scalar device_group label is required
+ # to avoid creating a multi-dimensional Pyomo index from the device tuple.
+ energy_commitment["device"] = [tuple(range(num_devices))] * len(
+ energy_commitment
+ )
+ energy_commitment["device_group"] = "site"
commitments.append(energy_commitment)
# Model penalties for demand unmet per device
@@ -3068,6 +3085,8 @@ def run_sequential_scheduler():
resolution=resolution,
market_prices=market_prices,
)
+ # Couple the energy commitment to device 0 (each device is scheduled separately).
+ energy_commitment["device"] = 0
ems_constraints = initialize_ems_constraints()
diff --git a/flexmeasures/data/models/planning/tests/test_storage.py b/flexmeasures/data/models/planning/tests/test_storage.py
index ed8c09be82..55154dd1ff 100644
--- a/flexmeasures/data/models/planning/tests/test_storage.py
+++ b/flexmeasures/data/models/planning/tests/test_storage.py
@@ -6,6 +6,7 @@
import numpy as np
import pandas as pd
+from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType
from flexmeasures.data.models.planning import Scheduler
from flexmeasures.data.models.planning.storage import StorageScheduler
from flexmeasures.data.models.planning.utils import initialize_index
@@ -15,6 +16,7 @@
get_sensors_from_db,
series_to_ts_specs,
)
+from flexmeasures.data.services.utils import get_or_create_model
def test_battery_solver_multi_commitment(add_battery_assets, db):
@@ -128,20 +130,24 @@ def test_battery_solver_multi_commitment(add_battery_assets, db):
# Check costs are correct
# 60 EUR for 600 kWh consumption priced at 100 EUR/MWh
- np.testing.assert_almost_equal(costs["energy"], 100 * (1 - 0.4))
+ np.testing.assert_almost_equal(costs["electricity net energy"], 100 * (1 - 0.4))
# 24000 EUR for any 24 kW consumption breach priced at 1000 EUR/kW
- np.testing.assert_almost_equal(costs["any consumption breach"], 1000 * (25 - 1))
+ np.testing.assert_almost_equal(
+ costs["electricity any consumption breach"], 1000 * (25 - 1)
+ )
# 24000 EUR for each 24 kW consumption breach per hour priced at 1000 EUR/kWh
np.testing.assert_almost_equal(
- costs["all consumption breaches"], 1000 * (25 - 1) * 96 / 4
+ costs["electricity all consumption breaches"], 1000 * (25 - 1) * 96 / 4
)
# No production breaches
- np.testing.assert_almost_equal(costs["any production breach"], 0)
- np.testing.assert_almost_equal(costs["all production breaches"], 0 * 96)
+ np.testing.assert_almost_equal(costs["electricity any production breach"], 0)
+ np.testing.assert_almost_equal(costs["electricity all production breaches"], 0 * 96)
# 1.3 EUR for the 5 kW extra consumption peak priced at 260 EUR/MW
- np.testing.assert_almost_equal(costs["consumption peak"], 260 / 1000 * (25 - 20))
+ np.testing.assert_almost_equal(
+ costs["electricity consumption peak"], 260 / 1000 * (25 - 20)
+ )
# No production peak
- np.testing.assert_almost_equal(costs["production peak"], 0)
+ np.testing.assert_almost_equal(costs["electricity production peak"], 0)
# Sample commitments
np.testing.assert_almost_equal(
@@ -579,3 +585,187 @@ def test_resolve_soc_at_start_from_percent_sensor_uses_device_sensor_fallback(
)
== 2.5
)
+
+
+def test_storage_scheduler_chp_coupling(app, db):
+ """Test that the StorageScheduler enforces CHP coupling constraints between devices.
+
+ Models a Combined Heat and Power unit with three sensors.
+
+ In the flex-model, the coupling coefficients are entered as positive magnitudes::
+
+ gas input -> 1.0
+ heat output -> 0.5
+ power output -> 0.3
+
+ Internally, the CHP is interpreted with the signed commodity-flow coefficients::
+
+ P_gas -> 1.0
+ P_heat -> -0.5
+ P_power -> -0.3
+
+ The returned storage schedule for the heat buffer is still positive, because this
+ test uses the storage sign convention for buffer charging.
+
+ - d=0 gas input: CHP gas consumption
+ - d=1 heat output: CHP heat -> heat buffer
+ - d=2 power output: CHP electricity production
+
+ The heat output is forced to exactly 5 kW per step by combining:
+ - ``production-capacity: "0 kW"`` (hard lower bound: derivative_min = 0)
+ - ``consumption-capacity: "5 kW"`` (hard upper bound: derivative_max = 0.005 MW)
+ - ``soc-targets`` requiring 20 kWh at the end of the 4-hour window
+
+ With soc_at_start = 0 and max 5 kW over 4 × 1-hour steps the only feasible
+ solution is P_heat = 5 kW every step. Substituting P_heat = 5 kW gives
+ alpha = 5 / 0.5 = 10 kW, so:
+
+ P_gas = 1.0 × 10 kW = 10 kW
+ P_power = −0.3 × 10 kW = −3 kW
+ """
+ # ---- asset type + asset
+ chp_type = get_or_create_model(GenericAssetType, name="chp-plant")
+ chp = GenericAsset(name="CHP plant (coupling test)", generic_asset_type=chp_type)
+ db.session.add(chp)
+ db.session.flush()
+
+ # ---- schedule window
+ start = pd.Timestamp("2026-01-01T00:00:00+01:00")
+ end = pd.Timestamp("2026-01-01T04:00:00+01:00")
+ resolution = timedelta(hours=1)
+
+ # CHP efficiencies (same values as the factory scenario in test_commitments.py)
+ ETA_HEAT = 0.5 # fraction of gas input that becomes heat
+ ETA_POWER = 0.3 # fraction of gas input that becomes electricity
+
+ # ---- sensors
+ gas_input_sensor = Sensor(
+ name="CHP gas input (coupling test)",
+ generic_asset=chp,
+ unit="MW",
+ event_resolution=resolution,
+ )
+ heat_output_sensor = Sensor(
+ name="CHP heat output (coupling test)",
+ generic_asset=chp,
+ unit="MW",
+ event_resolution=resolution,
+ )
+ power_output_sensor = Sensor(
+ name="CHP power output (coupling test)",
+ generic_asset=chp,
+ unit="MW",
+ event_resolution=resolution,
+ )
+ db.session.add_all([gas_input_sensor, heat_output_sensor, power_output_sensor])
+ db.session.flush()
+
+ # ---- flex model
+ # Flex-model coupling-coefficients are user-facing positive magnitudes.
+ # The intended internal CHP coefficients are +1.0 for gas, -0.5 for heat,
+ # and -0.3 for power.
+ flex_model = [
+ {
+ # d=0: gas input — pure flow device (no SoC), can only consume gas.
+ "sensor": gas_input_sensor.id,
+ "power-capacity": "20 kW",
+ "production-capacity": "0 kW", # derivative_min = 0
+ "coupling": "chp",
+ "coupling-coefficient": 1.0,
+ },
+ {
+ # d=1: heat output — tracks heat-buffer SoC, positive ems_power = heat
+ # added to buffer. The SoC target forces P_heat = 5 kW per step.
+ "sensor": heat_output_sensor.id,
+ "soc-at-start": "0 MWh",
+ "soc-min": "0 MWh",
+ "soc-max": "0.02 MWh", # 20 kWh — matches the SoC target
+ "soc-targets": [
+ {
+ # Single target at the schedule end: cumulative heat = 20 kWh.
+ # With max 5 kW and 4 × 1 h steps the only feasible solution
+ # is 5 kW every step.
+ "start": "2026-01-01T04:00:00+01:00",
+ "duration": "PT1H",
+ "value": "0.02 MWh",
+ }
+ ],
+ "power-capacity": "5 kW",
+ "consumption-capacity": "5 kW",
+ "production-capacity": "0 kW", # can only add heat, not extract
+ "prefer-charging-sooner": True,
+ "coupling": "chp",
+ "coupling-coefficient": ETA_HEAT, # = 0.5
+ },
+ {
+ # d=2: power output — pure flow device (no SoC), can only produce
+ # electricity (negative ems_power).
+ "sensor": power_output_sensor.id,
+ "power-capacity": "6 kW",
+ "consumption-capacity": "0 kW", # derivative_max = 0
+ "coupling": "chp",
+ "coupling-coefficient": ETA_POWER, # = 0.3 (sign inferred from capacities)
+ },
+ ]
+
+ flex_context = {
+ "consumption-price": "50 EUR/MWh",
+ "production-price": "50 EUR/MWh",
+ "site-power-capacity": "1 MW", # large enough to avoid EMS constraints
+ }
+
+ scheduler = StorageScheduler(
+ asset_or_sensor=chp,
+ start=start,
+ end=end,
+ resolution=resolution,
+ flex_model=flex_model,
+ flex_context=flex_context,
+ return_multiple=True,
+ )
+
+ results = scheduler.compute(skip_validation=True)
+
+ # ---- extract storage schedules per sensor
+ storage_schedules = {
+ r["sensor"]: r["data"] for r in results if r.get("name") == "storage_schedule"
+ }
+
+ assert gas_input_sensor in storage_schedules, "Gas input schedule missing"
+ assert heat_output_sensor in storage_schedules, "Heat output schedule missing"
+ assert power_output_sensor in storage_schedules, "Power output schedule missing"
+
+ gas_schedule = storage_schedules[gas_input_sensor]
+ heat_schedule = storage_schedules[heat_output_sensor]
+ power_schedule = storage_schedules[power_output_sensor]
+
+ # The SoC target of 20 kWh is met after 4 × 1-hour steps at 5 kW.
+ # The schedule index runs from ``start`` to ``end`` inclusive (5 time slots),
+ # so the last slot has no binding SoC constraint and the CHP is idle there.
+ # All assertions therefore apply to the first four active slots only.
+ active_steps = slice(None, -1) # exclude the final trailing idle slot
+
+ # Heat output is forced to exactly 5 kW per step by the SoC target.
+ # alpha = P_heat / ETA_HEAT = 0.005 / 0.5 = 0.010 MW
+ np.testing.assert_allclose(
+ heat_schedule.iloc[active_steps],
+ 0.005, # 5 kW expressed in MW
+ rtol=1e-4,
+ err_msg="Heat output should be exactly 5 kW per step (forced by SoC target)",
+ )
+
+ # Coupling: P_gas = 1.0 * alpha = 0.010 MW = 10 kW
+ np.testing.assert_allclose(
+ gas_schedule.iloc[active_steps],
+ 0.010, # 10 kW expressed in MW
+ rtol=1e-4,
+ err_msg="Gas input must be 10 kW — determined by coupling (1.0 * alpha)",
+ )
+
+ # Coupling: P_power = -ETA_POWER * alpha = -0.3 * 0.010 MW = -0.003 MW = -3 kW
+ np.testing.assert_allclose(
+ power_schedule.iloc[active_steps],
+ -0.003, # -3 kW expressed in MW
+ rtol=1e-4,
+ err_msg="Power output must be -3 kW — determined by coupling (-0.3 * alpha)",
+ )
diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py
index ad9dc79d17..b5bb9d8540 100644
--- a/flexmeasures/data/schemas/scheduling/__init__.py
+++ b/flexmeasures/data/schemas/scheduling/__init__.py
@@ -1,4 +1,6 @@
from __future__ import annotations
+
+from collections import OrderedDict
from datetime import timedelta
from typing import Any, Callable, Dict
@@ -140,71 +142,7 @@ class DBCommitmentSchema(CommitmentSchema, NoTimeSeriesSpecs):
pass
-class FlexContextSchema(Schema):
- """This schema defines fields that provide context to the portfolio to be optimized."""
-
- # Device commitments
- consumption_breach_price = VariableQuantityField(
- "/MW",
- data_key="consumption-breach-price",
- required=False,
- value_validator=validate.Range(min=0),
- metadata=metadata.CONSUMPTION_BREACH_PRICE.to_dict(),
- )
- production_breach_price = VariableQuantityField(
- "/MW",
- data_key="production-breach-price",
- required=False,
- value_validator=validate.Range(min=0),
- metadata=metadata.PRODUCTION_BREACH_PRICE.to_dict(),
- )
- soc_minima_breach_price = VariableQuantityField(
- "/MWh",
- data_key="soc-minima-breach-price",
- required=False,
- value_validator=validate.Range(min=0),
- metadata=metadata.SOC_MINIMA_BREACH_PRICE.to_dict(),
- )
- soc_maxima_breach_price = VariableQuantityField(
- "/MWh",
- data_key="soc-maxima-breach-price",
- required=False,
- value_validator=validate.Range(min=0),
- metadata=metadata.SOC_MAXIMA_BREACH_PRICE.to_dict(),
- )
- relax_constraints = fields.Bool(
- data_key="relax-constraints",
- load_default=False,
- metadata=metadata.RELAX_CONSTRAINTS.to_dict(),
- )
- # Dev fields
- relax_soc_constraints = fields.Bool(
- data_key="relax-soc-constraints",
- load_default=False,
- metadata=metadata.RELAX_SOC_CONSTRAINTS.to_dict(),
- )
- relax_capacity_constraints = fields.Bool(
- data_key="relax-capacity-constraints",
- load_default=False,
- metadata=metadata.RELAX_CAPACITY_CONSTRAINTS.to_dict(),
- )
- relax_site_capacity_constraints = fields.Bool(
- data_key="relax-site-capacity-constraints",
- load_default=False,
- metadata=metadata.RELAX_SITE_CAPACITY_CONSTRAINTS.to_dict(),
- )
-
- # Energy commitments
- ems_power_capacity_in_mw = VariableQuantityField(
- "MW",
- required=False,
- data_key="site-power-capacity",
- value_validator=validate.Range(min=0),
- metadata=metadata.SITE_POWER_CAPACITY.to_dict(),
- )
- # todo: deprecated since flexmeasures==0.23
- consumption_price_sensor = SensorIdField(data_key="consumption-price-sensor")
- production_price_sensor = SensorIdField(data_key="production-price-sensor")
+class SharedSchema(Schema):
consumption_price = VariableQuantityField(
"/MWh",
required=False,
@@ -212,6 +150,7 @@ class FlexContextSchema(Schema):
return_magnitude=False,
metadata=metadata.CONSUMPTION_PRICE.to_dict(),
)
+
production_price = VariableQuantityField(
"/MWh",
required=False,
@@ -220,14 +159,14 @@ class FlexContextSchema(Schema):
metadata=metadata.PRODUCTION_PRICE.to_dict(),
)
- # Capacity breach commitments
- ems_production_capacity_in_mw = VariableQuantityField(
+ ems_power_capacity_in_mw = VariableQuantityField(
"MW",
required=False,
- data_key="site-production-capacity",
+ data_key="site-power-capacity",
value_validator=validate.Range(min=0),
- metadata=metadata.SITE_PRODUCTION_CAPACITY.to_dict(),
+ metadata=metadata.SITE_POWER_CAPACITY.to_dict(),
)
+
ems_consumption_capacity_in_mw = VariableQuantityField(
"MW",
required=False,
@@ -235,6 +174,15 @@ class FlexContextSchema(Schema):
value_validator=validate.Range(min=0),
metadata=metadata.SITE_CONSUMPTION_CAPACITY.to_dict(),
)
+
+ ems_production_capacity_in_mw = VariableQuantityField(
+ "MW",
+ required=False,
+ data_key="site-production-capacity",
+ value_validator=validate.Range(min=0),
+ metadata=metadata.SITE_PRODUCTION_CAPACITY.to_dict(),
+ )
+
ems_consumption_breach_price = VariableQuantityField(
"/MW",
data_key="site-consumption-breach-price",
@@ -242,6 +190,7 @@ class FlexContextSchema(Schema):
value_validator=validate.Range(min=0),
metadata=metadata.SITE_CONSUMPTION_BREACH_PRICE.to_dict(),
)
+
ems_production_breach_price = VariableQuantityField(
"/MW",
data_key="site-production-breach-price",
@@ -250,7 +199,6 @@ class FlexContextSchema(Schema):
metadata=metadata.SITE_PRODUCTION_BREACH_PRICE.to_dict(),
)
- # Peak consumption commitment
ems_peak_consumption_in_mw = VariableQuantityField(
"MW",
required=False,
@@ -259,6 +207,7 @@ class FlexContextSchema(Schema):
load_default=ur.Quantity("0 kW"),
metadata=metadata.SITE_PEAK_CONSUMPTION.to_dict(),
)
+
ems_peak_consumption_price = VariableQuantityField(
"/MW",
data_key="site-peak-consumption-price",
@@ -267,7 +216,6 @@ class FlexContextSchema(Schema):
metadata=metadata.SITE_PEAK_CONSUMPTION_PRICE.to_dict(),
)
- # Peak production commitment
ems_peak_production_in_mw = VariableQuantityField(
"MW",
required=False,
@@ -276,6 +224,7 @@ class FlexContextSchema(Schema):
load_default=ur.Quantity("0 kW"),
metadata=metadata.SITE_PEAK_PRODUCTION.to_dict(),
)
+
ems_peak_production_price = VariableQuantityField(
"/MW",
data_key="site-peak-production-price",
@@ -283,7 +232,6 @@ class FlexContextSchema(Schema):
value_validator=validate.Range(min=0),
metadata=metadata.SITE_PEAK_PRODUCTION_PRICE.to_dict(),
)
- # todo: group by month start (MS), something like a commitment resolution, or a list of datetimes representing splits of the commitments
commitments = fields.Nested(
CommitmentSchema,
@@ -298,6 +246,90 @@ class FlexContextSchema(Schema):
data_key="inflexible-device-sensors",
metadata=metadata.INFLEXIBLE_DEVICE_SENSORS.to_dict(),
)
+
+
+class CommodityFlexContextSchema(SharedSchema):
+ commodity = fields.Str(
+ required=True,
+ data_key="commodity",
+ metadata=metadata.COMMODITY_FLEX_CONTEXT.to_dict(),
+ )
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ commodity_field = self.fields.pop("commodity")
+ self.fields = OrderedDict(
+ [("commodity", commodity_field), *self.fields.items()]
+ )
+
+
+class FlexContextSchema(SharedSchema):
+ """This schema defines fields that provide context to the portfolio to be optimized."""
+
+ commodity_contexts = fields.Nested(
+ CommodityFlexContextSchema,
+ data_key="commodities",
+ required=False,
+ many=True,
+ )
+ # Device commitments
+ consumption_breach_price = VariableQuantityField(
+ "/MW",
+ data_key="consumption-breach-price",
+ required=False,
+ value_validator=validate.Range(min=0),
+ metadata=metadata.CONSUMPTION_BREACH_PRICE.to_dict(),
+ )
+ production_breach_price = VariableQuantityField(
+ "/MW",
+ data_key="production-breach-price",
+ required=False,
+ value_validator=validate.Range(min=0),
+ metadata=metadata.PRODUCTION_BREACH_PRICE.to_dict(),
+ )
+ soc_minima_breach_price = VariableQuantityField(
+ "/MWh",
+ data_key="soc-minima-breach-price",
+ required=False,
+ value_validator=validate.Range(min=0),
+ metadata=metadata.SOC_MINIMA_BREACH_PRICE.to_dict(),
+ )
+ soc_maxima_breach_price = VariableQuantityField(
+ "/MWh",
+ data_key="soc-maxima-breach-price",
+ required=False,
+ value_validator=validate.Range(min=0),
+ metadata=metadata.SOC_MAXIMA_BREACH_PRICE.to_dict(),
+ )
+ relax_constraints = fields.Bool(
+ data_key="relax-constraints",
+ load_default=False,
+ metadata=metadata.RELAX_CONSTRAINTS.to_dict(),
+ )
+ # Dev fields
+ relax_soc_constraints = fields.Bool(
+ data_key="relax-soc-constraints",
+ load_default=False,
+ metadata=metadata.RELAX_SOC_CONSTRAINTS.to_dict(),
+ )
+ relax_capacity_constraints = fields.Bool(
+ data_key="relax-capacity-constraints",
+ load_default=False,
+ metadata=metadata.RELAX_CAPACITY_CONSTRAINTS.to_dict(),
+ )
+ relax_site_capacity_constraints = fields.Bool(
+ data_key="relax-site-capacity-constraints",
+ load_default=False,
+ metadata=metadata.RELAX_SITE_CAPACITY_CONSTRAINTS.to_dict(),
+ )
+
+ # Energy commitments
+ # todo: deprecated since flexmeasures==0.23
+ consumption_price_sensor = SensorIdField(data_key="consumption-price-sensor")
+ production_price_sensor = SensorIdField(data_key="production-price-sensor")
+
+ # todo: group by month start (MS), something like a commitment resolution, or a list of datetimes representing splits of the commitments
aggregate_power = VariableQuantityField(
to_unit="MW",
data_key="aggregate-power",
@@ -483,6 +515,7 @@ def _to_currency_per_mwh(price_unit: str) -> str:
EXAMPLE_UNIT_TYPES: Dict[str, list[str]] = {
+ "commodity": ["electricity", "gas"],
"energy-price": ["EUR/MWh", "JPY/kWh", "USD/MWh", "and other currencies."],
"power-price": ["EUR/kW", "JPY/kW", "USD/kW", "and other currencies."],
"power": ["MW", "kW"],
@@ -760,6 +793,15 @@ def _to_currency_per_mwh(price_unit: str) -> str:
},
"example-units": EXAMPLE_UNIT_TYPES["power"],
},
+ "commodity": {
+ "default": "electricity",
+ "description": rst_to_openapi(metadata.COMMODITY_FLEX_MODEL.description),
+ "types": {
+ "backend": "typeOne",
+ "ui": "One fixed value only.",
+ },
+ "example-units": EXAMPLE_UNIT_TYPES["commodity"],
+ },
}
@@ -908,7 +950,17 @@ def ensure_sensor_or_asset(self, data, **kwargs):
and data["sensor"].asset != data["asset"]
):
raise ValidationError("Sensor does not belong to asset.")
- if "sensor" not in data and "asset" not in data:
+ # if (
+ # "state-of-charge" in data["sensor_flex_model"]
+ # and "asset" in data
+ # and data["sensor_flex_model"]["state-of-charge"].asset != data["asset"]
+ # ):
+ # raise ValidationError("Sensor does not belong to asset.")
+ if (
+ "sensor" not in data
+ and "state-of-charge" not in data["sensor_flex_model"]
+ and "asset" not in data
+ ):
raise ValidationError("Specify either a sensor or an asset.")
@pre_load
diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py
index 2aaa23b9a1..7463579864 100644
--- a/flexmeasures/data/schemas/scheduling/metadata.py
+++ b/flexmeasures/data/schemas/scheduling/metadata.py
@@ -27,6 +27,12 @@ def to_dict(self):
# FLEX-CONTEXT
+COMMODITY_FLEX_CONTEXT = MetaData(
+ description="""Commodity to which this part of the flex-context applies.
+Defaults to ``"electricity"``.
+""",
+ examples=["electricity", "gas"],
+)
INFLEXIBLE_DEVICE_SENSORS = MetaData(
description="""Power sensors representing devices that are relevant, but not flexible in the timing of their demand/supply.
For example, a sensor recording rooftop solar power that is connected behind the main meter, and whose production falls under the same contract as the flexible device(s) being scheduled.
@@ -44,12 +50,11 @@ def to_dict(self):
example=[],
)
CONSUMPTION_PRICE = MetaData(
- description="The electricity price applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO₂ intensity—whatever fits your optimization problem. [#old_consumption_price_field]_",
- example={"sensor": 5},
- # examples=[{"sensor": 5}, "0.29 EUR/kWh"], # todo: waiting for https://github.com/marshmallow-code/apispec/pull/999
+ description="The commodity price (e.g. electricity price) applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO₂ intensity—whatever fits your optimization problem. [#old_consumption_price_field]_",
+ examples=[{"sensor": 5}, "0.29 EUR/kWh"],
)
PRODUCTION_PRICE = MetaData(
- description="The electricity price applied to the site's aggregate production. Can be (a sensor recording) market prices, but also CO₂ intensity—whatever fits your optimization problem, as long as the unit matches the ``consumption-price`` unit. [#old_production_price_field]_",
+ description="The commodity price (e.g. electricity price) applied to the site's aggregate production. Can be (a sensor recording) market prices, but also CO₂ intensity—whatever fits your optimization problem, as long as the unit matches the ``consumption-price`` unit. [#old_production_price_field]_",
example="0.12 EUR/kWh",
)
SITE_POWER_CAPACITY = MetaData(
@@ -184,7 +189,12 @@ def to_dict(self):
# FLEX-MODEL
-
+COMMODITY_FLEX_MODEL = MetaData(
+ description="""Commodity on which this device acts.
+Defaults to ``"electricity"``.
+""",
+ examples=["electricity", "gas"],
+)
CONSUMPTION = MetaData(
description="""Sensor used to record the scheduled power as seen from a consumption perspective.
@@ -299,14 +309,14 @@ def to_dict(self):
example="90%",
)
CHARGING_EFFICIENCY = MetaData(
- description="""One-way conversion efficiency from electricity to the storage's state of charge.
+ description="""One-way conversion efficiency from the commodity (e.g. electricity) to the storage's state of charge.
Can be a percentage, a ratio in the range [0,1], or a coefficient of performance (>1).
Defaults to 100% (no conversion loss).
""",
example=".9",
)
DISCHARGING_EFFICIENCY = MetaData(
- description="""One-way conversion efficiency from the storage's state of charge to electricity.
+ description="""One-way conversion efficiency from the storage's state of charge to the commodity (e.g. electricity).
Defaults to 100% (no conversion loss).""",
example="90%",
)
diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py
index 8453c2149d..7c29e607b7 100644
--- a/flexmeasures/data/schemas/scheduling/storage.py
+++ b/flexmeasures/data/schemas/scheduling/storage.py
@@ -29,6 +29,8 @@
is_energy_unit,
)
+ALLOWED_COMMODITIES = {"electricity", "gas"}
+
# Telling type hints what to expect after schema parsing
SoCTarget = TypedDict(
"SoCTarget",
@@ -246,6 +248,39 @@ class StorageFlexModelSchema(Schema):
validate=validate.Length(min=1),
metadata=metadata.SOC_USAGE.to_dict(),
)
+ commodity = fields.Str(
+ data_key="commodity",
+ load_default="electricity",
+ metadata=metadata.COMMODITY_FLEX_MODEL.to_dict(),
+ )
+
+ coupling = fields.Str(
+ data_key="coupling",
+ required=False,
+ load_default=None,
+ metadata=dict(
+ description="Name of the coupling group this device belongs to. "
+ "Devices sharing the same coupling name are constrained to have "
+ "proportionally related flows via a hard equality constraint. "
+ "Use together with 'coupling-coefficient' to set the ratio.",
+ example="chp",
+ ),
+ )
+ coupling_coefficient = fields.Float(
+ data_key="coupling-coefficient",
+ required=False,
+ load_default=1.0,
+ metadata=dict(
+ description="Positive coupling magnitude for this device within its coupling group. "
+ "The optimizer introduces a decision variable 'alpha' per group per time step "
+ "and constrains every device by P[d] == coeff * alpha. "
+ "The sign of coeff is inferred internally from directional capacities: "
+ "consumption-capacity = 0 implies output (negative), production-capacity = 0 implies input (positive). "
+ "Example: a CHP with gas input (1.0), heat output (0.5) and power output (0.3). "
+ "Defaults to 1.0.",
+ example=0.5,
+ ),
+ )
def __init__(
self,
@@ -404,6 +439,11 @@ def check_redundant_efficiencies(self, data: dict, **kwargs):
f"Fields `{field}` and `roundtrip_efficiency` are mutually exclusive."
)
+ @validates("commodity")
+ def validate_commodity(self, commodity: str, **kwargs):
+ if not isinstance(commodity, str) or not commodity.strip():
+ raise ValidationError("commodity must be a non-empty string.")
+
@post_load
def post_load_sequence(self, data: dict, **kwargs) -> dict:
"""Perform some checks and corrections after we loaded."""
@@ -558,6 +598,14 @@ class DBStorageFlexModelSchema(Schema):
metadata={"deprecated field": "production_capacity"},
)
+ commodity = fields.Str(
+ required=False,
+ data_key="commodity",
+ load_default="electricity",
+ validate=OneOf(["electricity", "gas"]),
+ metadata=dict(description="Commodity label for this device/asset."),
+ )
+
mapped_schema_keys: dict
def __init__(self, *args, **kwargs):
diff --git a/flexmeasures/data/services/scheduling.py b/flexmeasures/data/services/scheduling.py
index 5c7c7886fd..21fd7a75b4 100644
--- a/flexmeasures/data/services/scheduling.py
+++ b/flexmeasures/data/services/scheduling.py
@@ -800,7 +800,10 @@ def make_schedule( # noqa: C901
# Save any result that specifies a sensor to save it to
for result in consumption_schedule:
- if "sensor" not in result:
+ if rq_job and result["name"] == "commitment_costs":
+ rq_job.meta["scheduler_info"]["commitment_costs"] = result["data"]
+ continue
+ elif "sensor" not in result:
continue
# Ensure consumption_is_positive is set before resolving the sign.
diff --git a/flexmeasures/data/tests/conftest.py b/flexmeasures/data/tests/conftest.py
index c901aa9683..73820df4fa 100644
--- a/flexmeasures/data/tests/conftest.py
+++ b/flexmeasures/data/tests/conftest.py
@@ -229,7 +229,7 @@ def smart_building_types(app, fresh_db, setup_generic_asset_types_fresh_db):
@pytest.fixture(scope="function")
def smart_building(app, fresh_db, smart_building_types):
"""
- Topology of the sytstem:
+ Topology of the system:
+---------+
| |
@@ -414,6 +414,7 @@ def flex_description_sequential(
"site-production-capacity": "2kW",
"site-consumption-capacity": "5kW",
# Cheap commitments that are not expected to affect the resulting schedule
+ # todo: CommitmentSchema should have a commodity field that defaults to electricity
"commitments": [
{
"name": "a sample commitment rewarding supply",
diff --git a/flexmeasures/data/tests/test_scheduling_sequential.py b/flexmeasures/data/tests/test_scheduling_sequential.py
index 53f67fa8c9..68d5689f2d 100644
--- a/flexmeasures/data/tests/test_scheduling_sequential.py
+++ b/flexmeasures/data/tests/test_scheduling_sequential.py
@@ -92,9 +92,12 @@ def test_create_sequential_jobs(db, app, flex_description_sequential, smart_buil
work_on_rq(queue, handle_scheduling_exception)
# Check that the jobs completed successfully
- assert queued_jobs[0].get_status() == "finished"
- assert deferred_jobs[0].get_status() == "finished"
- assert deferred_jobs[1].get_status() == "finished"
+ ev_job = queued_jobs[0]
+ battery_job = deferred_jobs[0]
+ wrapup_job = deferred_jobs[1]
+ assert ev_job.get_status() == "finished"
+ assert battery_job.get_status() == "finished"
+ assert wrapup_job.get_status() == "finished"
# check results
ev_power = sensors["Test EV"].search_beliefs()
@@ -131,14 +134,26 @@ def test_create_sequential_jobs(db, app, flex_description_sequential, smart_buil
resolution = sensors["Test EV"].event_resolution.total_seconds() / 3600
ev_costs = (-ev_power * prices * resolution).sum().item()
battery_costs = (-battery_power * prices * resolution).sum().item()
- total_cost = ev_costs + battery_costs
# Assert costs
- assert ev_costs == 2.2375, f"EV cost should be 2.2375 €, got {ev_costs} €"
+ expected_ev_costs = 2.2375
+ expected_battery_costs = -4.415
assert (
- battery_costs == -4.415
- ), f"Battery cost should be -4.415 €, got {battery_costs} €"
- assert total_cost == -2.1775, f"Total cost should be -2.1775 €, got {total_cost} €"
+ ev_costs == expected_ev_costs
+ ), f"EV cost should be {expected_ev_costs} €, got {ev_costs} €"
+ assert (
+ battery_costs == expected_battery_costs
+ ), f"Battery cost should be {expected_battery_costs} €, got {battery_costs} €"
+
+ # todo: the ev job has scheduler_info and commitment costs, but the battery job has not
+ # Here, we want to check the electricity costs of the battery job, which takes into account the EV
+ # expected_total_cost = expected_ev_costs + expected_battery_costs
+ # np.testing.assert_approx_equal(
+ # battery_job.meta["scheduler_info"]["commitment_costs"]["electricity net energy"],
+ # expected_total_cost,
+ # 4,
+ # f"Reported costs should match our expectation",
+ # )
def test_create_sequential_jobs_fallback(
diff --git a/flexmeasures/data/tests/test_scheduling_simultaneous.py b/flexmeasures/data/tests/test_scheduling_simultaneous.py
index d58d6ab4c8..b5469d0e6b 100644
--- a/flexmeasures/data/tests/test_scheduling_simultaneous.py
+++ b/flexmeasures/data/tests/test_scheduling_simultaneous.py
@@ -11,7 +11,7 @@
def test_create_simultaneous_jobs(
db, app, flex_description_sequential, smart_building, use_heterogeneous_resolutions
):
- assets, sensors, _ = smart_building
+ assets, sensors, soc_sensors = smart_building
queue = app.queues["scheduling"]
start = pd.Timestamp("2015-01-03").tz_localize("Europe/Amsterdam")
end = pd.Timestamp("2015-01-04").tz_localize("Europe/Amsterdam")
@@ -20,6 +20,17 @@ def test_create_simultaneous_jobs(
"module": "flexmeasures.data.models.planning.storage",
"class": "StorageScheduler",
}
+ flex_description_sequential["flex_model"][0]["sensor_flex_model"][
+ "state-of-charge"
+ ] = {"sensor": soc_sensors["Test EV"].id}
+ if use_heterogeneous_resolutions:
+ flex_description_sequential["flex_model"][1]["sensor_flex_model"][
+ "state-of-charge"
+ ] = {"sensor": soc_sensors["Test Battery 1h"].id}
+ else:
+ flex_description_sequential["flex_model"][1]["sensor_flex_model"][
+ "state-of-charge"
+ ] = {"sensor": soc_sensors["Test Battery"].id}
flex_description_sequential["start"] = start
flex_description_sequential["end"] = end
@@ -47,9 +58,17 @@ def test_create_simultaneous_jobs(
]
ev_power = sensors["Test EV"].search_beliefs()
- battery_power = sensors["Test Battery"].search_beliefs()
+ ev_soc = soc_sensors["Test EV"].search_beliefs()
+ if use_heterogeneous_resolutions:
+ battery_power = sensors["Test Battery 1h"].search_beliefs()
+ battery_soc = soc_sensors["Test Battery 1h"].search_beliefs()
+ else:
+ battery_power = sensors["Test Battery"].search_beliefs()
+ battery_soc = soc_sensors["Test Battery"].search_beliefs()
assert ev_power.empty
+ assert ev_soc.empty
assert battery_power.empty
+ assert battery_soc.empty
# work tasks
work_on_rq(queue)
@@ -58,26 +77,33 @@ def test_create_simultaneous_jobs(
job.perform()
assert job.get_status() == "finished"
- # Get power values
+ # Get power and SoC values
ev_power = sensors["Test EV"].search_beliefs()
assert ev_power.sources.unique()[0].model == "StorageScheduler"
- ev_power = ev_power.droplevel([1, 2, 3])
+ ev_soc = soc_sensors["Test EV"].search_beliefs()
+ assert ev_soc.sources.unique()[0].model == "StorageScheduler"
if use_heterogeneous_resolutions:
battery_power = sensors["Test Battery 1h"].search_beliefs()
assert len(battery_power) == 24
+ battery_soc = soc_sensors["Test Battery 1h"].search_beliefs()
+ assert len(battery_soc) == 97
else:
battery_power = sensors["Test Battery"].search_beliefs()
assert len(battery_power) == 96
+ battery_soc = soc_sensors["Test Battery"].search_beliefs()
+ assert len(battery_soc) == 97
+
+ ev_power = ev_power.droplevel([1, 2, 3])
assert battery_power.sources.unique()[0].model == "StorageScheduler"
battery_power = battery_power.droplevel([1, 2, 3])
- start_charging = start + pd.Timedelta(hours=8)
- end_charging = start + pd.Timedelta(hours=10) - sensors["Test EV"].event_resolution
# Check schedules
- assert (
- ev_power.loc[start_charging:end_charging] != -0.005
- ).values.any(), "no charging at full device power capacity (5 kW) expected"
+ # start_charging = start + pd.Timedelta(hours=8)
+ # end_charging = start + pd.Timedelta(hours=10) - sensors["Test EV"].event_resolution
+ # assert (
+ # ev_power.loc[start_charging:end_charging] != -0.005
+ # ).values.any(), "no charging at full device power capacity (5 kW) expected,
for target_no in (1, 2, 3):
non_zero_target = flex_description_sequential["flex_model"][0][
"sensor_flex_model"
@@ -96,7 +122,7 @@ def test_create_simultaneous_jobs(
]
price_sensor = db.session.get(Sensor, price_sensor_id)
prices = price_sensor.search_beliefs(
- event_starts_after=start - pd.Timedelta(hours=1), event_ends_before=end
+ event_starts_after=start, event_ends_before=end
)
prices = prices.droplevel([1, 2, 3])
prices.index = prices.index.tz_convert("Europe/Amsterdam")
@@ -112,14 +138,27 @@ def test_create_simultaneous_jobs(
expected_battery_costs = expected_total_cost - expected_ev_costs
# Check costs
- assert (
- round(total_cost, 4) == expected_total_cost
- ), f"Total costs should be €{expected_total_cost}, got €{total_cost}"
-
- assert (
- round(ev_costs, 4) == expected_ev_costs
- ), f"EV costs should be €{expected_ev_costs}, got €{ev_costs}"
-
- assert (
- round(battery_costs, 4) == expected_battery_costs
- ), f"Battery costs should be €{expected_battery_costs}, got €{battery_costs}"
+ np.testing.assert_approx_equal(
+ total_cost,
+ expected_total_cost,
+ 4,
+ f"Total costs should be €{expected_total_cost}, got €{total_cost}",
+ )
+ np.testing.assert_approx_equal(
+ ev_costs,
+ expected_ev_costs,
+ 4,
+ f"EV costs should be €{expected_ev_costs}, got €{ev_costs}",
+ )
+ np.testing.assert_approx_equal(
+ battery_costs,
+ expected_battery_costs,
+ 4,
+ f"Battery costs should be €{expected_battery_costs}, got €{battery_costs}",
+ )
+ np.testing.assert_approx_equal(
+ job.meta["scheduler_info"]["commitment_costs"]["electricity net energy"],
+ expected_total_cost,
+ 4,
+ "Reported costs should match our expectation",
+ )
diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json
index 3f122566b7..e222eb3127 100644
--- a/flexmeasures/ui/static/openapi-specs.json
+++ b/flexmeasures/ui/static/openapi-specs.json
@@ -4556,79 +4556,118 @@
],
"additionalProperties": false
},
- "FlexContextOpenAPISchema": {
+ "CommodityFlexContext": {
"type": "object",
"properties": {
- "consumption-breach-price": {
- "description": "This penalty value is used to discourage the violation of the consumption-capacity constraint in the flex-model.\nIt effectively treats the capacity as a soft constraint, allowing the scheduler to exceed it when necessary but with a high cost.\nThe scheduler will attempt to minimize this cost.\nIt must use the same currency as the other price settings and cannot be negative.\n",
- "example": "10 EUR/kW",
- "$ref": "#/components/schemas/VariableQuantityOpenAPI"
+ "commodity": {
+ "type": "string",
+ "description": "Commodity to which this part of the flex-context applies.\nDefaults to ``\"electricity\"``.\n",
+ "examples": [
+ "electricity",
+ "gas"
+ ]
},
- "production-breach-price": {
- "description": "This penalty value is used to discourage the violation of the production-capacity constraint in the flex-model.\nIt effectively treats the capacity as a soft constraint, allowing the scheduler to exceed it when necessary but with a high cost.\nThe scheduler will attempt to minimize this cost.\nIt must use the same currency as the other price settings and cannot be negative.\n",
- "example": "10 EUR/kW",
- "$ref": "#/components/schemas/VariableQuantityOpenAPI"
+ "consumption-price": {
+ "description": "The commodity price (e.g. electricity price) applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem. [#old_consumption_price_field]_",
+ "examples": [
+ {
+ "sensor": 5
+ },
+ "0.29 EUR/kWh"
+ ]
},
- "soc-minima-breach-price": {
- "description": "This penalty value is used to discourage the violation of soc-minima constraints in the flex-model, which the scheduler will attempt to minimize.\nIt must use the same currency as the other price settings and cannot be negative.\nWhile it's an internal nudge to steer the scheduler\u2014and doesn't represent a real-life cost\u2014it should still be chosen in proportion to the actual energy prices at your site.\nIf it's too high, it will overly dominate other constraints; if it's too low, it will have no effect.\nWithout this value, the soc-minima become hard constraints, which means that any infeasible state-of-charge minima would prevent a complete schedule from being computed.\n",
- "example": "120 EUR/kWh",
- "$ref": "#/components/schemas/VariableQuantityOpenAPI"
+ "production-price": {
+ "description": "The commodity price (e.g. electricity price) applied to the site's aggregate production. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem, as long as the unit matches the ``consumption-price`` unit. [#old_production_price_field]_",
+ "example": "0.12 EUR/kWh"
},
- "soc-maxima-breach-price": {
- "description": "This penalty value is used to discourage the violation of soc-maxima constraints in the flex-model, which the scheduler will attempt to minimize.\nIt must use the same currency as the other price settings and cannot be negative.\nWhile it's an internal nudge to steer the scheduler\u2014and doesn't represent a real-life cost\u2014it should still be chosen in proportion to the actual energy prices at your site.\nIf it's too high, it will overly dominate other constraints; if it's too low, it will have no effect.\nWithout this value, the soc-maxima become hard constraints, which means that any infeasible state-of-charge maxima would prevent a complete schedule from being computed.\n",
- "example": "120 EUR/kWh",
- "$ref": "#/components/schemas/VariableQuantityOpenAPI"
+ "site-power-capacity": {
+ "description": "Maximum achievable power at the site's grid connection point, in either direction.\nBecomes a hard constraint in the optimization problem, which is especially suitable for physical limitations. [#asymmetric]_ [#minimum_capacity_overlap]_\n",
+ "example": "45kVA"
},
- "relax-constraints": {
- "type": "boolean",
- "default": false,
- "description": "If True (default is False), several constraints are relaxed by setting default breach prices within the optimization problem, leading to the default priority:\n\n1. Avoid breaching the site consumption/production capacity.\n2. Avoid not meeting SoC minima/maxima.\n3. Avoid breaching the desired device consumption/production capacity.\n\nWe recommend to set this field to True to enable the default prices and associated priorities as defined by FlexMeasures.\nFor tighter control over prices and priorities, the breach prices can also be set explicitly (the relevant fields have breach-price in their name).\n",
- "example": true
+ "site-consumption-capacity": {
+ "description": "Maximum consumption power at the site's grid connection point.\nIf ``site-power-capacity`` is defined, the minimum between the ``site-power-capacity`` and ``site-consumption-capacity`` will be used. [#consumption]_\nIf a ``site-consumption-breach-price`` is defined, the ``site-consumption-capacity`` becomes a soft constraint in the optimization problem.\nOtherwise, it becomes a hard constraint. [#minimum_capacity_overlap]_\n",
+ "example": "45kW"
},
- "relax-soc-constraints": {
- "type": "boolean",
- "default": false,
- "description": "If True, avoids not meeting SoC minima/maxima as a relaxed constraint.",
- "example": true
+ "site-production-capacity": {
+ "description": "Maximum production power at the site's grid connection point.\nIf ``site-power-capacity`` is defined, the minimum between the ``site-power-capacity`` and ``site-production-capacity`` will be used. [#production]_\nIf a ``site-production-breach-price`` is defined, the ``site-production-capacity`` becomes a soft constraint in the optimization problem.\nOtherwise, it becomes a hard constraint. [#minimum_capacity_overlap]_\n",
+ "example": "0kW"
},
- "relax-capacity-constraints": {
- "type": "boolean",
- "default": false,
- "description": "If True, avoids breaching the desired device consumption/production capacity as a relaxed constraint.",
- "example": true
+ "site-consumption-breach-price": {
+ "description": "This **penalty value** is used to discourage the violation of the ``site-consumption-capacity`` constraint in the flex-context.\nIt effectively treats the capacity as a **soft constraint**, allowing the scheduler to exceed it when necessary but with a high cost.\nThe scheduler will attempt to minimize this cost.\nIt must use the same currency as the other price settings and cannot be negative.\nThe field may define (a sensor recording) contractual penalties, or a theoretical penalty influencing how badly breaches should be avoided. [#penalty_field]_ [#breach_field]_\n",
+ "example": "1000 EUR/kW"
},
- "relax-site-capacity-constraints": {
- "type": "boolean",
- "default": false,
- "description": "If True, avoids breaching the site consumption/production capacity as a relaxed constraint.",
- "example": true
+ "site-production-breach-price": {
+ "description": "This **penalty value** is used to discourage the violation of the ``site-production-capacity`` constraint in the flex-context.\nIt effectively treats the capacity as a **soft constraint**, allowing the scheduler to exceed it when necessary but with a high cost.\nThe scheduler will attempt to minimize this cost.\nIt must use the same currency as the other price settings and cannot be negative.\nThe field may define (a sensor recording) contractual penalties, or a theoretical penalty influencing how badly breaches should be avoided. [#penalty_field]_ [#breach_field]_\"\n",
+ "example": "1000 EUR/kW"
},
- "site-power-capacity": {
- "description": "Maximum achievable power at the site's grid connection point, in either direction.\nBecomes a hard constraint in the optimization problem, which is especially suitable for physical limitations.\n",
- "example": "45kVA",
- "$ref": "#/components/schemas/VariableQuantityOpenAPI"
+ "site-peak-consumption": {
+ "default": "0.0 MW",
+ "description": "The site's previously achieved achieved peak consumption.\nThis value forms the baseline for new peak charges, since any peaks up to this level represent sunk costs.\nDefaults to 0 kW.\n",
+ "example": {
+ "sensor": 7
+ }
},
- "consumption-price-sensor": {
- "type": "integer"
+ "site-peak-consumption-price": {
+ "description": "Per-kW price applied to any consumption that exceeds the site's previously achieved peak consumption.\nThis price reflects the cost of increasing the site\u2019s peak further and is used by the scheduler to motivate peak shaving.\nIt must use the same currency as the other price settings and cannot be negative.\nFor large connections, this price is usually stated explicitly on the tariff sheets of their network operator. [#penalty_field]_\n",
+ "example": "260 EUR/MW"
},
- "production-price-sensor": {
- "type": "integer"
+ "site-peak-production": {
+ "default": "0.0 MW",
+ "description": "The site's previously achieved achieved peak production.\nThis value forms the baseline for new peak charges, since any peaks up to this level represent sunk costs.\nDefaults to 0 kW.\n",
+ "example": {
+ "sensor": 8
+ }
+ },
+ "site-peak-production-price": {
+ "description": "Per-kW price applied to any production that exceeds the site's previously achieved peak production.\nThis price reflects the cost of increasing the site\u2019s peak further and is used by the scheduler to motivate peak shaving.\nIt must use the same currency as the other price settings and cannot be negative.\nFor large connections, this price is usually stated explicitly on the tariff sheets of their network operator. [#penalty_field]_\n",
+ "example": "260 EUR/MW"
},
+ "commitments": {
+ "description": "Prior commitments. Support for this field in the UI is still under further development, but you can find more information in :ref:`commitments`.",
+ "example": [],
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Commitment"
+ }
+ },
+ "inflexible-device-sensors": {
+ "type": "array",
+ "description": "Power sensors representing devices that are relevant, but not flexible in the timing of their demand/supply.\nFor example, a sensor recording rooftop solar power that is connected behind the main meter, and whose production falls under the same contract as the flexible device(s) being scheduled.\nTheir power demand cannot be adjusted but still matters for finding the best schedule for other devices.\nMust be a list of integers.\n",
+ "example": [
+ 3,
+ 4
+ ],
+ "items": {
+ "type": "integer"
+ }
+ }
+ },
+ "required": [
+ "commodity"
+ ],
+ "additionalProperties": false
+ },
+ "FlexContextOpenAPISchema": {
+ "type": "object",
+ "properties": {
"consumption-price": {
- "description": "The electricity price applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem.",
- "example": {
- "sensor": 5
- },
+ "description": "The commodity price (e.g. electricity price) applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem.",
+ "examples": [
+ {
+ "sensor": 5
+ },
+ "0.29 EUR/kWh"
+ ],
"$ref": "#/components/schemas/VariableQuantityOpenAPI"
},
"production-price": {
- "description": "The electricity price applied to the site's aggregate production. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem, as long as the unit matches the consumption-price unit.",
+ "description": "The commodity price (e.g. electricity price) applied to the site's aggregate production. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem, as long as the unit matches the consumption-price unit.",
"example": "0.12 EUR/kWh",
"$ref": "#/components/schemas/VariableQuantityOpenAPI"
},
- "site-production-capacity": {
- "description": "Maximum production power at the site's grid connection point.\nIf site-power-capacity is defined, the minimum between the site-power-capacity and site-production-capacity will be used.\nIf a site-production-breach-price is defined, the site-production-capacity becomes a soft constraint in the optimization problem.\nOtherwise, it becomes a hard constraint.\n",
- "example": "0kW",
+ "site-power-capacity": {
+ "description": "Maximum achievable power at the site's grid connection point, in either direction.\nBecomes a hard constraint in the optimization problem, which is especially suitable for physical limitations.\n",
+ "example": "45kVA",
"$ref": "#/components/schemas/VariableQuantityOpenAPI"
},
"site-consumption-capacity": {
@@ -4636,6 +4675,11 @@
"example": "45kW",
"$ref": "#/components/schemas/VariableQuantityOpenAPI"
},
+ "site-production-capacity": {
+ "description": "Maximum production power at the site's grid connection point.\nIf site-power-capacity is defined, the minimum between the site-power-capacity and site-production-capacity will be used.\nIf a site-production-breach-price is defined, the site-production-capacity becomes a soft constraint in the optimization problem.\nOtherwise, it becomes a hard constraint.\n",
+ "example": "0kW",
+ "$ref": "#/components/schemas/VariableQuantityOpenAPI"
+ },
"site-consumption-breach-price": {
"description": "This penalty value is used to discourage the violation of the site-consumption-capacity constraint in the flex-context.\nIt effectively treats the capacity as a soft constraint, allowing the scheduler to exceed it when necessary but with a high cost.\nThe scheduler will attempt to minimize this cost.\nIt must use the same currency as the other price settings and cannot be negative.\nThe field may define (a sensor recording) contractual penalties, or a theoretical penalty influencing how badly breaches should be avoided.\n",
"example": "1000 EUR/kW",
@@ -4689,6 +4733,62 @@
"type": "integer"
}
},
+ "commodities": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/CommodityFlexContext"
+ }
+ },
+ "consumption-breach-price": {
+ "description": "This penalty value is used to discourage the violation of the consumption-capacity constraint in the flex-model.\nIt effectively treats the capacity as a soft constraint, allowing the scheduler to exceed it when necessary but with a high cost.\nThe scheduler will attempt to minimize this cost.\nIt must use the same currency as the other price settings and cannot be negative.\n",
+ "example": "10 EUR/kW",
+ "$ref": "#/components/schemas/VariableQuantityOpenAPI"
+ },
+ "production-breach-price": {
+ "description": "This penalty value is used to discourage the violation of the production-capacity constraint in the flex-model.\nIt effectively treats the capacity as a soft constraint, allowing the scheduler to exceed it when necessary but with a high cost.\nThe scheduler will attempt to minimize this cost.\nIt must use the same currency as the other price settings and cannot be negative.\n",
+ "example": "10 EUR/kW",
+ "$ref": "#/components/schemas/VariableQuantityOpenAPI"
+ },
+ "soc-minima-breach-price": {
+ "description": "This penalty value is used to discourage the violation of soc-minima constraints in the flex-model, which the scheduler will attempt to minimize.\nIt must use the same currency as the other price settings and cannot be negative.\nWhile it's an internal nudge to steer the scheduler\u2014and doesn't represent a real-life cost\u2014it should still be chosen in proportion to the actual energy prices at your site.\nIf it's too high, it will overly dominate other constraints; if it's too low, it will have no effect.\nWithout this value, the soc-minima become hard constraints, which means that any infeasible state-of-charge minima would prevent a complete schedule from being computed.\n",
+ "example": "120 EUR/kWh",
+ "$ref": "#/components/schemas/VariableQuantityOpenAPI"
+ },
+ "soc-maxima-breach-price": {
+ "description": "This penalty value is used to discourage the violation of soc-maxima constraints in the flex-model, which the scheduler will attempt to minimize.\nIt must use the same currency as the other price settings and cannot be negative.\nWhile it's an internal nudge to steer the scheduler\u2014and doesn't represent a real-life cost\u2014it should still be chosen in proportion to the actual energy prices at your site.\nIf it's too high, it will overly dominate other constraints; if it's too low, it will have no effect.\nWithout this value, the soc-maxima become hard constraints, which means that any infeasible state-of-charge maxima would prevent a complete schedule from being computed.\n",
+ "example": "120 EUR/kWh",
+ "$ref": "#/components/schemas/VariableQuantityOpenAPI"
+ },
+ "relax-constraints": {
+ "type": "boolean",
+ "default": false,
+ "description": "If True (default is False), several constraints are relaxed by setting default breach prices within the optimization problem, leading to the default priority:\n\n1. Avoid breaching the site consumption/production capacity.\n2. Avoid not meeting SoC minima/maxima.\n3. Avoid breaching the desired device consumption/production capacity.\n\nWe recommend to set this field to True to enable the default prices and associated priorities as defined by FlexMeasures.\nFor tighter control over prices and priorities, the breach prices can also be set explicitly (the relevant fields have breach-price in their name).\n",
+ "example": true
+ },
+ "relax-soc-constraints": {
+ "type": "boolean",
+ "default": false,
+ "description": "If True, avoids not meeting SoC minima/maxima as a relaxed constraint.",
+ "example": true
+ },
+ "relax-capacity-constraints": {
+ "type": "boolean",
+ "default": false,
+ "description": "If True, avoids breaching the desired device consumption/production capacity as a relaxed constraint.",
+ "example": true
+ },
+ "relax-site-capacity-constraints": {
+ "type": "boolean",
+ "default": false,
+ "description": "If True, avoids breaching the site consumption/production capacity as a relaxed constraint.",
+ "example": true
+ },
+ "consumption-price-sensor": {
+ "type": "integer"
+ },
+ "production-price-sensor": {
+ "type": "integer"
+ },
"aggregate-power": {
"description": "Sensor used to record the aggregate power schedule of all flexible and inflexible devices involved when scheduling this asset.",
"example": {
@@ -6175,12 +6275,12 @@
"$ref": "#/components/schemas/VariableQuantityOpenAPI"
},
"charging-efficiency": {
- "description": "One-way conversion efficiency from electricity to the storage's state of charge.\nCan be a percentage, a ratio in the range [0,1], or a coefficient of performance (>1).\nDefaults to 100% (no conversion loss).\n",
+ "description": "One-way conversion efficiency from the commodity (e.g. electricity) to the storage's state of charge.\nCan be a percentage, a ratio in the range [0,1], or a coefficient of performance (>1).\nDefaults to 100% (no conversion loss).\n",
"example": ".9",
"$ref": "#/components/schemas/VariableQuantityOpenAPI"
},
"discharging-efficiency": {
- "description": "One-way conversion efficiency from the storage's state of charge to electricity.\nDefaults to 100% (no conversion loss).",
+ "description": "One-way conversion efficiency from the storage's state of charge to the commodity (e.g. electricity).\nDefaults to 100% (no conversion loss).",
"example": "90%",
"$ref": "#/components/schemas/VariableQuantityOpenAPI"
},
@@ -6220,6 +6320,30 @@
],
"items": {}
},
+ "commodity": {
+ "type": "string",
+ "default": "electricity",
+ "description": "Commodity on which this device acts.\nDefaults to \"electricity\".\n",
+ "examples": [
+ "electricity",
+ "gas"
+ ]
+ },
+ "coupling": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null,
+ "description": "Name of the coupling group this device belongs to. Devices sharing the same coupling name are constrained to have proportionally related flows via a hard equality constraint. Use together with 'coupling-coefficient' to set the ratio.",
+ "example": "chp"
+ },
+ "coupling-coefficient": {
+ "type": "number",
+ "default": 1.0,
+ "description": "Positive coupling magnitude for this device within its coupling group. The optimizer introduces a decision variable 'alpha' per group per time step and constrains every device by P[d] == coeff * alpha. The sign of coeff is inferred internally from directional capacities: consumption-capacity = 0 implies output (negative), production-capacity = 0 implies input (positive). Example: a CHP with gas input (1.0), heat output (0.5) and power output (0.3). Defaults to 1.0.",
+ "example": 0.5
+ },
"sensor": {
"type": "integer",
"description": "ID of the device's power sensor."
diff --git a/flexmeasures/ui/tests/test_utils.py b/flexmeasures/ui/tests/test_utils.py
index 09a68ff005..4925ed44a2 100644
--- a/flexmeasures/ui/tests/test_utils.py
+++ b/flexmeasures/ui/tests/test_utils.py
@@ -79,6 +79,7 @@ def test_ui_flexcontext_schema():
"relax-site-capacity-constraints",
"consumption-price-sensor",
"production-price-sensor",
+ "commodities", # todo: https://github.com/FlexMeasures/flexmeasures/issues/2230
]
schema_keys = []
diff --git a/tests/documentation/test_schemas.py b/tests/documentation/test_schemas.py
index 0a85803103..ead6fb0a9e 100644
--- a/tests/documentation/test_schemas.py
+++ b/tests/documentation/test_schemas.py
@@ -10,6 +10,8 @@
# Metadata constants that intentionally do not appear in the documentation
EXCLUDED_METADATA = {
+ "COMMODITY_FLEX_CONTEXT", # appears as `commodity` in the flex-context listing in scheduling.rst
+ "COMMODITY_FLEX_MODEL", # appears as `commodity` in the flex-model listing in scheduling.rst
"RELAX_CAPACITY_CONSTRAINTS",
"RELAX_SITE_CAPACITY_CONSTRAINTS",
"RELAX_SOC_CONSTRAINTS",