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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ New features
* Let storage scheduling infer missing ``power-capacity`` from directional device capacities before falling back to site capacity, and default the missing opposite capacity to zero when only a non-zero ``consumption-capacity`` or ``production-capacity`` is configured [see `PR #2222 <https://www.github.com/FlexMeasures/flexmeasures/pull/2222>`_]
* Support multiple feeders to a shared storage [see `PR #2001 <https://www.github.com/FlexMeasures/flexmeasures/pull/2001>`_ ]
* The flex-context can now define multiple commodities, each specifying their own prices and grid capacities [see `PR #1946 <https://www.github.com/FlexMeasures/flexmeasures/pull/1946>`_, `PR #2172 <https://www.github.com/FlexMeasures/flexmeasures/pull/2172>`_, `PR #2235 <https://www.github.com/FlexMeasures/flexmeasures/pull/2235>`_ and `PR #2271 <https://www.github.com/FlexMeasures/flexmeasures/pull/2271>`_]
* Support commodity-converting devices, such as a CHP, e-boiler or heat pump, by describing each of the device's commodity ports as a flex-model entry sharing one ``coupling`` group with fixed flow ratios (``coupling-coefficient``) [see `PR #2218 <https://www.github.com/FlexMeasures/flexmeasures/pull/2218>`_]
* Commodities without energy prices in the flex-context (e.g. a heat or steam network without a grid connection) are scheduled as internal nodes whose devices must balance each other at every time step [see `PR #2279 <https://www.github.com/FlexMeasures/flexmeasures/pull/2279>`_]
* CLI support for adding/editing account attributes [see `PR #2242 <https://www.github.com/FlexMeasures/flexmeasures/pull/2242>`_]
* Extended ``GET /api/v3_0/jobs/<uuid>`` with a ``result`` field containing ``unresolved`` and ``resolved`` soft state-of-charge constraint analysis (``soc-minima``/``soc-maxima`` violations or satisfied constraints, keyed by asset ID) for scheduling jobs; both arrays are empty when no SoC constraints were defined [see `PR #2072 <https://www.github.com/FlexMeasures/flexmeasures/pull/2072>`_]

Expand Down
4 changes: 4 additions & 0 deletions documentation/features/scheduling.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ The ``flex-context`` is independent of the type of flexible device that is optim
With the flexibility context, we aim to describe the system in which the flexible assets operate, such as its physical and contractual limitations.
For multi-commodity scheduling problems, the flex-context can be defined separately per commodity (e.g. electricity and gas). See :ref:`tut_multi_commodity` for a hands-on example.

A commodity that defines no energy prices in the flex-context (e.g. a heat or steam network without a grid connection) is treated as an internal node:
its devices must balance each other at every time step, so everything produced into the node is consumed from it within the same time step.
Devices that convert between commodities (such as a CHP unit, gas boiler or electric heater) are described in the flex-model, one entry per commodity port, tied together by a ``coupling`` group.

