Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
67 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
1ec0b2c
Merge remote-tracking branch 'origin/main' into feat/switching-betwee…
Flix6x Apr 24, 2026
49df3a6
feat: rewrite test to not rely on multi-commodity
Flix6x Apr 24, 2026
a518437
feat: rewrite test to prove that only when both HPs share a single co…
Flix6x Apr 24, 2026
4258636
fix: do not coerce device_group into a time series
Flix6x Apr 24, 2026
b4f8b2b
docs: changelog entry
Flix6x Apr 24, 2026
6e3a5a4
docs: pretty_print is not specifically for FlowCommitments
Flix6x Apr 24, 2026
7eb1319
docs: add (back) inline dev notes
Flix6x Apr 24, 2026
a298dc7
feat: strengthen asserts
Flix6x Apr 24, 2026
7f15c13
feat: add check for exact electricity costs expected
Flix6x Apr 24, 2026
ffd0d86
fix: sum over electricity costs; and move to StockCommitment to model…
Flix6x Apr 24, 2026
a33790a
fix: replace vague assert with explicit asserts
Flix6x Apr 24, 2026
6b6a474
docs: lose confusing comment
Flix6x Apr 24, 2026
70e1af6
fix: backwards compatibility in case no device is specified;
Flix6x Apr 24, 2026
792d2df
fix: when device is present but device_group is absent, fall back to …
Flix6x Apr 25, 2026
a9174a5
dev: update expectations of how costs are shared between EV and battery
Flix6x Apr 26, 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
179 changes: 179 additions & 0 deletions documentation/concepts/commitments.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
.. _commitments:

Commitments
===========

Overview
--------

A **Commitment** is the economic abstraction FlexMeasures uses to express
market positions and soft constraints (preferences) inside the scheduler.
Commitments are converted to linear objective terms; all non-negotiable
operational limits are modelled separately as Pyomo constraints.

A commitment describes:

- a **baseline quantity** over time (the contracted or preferred position), and
- marginal prices for **upwards** and **downwards deviations** from that baseline.

The scheduler converts all provided commitments into terms in the optimization
objective function so that the solver *minimizes the total deviation cost*
across the schedule horizon. Absolute physical limitations (for example generator or
line capacities) are *not* modelled as commitments — those are enforced as
Pyomo constraints.

Key properties
--------------

Each Commitment has the following important attributes (high level):

- ``name`` — a logical string identifier (e.g. ``"energy"``, ``"production peak"``).
- ``device`` — optional: restricts the commitment to a single device; otherwise
it is an EMS/site-level commitment.
- ``index`` — the DatetimeIndex (time grid) on which the series are defined.
- ``quantity`` — the baseline Series (per slot or per group).
- ``upwards_deviation_price`` — Series defining marginal cost/reward for upward deviations.
- ``downwards_deviation_price`` — Series defining marginal cost/reward for downward deviations.
- ``_type`` — grouping indicator: ``'each'`` or ``'any'`` (see Grouping below).

Sign convention (flows vs stocks)
--------------------------------

- **Flow commitments** (e.g. power/energy flows):

- A *positive* baseline quantity denotes **consumption**.

- Actual > baseline → *upwards* deviation (more consumption).
- Actual < baseline → *downwards* deviation (less consumption).
- A *negative* baseline quantity denotes **production** (feed-in).

- Actual less negative (i.e. closer to zero) → *upwards* deviation (less production).
- Actual more negative → *downwards* deviation (more production).

- **Stock commitments** (e.g. state of charge for storage):

- ``quantity`` is the target stock level; deviations above/below that target
are priced via the upwards/downwards price series.

Soft vs hard semantics
----------------------

Commitments in FlexMeasures are **soft** by design: they represent economic
penalties or rewards that the optimizer considers when building schedules.
Hard operational constraints (such as physical power limits or strict device
interlocks) are expressed separately as Pyomo constraints in the scheduling
model. If a “hard” behaviour is required from a commitment, assign very large
penalty prices, but prefer modelling non-negotiable limits as Pyomo constraints.

Converting flex-context fields into commitments
-----------------------------------------------

Users may supply preferences and price fields in the ``flex-context``. The
scheduler translates the relevant fields into one or more `Commitment` objects
before calling the optimizer.

Typical translations include:

