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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ New features
* Support multiple feeders to a shared storage [see `PR #2001 <https://www.github.com/FlexMeasures/flexmeasures/pull/2001>`_, `PR #2321 <https://www.github.com/FlexMeasures/flexmeasures/pull/2321>`_, `PR #2322 <https://www.github.com/FlexMeasures/flexmeasures/pull/2322>`_ and `PR #2325 <https://www.github.com/FlexMeasures/flexmeasures/pull/2325>`_]
* 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>`_]
* Support unit-commitment of a ``coupling`` group, so a commodity-converting device (such as a cogeneration unit) can be modelled with an on/off binary, a minimum level when on (``coupling-min`` on the reference port), and a per-port no-load base gated by that binary (``coupling-base``), making the affine ``P = coefficient · level + base`` relation exact including no-load fuel [see `PR #2336 <https://www.github.com/FlexMeasures/flexmeasures/pull/2336>`_]
* Commodity contexts that omit grid-connection fields (prices and site capacities) now get smart defaults instead of failing or silently leaving the grid unconstrained — for instance, a bare ``{"commodity": "gas"}`` is treated as having no grid connection; see :ref:`commodity_context_defaults` for the full rules [see `PR #2272 <https://www.github.com/FlexMeasures/flexmeasures/pull/2272>`_]
* CLI support for adding/editing account attributes [see `PR #2242 <https://www.github.com/FlexMeasures/flexmeasures/pull/2242>`_]
* Improve chart axis domain for event values not around zero, with a per-sub-chart ``y-axis`` option in ``sensors_to_show`` (default ``zero``, which pads the axis out to include zero) that can be set to ``data`` to fit a sub-chart's y-axis to the values shown, to an explicit ``[min, max]`` domain that the axis will cover at least (expanding to fit data beyond it), or to a strict ``{"min": min, "max": max}`` domain that the axis will never exceed (clamping data beyond it, with a warning when that happens), editable from the graph editor [see `PR #2244 <https://www.github.com/FlexMeasures/flexmeasures/pull/2244>`_]
Expand Down
120 changes: 119 additions & 1 deletion flexmeasures/data/models/planning/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ class FlexDevice:
#: Signed internal coupling coefficient: positive for input (consuming) ports, negative for output (producing) ports.
#: Meaningless (1.0) for uncoupled devices.
coupling_coefficient: float = 1.0
#: Signed per-port no-load base (in MW), gated by the group's on/off binary when the group is unit-committed.
#: Follows the same sign convention as the coefficient; 0.0 when no ``coupling-base`` is given.
coupling_base: float = 0.0
#: Group minimum marginal level (in MW), declared on the reference port (|coefficient| == 1). None when not set.
coupling_min: float | None = None
#: The reference port's power capacity (in MW), used as the group's maximum marginal level. None when not resolvable.
coupling_max: float | None = None

@property
def sensor_id(self) -> int | None:
Expand Down Expand Up @@ -208,6 +215,39 @@ def _resolve_coupling_coefficient(flex_model: dict) -> float:
return coefficient


def _quantity_to_mw(value: Any) -> float | None:
"""Convert a fixed power quantity to a magnitude in MW; None if it is not a fixed scalar quantity.

Sensor references and time series (used for time-varying capacities) return None:
a unit-committed coupling group needs scalar bounds, so those are handled by the caller.
"""
if value is None:
return None
if hasattr(value, "to") and hasattr(value, "magnitude"):
try:
return float(value.to("MW").magnitude)
except Exception:
return None
try:
return float(value)
except (TypeError, ValueError):
return None


def _resolve_coupling_base(base_value: Any, coefficient: float) -> float:
"""Resolve a coupled port's signed no-load base (in MW) from its raw ``coupling-base`` value.

The ``coupling-base`` field is a user-facing positive power magnitude; its internal
sign follows the port's flow direction, i.e. the sign of the port's coupling
coefficient (mirroring :func:`_resolve_coupling_coefficient`). Returns 0.0 when no
base is given (proportional, non-unit-committed behaviour).
"""
magnitude = _quantity_to_mw(base_value)
if magnitude is None:
return 0.0
return math.copysign(abs(magnitude), coefficient)


def _ref_id(value: Any) -> int | None:
"""Return the id of a sensor/asset reference, which may be a model object or a raw id."""
if value is None:
Expand Down Expand Up @@ -394,6 +434,8 @@ def register_stock_params(stock_key: int, fm: dict) -> None:
inventory.stock_entries[stock_key] = fm

