Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
9dd779a
docs: new section describing commitments
Flix6x Dec 4, 2025
13e9308
docs: rewrite the overview in commitments section
Flix6x Dec 4, 2025
1179198
docs: keep ems variables, but explain them as representing the site c…
Flix6x Dec 4, 2025
1b10360
docs: cross-reference the commitments section and the linear problem …
Flix6x Dec 4, 2025
0d25b25
Merge remote-tracking branch 'refs/remotes/origin/main' into docs/com…
Flix6x Dec 8, 2025
6f6e52b
fix: cross-references
Flix6x Dec 8, 2025
3599379
docs: add commitments section to index
Flix6x Dec 8, 2025
ce1533d
Merge remote-tracking branch 'refs/remotes/origin/main' into docs/com…
Flix6x Jan 23, 2026
84fbd5a
feat: add a util function for printing out commitments in a tabulated…
Ahmad-Wahid Jan 23, 2026
90177bf
refactor: move pretty printing method to class
Flix6x Jan 23, 2026
9f34676
feat: Commitment supports device groups
Flix6x Jan 23, 2026
43107c8
feat: start testing device grouping
Flix6x Jan 23, 2026
b90d7f0
dev: test multi-feed
Flix6x Jan 23, 2026
4179981
update the ids of devices to be integers
Ahmad-Wahid Jan 26, 2026
8b108ed
feat: function that group commitment quantities
Ahmad-Wahid Jan 26, 2026
485349e
add commitments for multi group
Ahmad-Wahid Jan 26, 2026
81bb9ec
fix: get unique list of devices for a frame column
Ahmad-Wahid Jan 27, 2026
334e4d3
fix: create util functions that extract devices for a list of values …
Ahmad-Wahid Jan 27, 2026
f738d9f
fix: create a series for a list of grouped devices
Ahmad-Wahid Jan 27, 2026
620c6dc
drop outdated comments
Ahmad-Wahid Jan 31, 2026
40eb747
use commitment costs and add asserts for electricity and gas
Ahmad-Wahid Jan 31, 2026
708d869
and an assert for commodity costs
Ahmad-Wahid Jan 31, 2026
ce307f8
add an extra assert on costs
Ahmad-Wahid Jan 31, 2026
ee33fa2
Merge remote-tracking branch 'origin/main' into feat/switching-betwee…
Flix6x Feb 10, 2026
82f807e
fix: wrong timezone; the test relied on the preference to charge soon…
Flix6x Mar 13, 2026
128550f
feat: move preference to charge sooner and discharge later into a Sto…
Flix6x Mar 13, 2026
130b9dd
fix: test case no longer relies on arbitrage opportunity coming from …
Flix6x Mar 13, 2026
1eab828
feat: check for optimal schedule
Flix6x Mar 13, 2026
b9bc4a9
feat: prefer a full storage earlier over later
Flix6x Mar 13, 2026
57df5c3
docs: update commitment name and inline comments
Flix6x Mar 13, 2026
ce71637
docs: touch up test explanation
Flix6x Mar 14, 2026
f6183df
fix: update test case given preference for a full battery
Flix6x Mar 14, 2026
ed471a8
delete: clean up comment
Flix6x Mar 14, 2026
7611fb9
feat: model the preference to curtail later within the same StockComm…
Flix6x Mar 14, 2026
bf16e63
fix: reduce tiny price slope
Flix6x Mar 14, 2026
06c30dc
docs: delete duplicate changelog entry
Flix6x Mar 16, 2026
0125e28
Merge remote-tracking branch 'origin/main' into feat/full-soc-preference
Flix6x Mar 18, 2026
d99089b
docs: fix broken link
Flix6x Mar 18, 2026
cf01f1d
Revert "fix: reduce tiny price slope"
Flix6x Mar 18, 2026
bdbdead
fix: soc unit conversion
Flix6x Mar 18, 2026
f987706
fix: adapt test to check for 1 hour of free energy at 15-min scheduli…
Flix6x Mar 18, 2026
12cf595
Merge remote-tracking branch 'origin/main' into feat/switching-betwee…
Flix6x Mar 19, 2026
534179a
style: black
Flix6x Mar 19, 2026
05aed7e
fix: check curtailment preference per distinct device
Flix6x Mar 20, 2026
e55f638
fix: set tight tolerance for HiGHS solver
Flix6x Mar 20, 2026
764712f
refactor: merge if-blocks
Flix6x Mar 20, 2026
9070eae
fix: use iloc
Flix6x Mar 23, 2026
857e9c1
fix: diminish tiny price slope by number of planning steps
Flix6x Mar 23, 2026
54c9c36
refactor: always diminish tiny price slope by number of planning step…
Flix6x Mar 23, 2026
158c7a0
chore: increment StorageScheduler version
Flix6x Mar 23, 2026
261ed3a
Merge branch 'feat/full-soc-preference' into feat/switching-between-g…
Flix6x Mar 23, 2026
66e9a8b
Merge remote-tracking branch 'origin/main' into feat/switching-betwee…
Flix6x Mar 23, 2026
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
114 changes: 113 additions & 1 deletion flexmeasures/data/models/planning/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from __future__ import annotations