- tariffs (``consumption-price``, ``production-price``) → an ``"energy"`` FlowCommitment with zero baseline so net consumption/production is priced;
- peak/excess limits (``site-peak-production``, ``site-peak-production-price``, etc.) → dedicated peak FlowCommitment(s);
- storage-related fields (``soc-minima``, ``soc-minima-breach-price``, etc.) → StockCommitment(s).

A short example
---------------

Below is a compact example showing how the scheduler conceptually creates an
``"energy"`` flow commitment from a (per-slot) tariff:

.. code-block:: python

from pandas import Series, date_range
from flexmeasures.data.models.planning import FlowCommitment

index = date_range(start="2025-01-01 00:00", periods=24, freq="H")
# zero baseline → the asset may consume or produce; deviations are priced.
baseline = Series(0.0, index=index)

# consumption and production tariffs (per kWh)
consumption_price = Series(0.20, index=index) # 0.20 EUR/kWh for consumption
production_price = Series(-0.05, index=index) # -0.05 EUR/kWh reward for production

energy_commitment = FlowCommitment(
name="energy",
index=index,
quantity=baseline,
upwards_deviation_price=consumption_price,
downwards_deviation_price=production_price,
_type="each"
)

The scheduler sets up such commitments (site-level and device-level) and, together with any prior commitments, hands them to the linear optimizer.

Examples (commitments commonly derived from flex-context)
--------------------------------------------------------

The examples below map the most common `flex-context` semantics to the
commitments the scheduler constructs.

1. **Energy (tariff)**

- *Fields used*: ``consumption-price``, ``production-price``.
- *Commitment*: Flow commitment named ``"energy"`` with zero baseline and
the two price series as upwards/downwards deviation prices.

2. **Peak consumption**

- *Fields used*: ``site-peak-consumption`` (baseline) and ``site-peak-consumption-price`` (upwards-deviation price); the downwards price is set to ``0``.
- *Commitment*: Flow commitment named ``"consumption peak"``; positive baseline
values denote the prior consumption peak associated with sunk costs, and the upwards price penalises going beyond that baseline.

3. **Peak production / peak feed-in**

- *Fields used*: ``site-peak-production`` (baseline) and ``site-peak-production-price`` (downwards-deviation price); the upwards price is set to ``0``.
- *Commitment*: Flow commitment named ``"production peak"``; negative baseline
values denote the prior production peak associated with sunk costs, and the downwards price penalises going beyond that baseline.

4. **Consumption capacity**

- *Fields used*: ``site-consumption-capacity`` (baseline), and ``site-consumption-breach-price`` (upwards-deviation price); the downwards price is set to ``0``.
- *Commitment*: Flow commitment named ``"consumption breach"``; positive baseline
values denote the allowed consumption limit and the upwards price penalises going
beyond that limit.

5. **Production capacity**

- *Fields used*: ``site-production-capacity`` (baseline) and ``site-production-breach-price`` (downwards-deviation price); the upwards price is set to ``0``.
- *Commitment*: Flow commitment named ``"production breach"``; negative baseline
values denote the allowed production limit and the downwards price penalises going
beyond that limit.

6. **SOC minima / maxima (storage preferences)**

- *Fields used*: ``soc-minima``, ``soc-minima-breach-price``, ``soc-maxima`` and ``soc-maxima-breach-price``.
- *Commitment*: StockCommitment(s) that price deviations below minima or
above maxima. Hard storage capacities are set through ``soc-min`` and ``soc-max`` instead and are modelled as Pyomo constraints.

7. **Power bands per device**

- *Fields used*: ``consumption-capacity`` and ``production-capacity`` (baselines), ``consumption-breach-price`` (upwards-deviation price, with 0 downwards) and ``production-breach-price`` (downwards-deviation price, with 0 upwards).
- *Commitment*: FlowCommitment with either baseline and corresponding prices.

Grouping across time and devices
--------------------------------

- ``_type == 'each'``: penalise deviations per time slot (default for time series).
- ``_type == 'any'``: treat the whole commitment horizon as one group (useful
for peak-style penalties where only the maximum over the window should be
counted).

.. note::

Near-term feature: support for **grouping over devices** is planned and
documented here. When enabled, grouping over devices lets you express
soft constraints that aggregate deviations across a set of devices,
for example, an intermediate capacity constraint from a feeder shared by a group of devices (via **flow commitments**), or multiple power-to-heat devices that feed a shared thermal buffer (via **stock commitments**).