for fm in flex_model_list:
# Each flex-model entry is a deserialized dict.
assert isinstance(fm, dict)
# Group entry (multi-device mode only): this entry's own sensor/asset is
# the aggregate sensor/asset referenced by another entry's "group" field.
if _classify_group_entry(inventory, fm):
Expand Down Expand Up @@ -453,7 +495,14 @@ def register_stock_params(stock_key: int, fm: dict) -> None:
commodity=fm.get("commodity", "electricity"),
stock_key=stock_key,
coupling=fm.get("coupling"),
coupling_coefficient=_resolve_coupling_coefficient(fm),
coupling_coefficient=(
coupling_coefficient := _resolve_coupling_coefficient(fm)
),
coupling_base=_resolve_coupling_base(
fm.get("coupling_base"), coupling_coefficient
),
coupling_min=_quantity_to_mw(fm.get("coupling_min")),
coupling_max=_quantity_to_mw(fm.get("power_capacity_in_mw")),
)
inventory.entries.append(device)
inventory.devices.append(device)
Expand Down Expand Up @@ -562,6 +611,75 @@ def coupling_groups(self) -> dict[str, list[tuple[int, float]]]:
)
return groups

def _coupling_reference_port(self, members: list[tuple[int, float]]) -> FlexDevice:
"""Return a coupling group's reference port: the port with |coefficient| == 1.

The reference port is the driving variable of the group; it carries the group's
``coupling-min`` and the ``power-capacity`` that bounds the group's marginal level.

:raises ValueError: When no member has a unit coefficient.
"""
for d_idx, coeff in members:
if math.isclose(abs(coeff), 1.0, abs_tol=1e-9):
return self.devices[d_idx]
raise ValueError(
"A unit-committed coupling group must have a reference port with"
" coupling-coefficient 1 (the driving variable). None was found."
)

@cached_property
def coupling_uc(self) -> dict[str, tuple[float, float]]:
"""Map each unit-committed coupling group to its ``(min, max)`` marginal-level bounds (in MW).

A coupling group is unit-committed when its reference port declares a
``coupling-min``, or any of its ports declares a non-zero ``coupling-base``.
The minimum comes from the reference port's ``coupling-min`` (0 when only a base
is given); the maximum from the reference port's ``power-capacity``.
Empty for purely proportional coupling (leaving the problem an LP).

:raises ValueError: When a unit-committed group lacks a resolvable power-capacity
on its reference port.
"""
result: dict[str, tuple[float, float]] = {}
for name, members in self.coupling_groups.items():
group_devices = [self.devices[d_idx] for d_idx, _ in members]
declared_mins = [
dev.coupling_min
for dev in group_devices
if dev.coupling_min is not None
]
has_base = any(abs(dev.coupling_base) > 1e-12 for dev in group_devices)
if not declared_mins and not has_base:
continue
reference = self._coupling_reference_port(members)
min_level = declared_mins[0] if declared_mins else 0.0
max_level = reference.coupling_max
if max_level is None:
raise ValueError(
f"Unit-committed coupling group '{name}' needs a fixed power-capacity"
" on its reference port (the port with coupling-coefficient 1) to bound"
" its marginal level."
)
result[name] = (min_level, max_level)
return result

@cached_property
def coupling_bases(self) -> dict[str, list[tuple[int, float]]]:
"""Map each unit-committed coupling group to its ports' (device index, signed base) pairs (in MW).

Restricted to the groups in :attr:`coupling_uc`; suitable for passing to
``device_scheduler(coupling_bases=...)`` alongside ``coupling_groups``.
"""
uc_groups = self.coupling_uc
result: dict[str, list[tuple[int, float]]] = {}
for name, members in self.coupling_groups.items():
if name not in uc_groups:
continue
result[name] = [
(d_idx, self.devices[d_idx].coupling_base) for d_idx, _ in members
]
return result