Fields can have fixed values, but some fields can also point to sensors, so they will always represent the dynamics of the asset's environment (as long as that sensor has current data).
The full list of flex-context fields follows below.
For more details on the possible formats for field values, see :ref:`variable_quantities`.
Expand Down
39 changes: 39 additions & 0 deletions flexmeasures/data/models/planning/linear_optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def device_scheduler( # noqa C901
initial_stock: float | list[float] = 0,
stock_groups: dict[int, list[int]] | None = None,
coupling_groups: dict[str, list[tuple[int, float]]] | None = None,
balance_groups: dict[str, list[int]] | 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,
Expand Down Expand Up @@ -91,6 +92,15 @@ def device_scheduler( # noqa C901

coupling_groups={"chp": [(0, 1.0), (1, -0.5), (2, -0.3)]}

:param balance_groups: Flow-balance constraints for internal commodity nodes (e.g. a heat or steam network
without a grid connection). Each entry maps a node name to a list of device indices
whose stock-side flows must balance at every time step:
``sum_d(P_up[d, j] * eff_up[d, j] + P_down[d, j] / eff_down[d, j] + stock_delta[d, j]) == 0``.
In other words, everything produced into the node is consumed from it within the
same time step; the node itself stores nothing. To add storage to a node, include
a storage device in the group (its flow absorbs the imbalance and its stock is
bounded by its own device constraints).

Potentially deprecated arguments:
commitment_quantities: amounts of flow specified in commitments (both previously ordered and newly requested)
- e.g. in MW or boxes/h
Expand Down Expand Up @@ -172,6 +182,13 @@ def device_scheduler( # noqa C901
for d_idx, coeff in members:
coupling_device_specs.append((g_idx, d_idx, coeff))

# Collect the device lists of the balance groups (internal commodity nodes).
balance_group_specs: list[list[int]] = []
if balance_groups:
balance_group_specs = [
list(devices) for devices in balance_groups.values() if devices
]

# Move commitments from old structure to new
if commitments is None:
commitments = []
Expand Down Expand Up @@ -857,6 +874,28 @@ def flow_coupling_rule(m, c, j):
model.coupling_device_range, model.j, rule=flow_coupling_rule
)

if balance_group_specs:
model.balance_group_range = RangeSet(0, len(balance_group_specs) - 1)

def node_balance_rule(m, b, j):
"""Balance the power flows of an internal commodity node at every time step.

Everything produced into the node must be consumed from it within the same
time step. The balance sums the devices' commodity-side flows (ems_power);
derivative efficiencies and stock deltas describe each device's own
stock-side conversion (e.g. of a shared buffer) and do not enter the
commodity balance.
"""
return (
0,
sum(m.ems_power[d, j] for d in balance_group_specs[b]),
0,
)

model.node_balance_constraints = Constraint(
model.balance_group_range, model.j, rule=node_balance_rule
)

# Add objective
def cost_function(m):
costs = 0
Expand Down
26 changes: 23 additions & 3 deletions flexmeasures/data/models/planning/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,11 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901
# of each device model. Devices sharing the same coupling name form a group.
self.coupling_groups = self._build_coupling_groups(device_models)

# Balance groups for internal commodity nodes (commodities without energy
# prices, i.e. without a grid connection) are derived further below, once
# the devices of each commodity are enumerated.
self.balance_groups: dict[str, list[int]] = {}

# List the asset(s) and sensor(s) being scheduled
if self.asset is not None:
if not isinstance(self.flex_model, list):
Expand Down Expand Up @@ -409,9 +414,23 @@ def device_list_series(
production_price = consumption_price

if consumption_price is None:
raise ValueError(
f"Missing consumption price for commodity '{commodity}'."
)
if commodity == "electricity":
# Electricity is assumed to be grid-connected, so a missing
# price is treated as a configuration error rather than as
# an internal node.
raise ValueError(
f"Missing consumption price for commodity '{commodity}'."
)
# A non-electricity commodity without energy prices is treated as an
# internal node (e.g. a heat or steam network without a grid
# connection): its devices must balance each other at every time
# step, and it needs no commitments or EMS-level capacity constraints.
current_app.logger.info(
f"Commodity '{commodity}' has no energy prices; treating it as an "
f"internal node whose devices (indices {devices}) balance each other."
)
self.balance_groups[commodity] = list(devices)
continue

# Energy prices for this commodity.
up_deviation_prices = get_continuous_series_sensor_or_quantity(
Expand Down Expand Up @@ -2650,6 +2669,7 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType:
initial_stock=initial_stock,
stock_groups=self.stock_groups,
coupling_groups=self.coupling_groups if self.coupling_groups else None,
balance_groups=getattr(self, "balance_groups", None) or None,
)
if "infeasible" in (tc := scheduler_results.solver.termination_condition):
raise InfeasibleProblemException(tc)
Expand Down
52 changes: 39 additions & 13 deletions flexmeasures/data/models/planning/tests/test_commitments.py
Original file line number Diff line number Diff line change
Expand Up @@ -1833,9 +1833,15 @@ def _flow_df(**kwargs) -> pd.DataFrame:
def _run_factory_scenario(
gas_price: float,
elec_price: float,
use_balance_groups: bool = False,
) -> tuple:
"""Run the simplified factory scenario and return the 7 device schedules.

With ``use_balance_groups=False``, the heat and steam nodes are balanced via
shared stock groups whose first ("reference") device carries min=max=0 stock
bounds. With ``use_balance_groups=True``, the same nodes are expressed directly
as ``balance_groups``, needing neither stock groups nor reference-device bounds.

Devices
~~~~~~~
d=0 e-heater electricity → heat coupling (ems_power ≥ 0, i.e. consumes electricity)
Expand Down Expand Up @@ -1886,11 +1892,14 @@ def _df(**kwargs) -> pd.DataFrame:
defaults.update(kwargs)
return pd.DataFrame(defaults, index=index)

# With balance groups, no reference device needs min=max=0 stock bounds.
node_bounds = {} if use_balance_groups else {"min": 0.0, "max": 0.0}

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}),
_df(**node_bounds, **{"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).
Expand All @@ -1908,8 +1917,7 @@ def _df(**kwargs) -> pd.DataFrame:
# 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,
**node_bounds,
**{
"derivative min": -CHP_GAS_MAX * ETA_HEAT,
"derivative max": 0.0,
Expand All @@ -1933,11 +1941,18 @@ def _df(**kwargs) -> pd.DataFrame:
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]}
# Node membership: the steamer (d=2) converts heat to steam, so it belongs
# to both nodes (its single flow drains heat and feeds steam).
heat_node = [0, 1, 2]
steam_node = [2, 4, 6]
if use_balance_groups:
stock_groups = None
balance_groups = {"heat": heat_node, "steam": steam_node}
else:
# stock group: all heat-buffer devices share the same stock
# (keys 0 and 1 are arbitrary group ids, not device indices)
stock_groups = {0: heat_node, 1: steam_node}
balance_groups = None

# CHP coupling: coefficients are signed efficiency fractions.
# coeff_heat = -η_heat = -0.5 → P_heat = -0.5 * alpha = -0.5 * P_gas
Expand Down Expand Up @@ -1979,6 +1994,7 @@ def _df(**kwargs) -> pd.DataFrame:
commitments=commitments,
stock_groups=stock_groups,
coupling_groups=coupling_groups,
balance_groups=balance_groups,
)

assert results.solver.termination_condition == "optimal", (
Expand All @@ -1988,10 +2004,14 @@ def _df(**kwargs) -> pd.DataFrame:
return tuple(schedules)


def test_factory_chp_dispatch():
@pytest.mark.parametrize("use_balance_groups", [False, True])
def test_factory_chp_dispatch(use_balance_groups):
"""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
The heat and steam nodes are balanced either via shared stock groups with
a min=max=0 reference device (``use_balance_groups=False``) or via explicit
``balance_groups`` (``use_balance_groups=True``) — both must yield the same
dispatch. The steam node 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.

Expand Down Expand Up @@ -2042,7 +2062,9 @@ def test_factory_chp_dispatch():
# 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)
_run_factory_scenario(
gas_price=20.0, elec_price=50.0, use_balance_groups=use_balance_groups
)
)

expected_chp_gas = pd.Series(20.0, index=e_heater.index)
Expand Down Expand Up @@ -2107,7 +2129,9 @@ def test_factory_chp_dispatch():
# 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)
_run_factory_scenario(
gas_price=100.0, elec_price=10.0, use_balance_groups=use_balance_groups
)
)

expected_eheater_b = pd.Series(15.0, index=e_heater.index)
Expand Down Expand Up @@ -2155,7 +2179,9 @@ def test_factory_chp_dispatch():
# 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)
_run_factory_scenario(
gas_price=50.0, elec_price=55.0, use_balance_groups=use_balance_groups
)
)

expected_chp_gas = pd.Series(0.0, index=e_heater.index)
Expand Down
Loading
Loading