Advanced: mathematical formulation
----------------------------------

For a compact formulation of how commitments enter the optimization problem, see :ref:`storage_device_scheduler`.
15 changes: 8 additions & 7 deletions documentation/concepts/device_scheduler.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ Storage device scheduler: Linear model

Introduction
--------------
This generic storage 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,
and with multiple market commitments on the EMS level.
This generic storage device scheduler is able to handle a site with multiple devices, with various types of constraints on the site level and on the device level,
and with multiple market commitments on the site level.

A typical example is a house with many devices. The commitments are assumed to be with regard to the flow of energy to the device (positive for consumption, negative for production). In practice, this generic scheduler is used in the **StorageScheduler** to schedule a storage device.

The solver minimizes the costs of deviating from the commitments.
For a more detailed explanation of commitments in FlexMeasures, see :ref:`commitments`.



Expand Down Expand Up @@ -45,9 +46,9 @@ Symbol Variable in the Code
:math:`\epsilon(d,j)` efficiencies Stock energy losses.
:math:`P_{max}(d,j)` device_derivative_max Maximum flow of device :math:`d` during time period :math:`j`.
:math:`P_{min}(d,j)` device_derivative_min Minimum flow of device :math:`d` during time period :math:`j`.
:math:`P^{ems}_{min}(j)` ems_derivative_min Minimum flow of the EMS during time period :math:`j`.
:math:`P^{ems}_{max}(j)` ems_derivative_max Maximum flow of the EMS during time period :math:`j`.
:math:`Commitment(c,j)` commitment_quantity Commitment c (at EMS level) over time step :math:`j`.
:math:`P^{ems}_{min}(j)` ems_derivative_min Minimum flow of the site's grid connection point during time period :math:`j`.
:math:`P^{ems}_{max}(j)` ems_derivative_max Maximum flow of the site's grid connection point during time period :math:`j`.
:math:`Commitment(c,j)` commitment_quantity Commitment c (at site level) over time step :math:`j`.
:math:`M` M Large constant number, upper bound of :math:`Power_{up}(d,j)` and :math:`|Power_{down}(d,j)|`.
:math:`D(d,j)` stock_delta Explicit energy gain or loss of device :math:`d` during time period :math:`j`.
================================ ================================================ ==============================================================================================================
Expand All @@ -58,8 +59,8 @@ Variables
================================ ================================================ ==============================================================================================================
Symbol Variable in the Code Description
================================ ================================================ ==============================================================================================================
:math:`\Delta_{up}(c,j)` commitment_upwards_deviation Upwards deviation from the power commitment :math:`c` of the EMS during time period :math:`j`.
:math:`\Delta_{down}(c,j)` commitment_downwards_deviation Downwards deviation from the power commitment :math:`c` of the EMS during time period :math:`j`.
:math:`\Delta_{up}(c,j)` commitment_upwards_deviation Upwards deviation from the power commitment :math:`c` of the site during time period :math:`j`.
:math:`\Delta_{down}(c,j)` commitment_downwards_deviation Downwards deviation from the power commitment :math:`c` of the site during time period :math:`j`.
:math:`\Delta Stock(d,j)` n/a Change of stock of device :math:`d` at the end of time period :math:`j`.
:math:`P_{up}(d,j)` device_power_up Upwards power of device :math:`d` during time period :math:`j`.
:math:`P_{down}(d,j)` device_power_down Downwards power of device :math:`d` during time period :math:`j`.
Expand Down
1 change: 1 addition & 0 deletions documentation/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ In :ref:`getting_started`, we have some helpful tips how to dive into this docum
concepts/flexibility
concepts/data-model
concepts/security_auth
concepts/commitments
concepts/device_scheduler


Expand Down
115 changes: 113 additions & 2 deletions flexmeasures/data/models/planning/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

from dataclasses import dataclass, field
from datetime import datetime, timedelta
from tabulate import tabulate
from typing import Any, Dict, List, Type, Union

from collections.abc import Iterable
import pandas as pd
from flask import current_app

Expand Down Expand Up @@ -280,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 @@ -339,10 +341,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 @@ -353,6 +412,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 @@ -374,3 +440,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)
Loading
Loading