from collections.abc import Iterable
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from tabulate import tabulate
from typing import Any, Type

import pandas as pd
Expand Down Expand Up @@ -279,6 +281,7 @@ class Commitment:

name: str
device: pd.Series = None
device_group: pd.Series = None
index: pd.DatetimeIndex = field(repr=False, default=None)
_type: str = field(repr=False, default="each")
group: pd.Series = field(init=False)
Expand Down Expand Up @@ -361,10 +364,67 @@ def __post_init__(self):
"downwards deviation price"
)
self.group = self.group.rename("group")
self._init_device_group()

def _init_device_group(self):
# EMS-level commitment
if self.device is None:
self.device_group = pd.Series({"EMS": 0}, name="device_group")
return

# Extract device universe
if isinstance(self.device, pd.Series):
devices = extract_devices(self.device)
else:
devices = [self.device]

devices = list(devices)

# Default: one group per device (backwards compatible)
if self.device_group is None:
self.device_group = pd.Series(
range(len(devices)), index=devices, name="device_group"
)
else:
# Validate custom grouping
missing = set(devices) - set(self.device_group.index)
if missing:
raise ValueError(
f"device_group missing assignments for devices: {missing}"
)
self.device_group = self.device_group.loc[devices]
self.device_group.name = "device_group"

def pretty_print(self):
"""
Pretty-print a list of FlowCommitment objects as tabulated pandas DataFrames.

For each FlowCommitment, a DataFrame indexed by time is created containing
the commitment name, device values, group index, quantity, and any available
upward or downward deviation prices. Each commitment is printed separately
in a readable table format, making this function suitable for debugging,
logging, and interactive inspection.
"""
df = self.to_frame()
df = pd.DataFrame(index=df.device.index)

df["commitment"] = self.name
df["device"] = self.device
df["group"] = self.group
df["quantity"] = self.quantity

if hasattr(self, "upwards_deviation_price"):
df["up_price"] = self.upwards_deviation_price

if hasattr(self, "downwards_deviation_price"):
df["down_price"] = self.downwards_deviation_price

if not df.empty:
print(tabulate(df, headers=df.columns, tablefmt="fancy_grid"))