@cached_property
def group_to_devices(self) -> dict[tuple[str, int], list[int]]:
"""Map each group key to the indices of the (leaf) member devices of that group.
Expand Down
70 changes: 66 additions & 4 deletions flexmeasures/data/models/planning/linear_optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ 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,
coupling_uc: dict[str, tuple[float, float]] | None = None,
coupling_bases: 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,
Expand Down Expand Up @@ -92,6 +94,16 @@ def device_scheduler( # noqa C901

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

:param coupling_uc: Optional unit-commitment bounds per coupling group, marking that group unit-committed.
Each entry maps a group name to a ``(min, max)`` pair for the group's marginal level ``alpha``.
A per-time-step binary ``u`` is introduced and ``min * u <= alpha[group, j] <= max * u``,
so ``alpha`` is zero when the group is off and at least ``min`` when on.
:param coupling_bases: Optional per-port no-load base per coupling group. Each entry maps a group name to a list of
``(device_index, base)`` tuples. For a unit-committed group, every port ``d`` is then
constrained by ``P[d, j] == coeff_d * alpha[group, j] + base_d * u[group, j]``, so the
(signed) no-load base is gated by the on/off binary (e.g. a cogeneration unit's no-load fuel).
Ignored for groups not present in ``coupling_uc``.

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 @@ -807,6 +819,9 @@ def device_derivative_equalities(m, d, j):
)

if coupling_device_specs:
# coupling_device_specs is only populated when coupling_groups is given.
assert coupling_groups is not None
group_names = list(coupling_groups.keys())
n_coupling_groups = len(coupling_groups)

# One free variable per group per time step: the common normalised flow.
Expand All @@ -815,14 +830,61 @@ def device_derivative_equalities(m, d, j):

model.coupling_device_range = RangeSet(0, len(coupling_device_specs) - 1)

# Unit-commitment: a group listed in ``coupling_uc`` gains a per-time-step
# on/off binary. Its marginal level ``alpha`` is then gated by that binary
# (min * u <= alpha <= max * u), and every port picks up a no-load base
# (base * u) on top of the proportional term. Groups without UC keep their
# purely proportional behaviour (the model stays an LP unless some group is
# unit-committed).
coupling_uc = coupling_uc or {}
coupling_bases = coupling_bases or {}
uc_bounds_by_gidx: dict[int, tuple[float, float]] = {}
base_by_group_device: dict[tuple[int, int], float] = {}
for g_idx, name in enumerate(group_names):
if name in coupling_uc:
uc_bounds_by_gidx[g_idx] = coupling_uc[name]
for d_idx, base in coupling_bases.get(name, []):
base_by_group_device[(g_idx, d_idx)] = base

if uc_bounds_by_gidx:
model.coupling_uc_range = Set(initialize=sorted(uc_bounds_by_gidx))
model.coupling_on = Var(
model.coupling_uc_range, model.j, domain=Binary, initialize=0
)

def coupling_alpha_lower_rule(m, g, j):
"""alpha is at least the group minimum when the group is on, else zero."""
min_level, _ = uc_bounds_by_gidx[g]
return m.coupling_alpha[g, j] >= min_level * m.coupling_on[g, j]

def coupling_alpha_upper_rule(m, g, j):
"""alpha is at most the group maximum when on, and forced to zero when off."""
_, max_level = uc_bounds_by_gidx[g]
return m.coupling_alpha[g, j] <= max_level * m.coupling_on[g, j]

model.coupling_alpha_lower_bounds = Constraint(
model.coupling_uc_range, model.j, rule=coupling_alpha_lower_rule
)
model.coupling_alpha_upper_bounds = Constraint(
model.coupling_uc_range, model.j, rule=coupling_alpha_upper_rule
)

def flow_coupling_rule(m, c, j):
"""Enforce P[d, j] == coeff * alpha[group, j] for each coupled device.
"""Enforce the (affine) coupling of each port to its group's level.

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).
For a purely proportional group: ``P[d, j] == coeff * alpha[group, j]``.
For a unit-committed group: ``P[d, j] == coeff * alpha[group, j] + base * u[group, j]``,
so the (signed) no-load base is gated by the on/off binary.
The coefficient sign indicates direction: positive for inputs (consuming),
negative for outputs (producing); the base follows the same sign convention.
"""
g, d, coeff = coupling_device_specs[c]
if g in uc_bounds_by_gidx:
base = base_by_group_device.get((g, d), 0.0)
return (
m.ems_power[d, j]
== coeff * m.coupling_alpha[g, j] + base * m.coupling_on[g, j]
)
return m.ems_power[d, j] == coeff * m.coupling_alpha[g, j]

model.flow_coupling_constraints = Constraint(
Expand Down
7 changes: 7 additions & 0 deletions flexmeasures/data/models/planning/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,11 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901
# The coupling groups (converter ports sharing a coupling name) also derive from the inventory,
# with signed coefficients per canonical device index.
self.coupling_groups = inventory.coupling_groups
# Unit-commitment of a coupling group: per-group (min, max) marginal-level bounds
# and per-port no-load bases, gated by a per-time-step on/off binary. Empty for
# purely proportional coupling (keeping the problem an LP).
self.coupling_uc = inventory.coupling_uc
self.coupling_bases = inventory.coupling_bases

# Group entries (intermediate power constraints on groups of devices, e.g. a
# sub-EMS) come classified from the inventory, together with the resolved
Expand Down Expand Up @@ -3177,6 +3182,8 @@ 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,
coupling_uc=self.coupling_uc if self.coupling_uc else None,
coupling_bases=self.coupling_bases if self.coupling_bases else None,
)
if "infeasible" in (tc := scheduler_results.solver.termination_condition):
raise InfeasibleProblemException(tc)
Expand Down
Loading
Loading