def to_frame(self) -> pd.DataFrame:
"""Contains all info apart from the name."""
return pd.concat(
df = pd.concat(
[
self.device,
self.quantity,
Expand All @@ -375,6 +435,13 @@ def to_frame(self) -> pd.DataFrame:
],
axis=1,
)
# map device → device_group
if self.device is not None:
df["device_group"] = map_device_to_group(self.device, self.device_group)
else:
df["device_group"] = 0

return df


class FlowCommitment(Commitment):
Expand All @@ -396,3 +463,48 @@ class StockCommitment(Commitment):
Scheduler.compute_schedule = deprecated(Scheduler.compute, "0.14")(
Scheduler.compute_schedule
)


def extract_devices(device):
"""
Return a flat list of unique device identifiers from:
- scalar device
- Series of scalars
- Series of iterables (e.g. [0, 1])
"""
if device is None:
return []

if isinstance(device, pd.Series):
values = device.dropna().values
else:
values = [device]

devices = set()
for v in values:
if isinstance(v, Iterable) and not isinstance(v, (str, bytes)):
devices.update(v)
else:
devices.add(v)

return list(devices)


def map_device_to_group(device_series, device_group_map):
"""
Map device identifiers to device_group.

- scalar device → group label
- iterable of devices → group label (must be identical)
"""

def resolve(v):
if isinstance(v, (list, tuple, set)):
groups = {device_group_map[d] for d in v}
if len(groups) != 1:
raise ValueError(f"Devices {v} map to multiple device groups: {groups}")
return groups.pop()
else:
return device_group_map[v]

return device_series.apply(resolve)
73 changes: 69 additions & 4 deletions flexmeasures/data/models/planning/linear_optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,27 @@ def convert_commitments_to_subcommitments(

commitments, commitment_mapping = convert_commitments_to_subcommitments(commitments)

device_group_lookup = {}

for c, df in enumerate(commitments):
if "device_group" not in df.columns or "device" not in df.columns:
continue

rows = df[["device", "device_group"]].dropna()

device_group_lookup[c] = {}

for _, row in rows.iterrows():
g = row["device_group"]
d = row["device"]

if isinstance(d, (list, tuple, set, np.ndarray)):
devices = set(d)
else:
devices = {d}

device_group_lookup[c].setdefault(g, set()).update(devices)

# Oversimplified check for a convex cost curve
df = pd.concat(commitments)[
["upwards deviation price", "downwards deviation price"]
Expand Down Expand Up @@ -243,13 +264,21 @@ def convert_commitments_to_subcommitments(
)
model.c = RangeSet(0, len(commitments) - 1, doc="Set of commitments")

# Add 2D indices for commitment datetimes (cj)
def commitment_device_groups_init(m):
return ((c, g) for c, groups in device_group_lookup.items() for g in groups)

model.cg = Set(dimen=2, initialize=commitment_device_groups_init)

def commitments_init(m):
return ((c, j) for c in m.c for j in commitments[c]["j"])

model.cj = Set(dimen=2, initialize=commitments_init)

def commitment_time_device_groups_init(m):
return ((c, j, g) for (c, j) in m.cj for (_, g) in m.cg if _ == c)

model.cjg = Set(dimen=3, initialize=commitment_time_device_groups_init)

# Add parameters
def price_down_select(m, c):
if "downwards deviation price" not in commitments[c].columns:
Expand Down Expand Up @@ -362,6 +391,40 @@ def device_derivative_up_efficiency(m, d, j):
def device_stock_delta(m, d, j):
return device_constraints[d]["stock delta"].iloc[j]

def grouped_commitment_equalities(m, c, j, g):
"""
Enforce a commitment deviation constraint on the aggregate of devices in a group.

For commitment ``c`` at time index ``j``, this constraint couples the commitment
baseline (plus deviation variables) to the summed flow or stock of all devices
belonging to device group ``g``. StockCommitments aggregate device stocks, while
FlowCommitments aggregate device flows. Constraints are skipped if the commitment
is inactive at ``(c, j)`` or if the group contains no devices.
"""
if m.commitment_quantity[c, j] == -infinity:
return Constraint.Skip

devices_in_group = device_group_lookup.get(c, {}).get(g, set())
if not devices_in_group:
return Constraint.Skip

center = (
m.commitment_quantity[c, j]
+ m.commitment_downwards_deviation[c]
+ m.commitment_upwards_deviation[c]
)

if commitments[c]["class"].apply(lambda cl: cl == StockCommitment).all():
center -= sum(_get_stock_change(m, d, j) for d in devices_in_group)
else:
center -= sum(m.ems_power[d, j] for d in devices_in_group)

return (
0 if "upwards deviation price" in commitments[c].columns else None,
center,
0 if "downwards deviation price" in commitments[c].columns else None,
)

model.up_price = Param(model.c, initialize=price_up_select)
model.down_price = Param(model.c, initialize=price_down_select)
model.commitment_quantity = Param(
Expand Down Expand Up @@ -565,6 +628,10 @@ def device_derivative_equalities(m, d, j):
0,
)

model.grouped_commitment_equalities = Constraint(
model.cjg, rule=grouped_commitment_equalities
)

model.device_energy_bounds = Constraint(model.d, model.j, rule=device_bounds)
model.device_power_bounds = Constraint(
model.d, model.j, rule=device_derivative_bounds
Expand Down Expand Up @@ -592,9 +659,7 @@ def device_derivative_equalities(m, d, j):
model.ems_power_commitment_equalities = Constraint(
model.cj, rule=ems_flow_commitment_equalities
)
model.device_energy_commitment_equalities = Constraint(
model.cj, model.d, rule=device_stock_commitment_equalities
)

model.device_power_equalities = Constraint(
model.d, model.j, rule=device_derivative_equalities
)
Expand Down
Loading
Loading