From 9dd779ad2b4a2c80473ec4cdfa364555bd5f58c5 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 4 Dec 2025 11:27:27 +0100 Subject: [PATCH 01/21] docs: new section describing commitments Signed-off-by: F.N. Claessen --- documentation/concepts/commitments.rst | 169 +++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 documentation/concepts/commitments.rst diff --git a/documentation/concepts/commitments.rst b/documentation/concepts/commitments.rst new file mode 100644 index 0000000000..5521f6ac1d --- /dev/null +++ b/documentation/concepts/commitments.rst @@ -0,0 +1,169 @@ +Commitments +=========== + +Overview +-------- + +A **Commitment** is the central economic abstraction used by FlexMeasures to +express *soft constraints, preferences and market positions* in the scheduler. + +A commitment describes: + +- a **baseline quantity** over time (the target or assumed 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 limits (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**). From 13e93086fd3c0b928ce6d3fcc6e3c764d58a9a0e Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 4 Dec 2025 13:44:08 +0100 Subject: [PATCH 02/21] docs: rewrite the overview in commitments section Signed-off-by: F.N. Claessen --- documentation/concepts/commitments.rst | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/documentation/concepts/commitments.rst b/documentation/concepts/commitments.rst index 5521f6ac1d..24d3143ad1 100644 --- a/documentation/concepts/commitments.rst +++ b/documentation/concepts/commitments.rst @@ -4,17 +4,19 @@ Commitments Overview -------- -A **Commitment** is the central economic abstraction used by FlexMeasures to -express *soft constraints, preferences and market positions* in the scheduler. +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 target or assumed position), and +- 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 limits (for example generator or +across the schedule horizon. Absolute physical limitations (for example generator or line capacities) are *not* modelled as commitments — those are enforced as Pyomo constraints. From 117919806ca403cb2873c5eb2db509a6c0c8d1d2 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 4 Dec 2025 13:52:05 +0100 Subject: [PATCH 03/21] docs: keep ems variables, but explain them as representing the site context Signed-off-by: F.N. Claessen --- documentation/concepts/device_scheduler.rst | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/documentation/concepts/device_scheduler.rst b/documentation/concepts/device_scheduler.rst index 7d85e57415..3b87ac7963 100644 --- a/documentation/concepts/device_scheduler.rst +++ b/documentation/concepts/device_scheduler.rst @@ -5,8 +5,8 @@ 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. @@ -45,9 +45,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`. ================================ ================================================ ============================================================================================================== @@ -58,8 +58,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`. From 1b103601a5d080f892cec0590d0aae61a8486e4b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 4 Dec 2025 13:55:24 +0100 Subject: [PATCH 04/21] docs: cross-reference the commitments section and the linear problem formulation Signed-off-by: F.N. Claessen --- documentation/concepts/commitments.rst | 8 ++++++++ documentation/concepts/device_scheduler.rst | 1 + 2 files changed, 9 insertions(+) diff --git a/documentation/concepts/commitments.rst b/documentation/concepts/commitments.rst index 24d3143ad1..d21094e522 100644 --- a/documentation/concepts/commitments.rst +++ b/documentation/concepts/commitments.rst @@ -1,3 +1,5 @@ +.. _commitments: + Commitments =========== @@ -169,3 +171,9 @@ Grouping across time and devices 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:``. diff --git a/documentation/concepts/device_scheduler.rst b/documentation/concepts/device_scheduler.rst index 3b87ac7963..cb36d0dc46 100644 --- a/documentation/concepts/device_scheduler.rst +++ b/documentation/concepts/device_scheduler.rst @@ -11,6 +11,7 @@ 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:``. From 6f6e52b0290a4af369b4cc1f898b8ca30dd07fd8 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Dec 2025 15:54:12 +0100 Subject: [PATCH 05/21] fix: cross-references Signed-off-by: F.N. Claessen --- documentation/concepts/commitments.rst | 2 +- documentation/concepts/device_scheduler.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/concepts/commitments.rst b/documentation/concepts/commitments.rst index d21094e522..11ab4cc955 100644 --- a/documentation/concepts/commitments.rst +++ b/documentation/concepts/commitments.rst @@ -176,4 +176,4 @@ Grouping across time and devices Advanced: mathematical formulation ---------------------------------- -For a compact formulation of how commitments enter the optimization problem, see :ref:``. +For a compact formulation of how commitments enter the optimization problem, see :ref:`storage_device_scheduler`. diff --git a/documentation/concepts/device_scheduler.rst b/documentation/concepts/device_scheduler.rst index cb36d0dc46..289f40cfc8 100644 --- a/documentation/concepts/device_scheduler.rst +++ b/documentation/concepts/device_scheduler.rst @@ -11,7 +11,7 @@ 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:``. +For a more detailed explanation of commitments in FlexMeasures, see :ref:`commitments`. From 359937985193a7cf4e018df6e29b079e081eeaee Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Dec 2025 15:54:26 +0100 Subject: [PATCH 06/21] docs: add commitments section to index Signed-off-by: F.N. Claessen --- documentation/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/documentation/index.rst b/documentation/index.rst index 337beb2585..748b160395 100644 --- a/documentation/index.rst +++ b/documentation/index.rst @@ -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 From 84fbd5a4f731417f9d4eb1f26937c050abb9562e Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Fri, 23 Jan 2026 11:42:18 +0100 Subject: [PATCH 07/21] feat: add a util function for printing out commitments in a tabulated format --- flexmeasures/data/models/planning/utils.py | 30 ++++++++++++++++++++++ flexmeasures/ui/static/openapi-specs.json | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/utils.py b/flexmeasures/data/models/planning/utils.py index 6ba41dd20e..30fe7b457c 100644 --- a/flexmeasures/data/models/planning/utils.py +++ b/flexmeasures/data/models/planning/utils.py @@ -8,6 +8,7 @@ from pandas.tseries.frequencies import to_offset import numpy as np import timely_beliefs as tb +from tabulate import tabulate from flexmeasures.data.models.planning.exceptions import UnknownPricesException from flexmeasures.data.models.time_series import Sensor, TimedBelief @@ -559,3 +560,32 @@ def initialize_device_commitment( stock_commitment["device"] = device stock_commitment["class"] = StockCommitment return stock_commitment + + +def print_commitments(flow_commitments): + """ + 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. + """ + for fc in flow_commitments: + + df = pd.DataFrame(index=fc.device.index) + + df["commitment"] = fc.name + df["device"] = fc.device + df["group"] = fc.group + df["quantity"] = fc.quantity + + if hasattr(fc, "upwards_deviation_price"): + df["up_price"] = fc.upwards_deviation_price + + if hasattr(fc, "downwards_deviation_price"): + df["down_price"] = fc.downwards_deviation_price + + if not df.empty: + print(tabulate(df, headers=df.columns, tablefmt="fancy_grid")) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 89629bb1cf..8c2dee2f83 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -7,7 +7,7 @@ }, "termsOfService": null, "title": "FlexMeasures", - "version": "0.31.0" + "version": "0.30.0" }, "externalDocs": { "description": "FlexMeasures runs on the open source FlexMeasures technology. Read the docs here.", From 90177bfcfdacd28134763969c07cc3cb90ca78c4 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 23 Jan 2026 12:54:01 +0100 Subject: [PATCH 08/21] refactor: move pretty printing method to class Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/__init__.py | 28 +++++++++++++++++ flexmeasures/data/models/planning/utils.py | 30 ------------------- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py index 636ff9e2b5..9a18f990f0 100644 --- a/flexmeasures/data/models/planning/__init__.py +++ b/flexmeasures/data/models/planning/__init__.py @@ -2,6 +2,7 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta +from tabulate import tabulate from typing import Any, Dict, List, Type, Union import pandas as pd @@ -340,6 +341,33 @@ def __post_init__(self): ) self.group = self.group.rename("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( diff --git a/flexmeasures/data/models/planning/utils.py b/flexmeasures/data/models/planning/utils.py index 30fe7b457c..6ba41dd20e 100644 --- a/flexmeasures/data/models/planning/utils.py +++ b/flexmeasures/data/models/planning/utils.py @@ -8,7 +8,6 @@ from pandas.tseries.frequencies import to_offset import numpy as np import timely_beliefs as tb -from tabulate import tabulate from flexmeasures.data.models.planning.exceptions import UnknownPricesException from flexmeasures.data.models.time_series import Sensor, TimedBelief @@ -560,32 +559,3 @@ def initialize_device_commitment( stock_commitment["device"] = device stock_commitment["class"] = StockCommitment return stock_commitment - - -def print_commitments(flow_commitments): - """ - 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. - """ - for fc in flow_commitments: - - df = pd.DataFrame(index=fc.device.index) - - df["commitment"] = fc.name - df["device"] = fc.device - df["group"] = fc.group - df["quantity"] = fc.quantity - - if hasattr(fc, "upwards_deviation_price"): - df["up_price"] = fc.upwards_deviation_price - - if hasattr(fc, "downwards_deviation_price"): - df["down_price"] = fc.downwards_deviation_price - - if not df.empty: - print(tabulate(df, headers=df.columns, tablefmt="fancy_grid")) From 9f34676c64ec49f145533f01cdf8d785965f12ab Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 23 Jan 2026 12:54:46 +0100 Subject: [PATCH 09/21] feat: Commitment supports device groups Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/__init__.py | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py index 9a18f990f0..df7e6dbd3f 100644 --- a/flexmeasures/data/models/planning/__init__.py +++ b/flexmeasures/data/models/planning/__init__.py @@ -281,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) @@ -340,6 +341,36 @@ 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 = self.device.unique() + 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): """ @@ -370,7 +401,7 @@ def pretty_print(self): def to_frame(self) -> pd.DataFrame: """Contains all info apart from the name.""" - return pd.concat( + df = pd.concat( [ self.device, self.quantity, @@ -381,6 +412,13 @@ def to_frame(self) -> pd.DataFrame: ], axis=1, ) + # map device → device_group + if self.device is not None: + df["device_group"] = self.device.map(self.device_group) + else: + df["device_group"] = 0 + + return df class FlowCommitment(Commitment): From 43107c827933cd071619f2332e1ea432a679f382 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 23 Jan 2026 12:55:47 +0100 Subject: [PATCH 10/21] feat: start testing device grouping Signed-off-by: F.N. Claessen --- .../models/planning/tests/test_commitments.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 flexmeasures/data/models/planning/tests/test_commitments.py diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py new file mode 100644 index 0000000000..e4bd2ba2f7 --- /dev/null +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -0,0 +1,59 @@ +import pandas as pd + +from flexmeasures.data.models.planning import StockCommitment +from flexmeasures.data.models.planning.utils import initialize_index + + +def test_multi_feed(): + start = pd.Timestamp("2026-01-01T00:00+01") + end = pd.Timestamp("2026-01-02T00:00+01") + resolution = pd.Timedelta("PT1H") + index = (initialize_index(start=start, end=end, resolution=resolution),) + + device_group = pd.Series( + { + "gas boiler": "shared thermal buffer", + "heat pump power": "shared thermal buffer", + "battery power": "battery SoC", + } + ) + + max_thermal_soc = "100 kWh" + breach_price = "1000 EUR/kWh" + + commitment_a = StockCommitment( + name="buffer max", + index=index, + quantity=max_thermal_soc, + upwards_deviation_price=breach_price, + downwards_deviation_price=0, + device=pd.Series( + "gas boiler", index=index + ), # per-slot device resolution happens elsewhere + device_group=device_group, + ) + commitment_b = StockCommitment( + name="buffer max", + index=index, + quantity=max_thermal_soc, + upwards_deviation_price=breach_price, + downwards_deviation_price=0, + device=pd.Series( + "heat pump power", index=index + ), # per-slot device resolution happens elsewhere + device_group=device_group, + ) + commitment_c = StockCommitment( + name="buffer max", + index=index, + quantity=max_thermal_soc, + upwards_deviation_price=breach_price, + downwards_deviation_price=0, + device=pd.Series( + "battery power", index=index + ), # per-slot device resolution happens elsewhere + device_group=device_group, + ) + assert commitment_a.device_group[0] == "shared thermal buffer" + assert commitment_b.device_group[0] == "shared thermal buffer" + assert commitment_c.device_group[0] == "battery SoC" From b90d7f0da8d9036c535e0f397af740c0e3b1adcb Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 23 Jan 2026 14:35:35 +0100 Subject: [PATCH 11/21] dev: test multi-feed Signed-off-by: F.N. Claessen --- .../models/planning/tests/test_commitments.py | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index e4bd2ba2f7..9472b845eb 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -57,3 +57,104 @@ def test_multi_feed(): assert commitment_a.device_group[0] == "shared thermal buffer" assert commitment_b.device_group[0] == "shared thermal buffer" assert commitment_c.device_group[0] == "battery SoC" + + +import pandas as pd +import numpy as np + +from flexmeasures.data.models.planning import StockCommitment +from flexmeasures.data.models.planning.utils import initialize_index +from flexmeasures.data.models.planning.linear_optimization import device_scheduler + + +def test_multi_feed_device_scheduler_shared_buffer(): + # ---- time setup + start = pd.Timestamp("2026-01-01T00:00+01") + end = pd.Timestamp("2026-01-02T00:00+01") + resolution = pd.Timedelta("PT1H") + index = initialize_index(start=start, end=end, resolution=resolution) + + # ---- three devices + devices = ["gas boiler", "heat pump power", "battery power"] + + # ---- device grouping + device_group = pd.Series( + { + "gas boiler": "shared thermal buffer", + "heat pump power": "shared thermal buffer", + "battery power": "battery SoC", + } + ) + + # ---- trivial device constraints (stocks unconstrained individually) + device_constraints = [] + for _ in devices: + df = pd.DataFrame( + { + "min": -np.inf, + "max": np.inf, + "equals": np.nan, + "derivative min": -np.inf, + "derivative max": np.inf, + "derivative equals": np.nan, + }, + index=index, + ) + device_constraints.append(df) + + # ---- no EMS-level constraints + ems_constraints = pd.DataFrame( + { + "derivative min": -np.inf, + "derivative max": np.inf, + }, + index=index, + ) + + # ---- shared buffer max = 100 (soft) + max_soc = 100.0 + breach_price = 1_000.0 + + commitments = [] + for dev in devices: + commitments.append( + StockCommitment( + name="buffer max", + index=index, + quantity=max_soc, + upwards_deviation_price=breach_price, + downwards_deviation_price=0, + device=pd.Series(dev, index=index), + device_group=device_group, + ) + ) + + # ---- run scheduler + planned_power, planned_costs, results, model = device_scheduler( + device_constraints=device_constraints, + ems_constraints=ems_constraints, + commitments=commitments, + initial_stock=0, + ) + + # ---- sanity: model solved + assert results.solver.termination_condition in ("optimal", "locallyOptimal") + + # ---- key assertion: exactly TWO commitment groups + # - one for "shared thermal buffer" + # - one for "battery SoC" + # + # i.e. NOT three (which would indicate per-device baselines) + commitment_groups = set(commitments[0].device_group.values) + breakpoint() + assert commitment_groups == { + "shared thermal buffer", + "battery SoC", + } + + # ---- key behavioural check: + # total commitment cost should be <= 1 breach per group per timestep + # + # If baselines were duplicated, cost would be ~2x for the shared buffer. + expected_max_cost = len(index) * breach_price * 2 + assert planned_costs <= expected_max_cost From 41799816ea8d9f2c44bcaa42828764a96781d6da Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Mon, 26 Jan 2026 15:47:14 +0100 Subject: [PATCH 12/21] update the ids of devices to be integers --- .../models/planning/tests/test_commitments.py | 77 ++----------------- 1 file changed, 6 insertions(+), 71 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 9472b845eb..4d856c733c 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -1,64 +1,3 @@ -import pandas as pd - -from flexmeasures.data.models.planning import StockCommitment -from flexmeasures.data.models.planning.utils import initialize_index - - -def test_multi_feed(): - start = pd.Timestamp("2026-01-01T00:00+01") - end = pd.Timestamp("2026-01-02T00:00+01") - resolution = pd.Timedelta("PT1H") - index = (initialize_index(start=start, end=end, resolution=resolution),) - - device_group = pd.Series( - { - "gas boiler": "shared thermal buffer", - "heat pump power": "shared thermal buffer", - "battery power": "battery SoC", - } - ) - - max_thermal_soc = "100 kWh" - breach_price = "1000 EUR/kWh" - - commitment_a = StockCommitment( - name="buffer max", - index=index, - quantity=max_thermal_soc, - upwards_deviation_price=breach_price, - downwards_deviation_price=0, - device=pd.Series( - "gas boiler", index=index - ), # per-slot device resolution happens elsewhere - device_group=device_group, - ) - commitment_b = StockCommitment( - name="buffer max", - index=index, - quantity=max_thermal_soc, - upwards_deviation_price=breach_price, - downwards_deviation_price=0, - device=pd.Series( - "heat pump power", index=index - ), # per-slot device resolution happens elsewhere - device_group=device_group, - ) - commitment_c = StockCommitment( - name="buffer max", - index=index, - quantity=max_thermal_soc, - upwards_deviation_price=breach_price, - downwards_deviation_price=0, - device=pd.Series( - "battery power", index=index - ), # per-slot device resolution happens elsewhere - device_group=device_group, - ) - assert commitment_a.device_group[0] == "shared thermal buffer" - assert commitment_b.device_group[0] == "shared thermal buffer" - assert commitment_c.device_group[0] == "battery SoC" - - import pandas as pd import numpy as np @@ -80,9 +19,9 @@ def test_multi_feed_device_scheduler_shared_buffer(): # ---- device grouping device_group = pd.Series( { - "gas boiler": "shared thermal buffer", - "heat pump power": "shared thermal buffer", - "battery power": "battery SoC", + 0: "shared thermal buffer", # gas boiler + 1: "shared thermal buffer", # "heat pump power" + 2: "battery SoC", #"battery power" } ) @@ -116,7 +55,7 @@ def test_multi_feed_device_scheduler_shared_buffer(): breach_price = 1_000.0 commitments = [] - for dev in devices: + for d,dev in enumerate(devices): commitments.append( StockCommitment( name="buffer max", @@ -124,7 +63,7 @@ def test_multi_feed_device_scheduler_shared_buffer(): quantity=max_soc, upwards_deviation_price=breach_price, downwards_deviation_price=0, - device=pd.Series(dev, index=index), + device=pd.Series(d, index=index), device_group=device_group, ) ) @@ -146,11 +85,7 @@ def test_multi_feed_device_scheduler_shared_buffer(): # # i.e. NOT three (which would indicate per-device baselines) commitment_groups = set(commitments[0].device_group.values) - breakpoint() - assert commitment_groups == { - "shared thermal buffer", - "battery SoC", - } + assert commitment_groups == {"shared thermal buffer"} # ---- key behavioural check: # total commitment cost should be <= 1 breach per group per timestep From 8b108ed881a7576760d87c8a24a6bb48b8855eb4 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Mon, 26 Jan 2026 15:52:21 +0100 Subject: [PATCH 13/21] feat: function that group commitment quantities Signed-off-by: Ahmad-Wahid --- .../models/planning/linear_optimization.py | 68 +++++++++++++++++-- flexmeasures/ui/static/openapi-specs.json | 2 +- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 2f1ba06d1d..38caeb127e 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -207,6 +207,22 @@ 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 + + device_group_lookup[c] = {} + + # keep only relevant rows + rows = df[["device", "device_group"]].dropna().drop_duplicates() + + for _, row in rows.iterrows(): + g = row["device_group"] + d = row["device"] # NOTE: must match model.d indexing + device_group_lookup[c].setdefault(g, set()).add(d) + # Oversimplified check for a convex cost curve df = pd.concat(commitments)[ ["upwards deviation price", "downwards deviation price"] @@ -243,13 +259,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: @@ -362,6 +386,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( @@ -565,6 +623,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 @@ -592,9 +654,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 ) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 8c2dee2f83..89629bb1cf 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -7,7 +7,7 @@ }, "termsOfService": null, "title": "FlexMeasures", - "version": "0.30.0" + "version": "0.31.0" }, "externalDocs": { "description": "FlexMeasures runs on the open source FlexMeasures technology. Read the docs here.", From 485349e425b6efe40b5abbfed45aaf457bbd2246 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Mon, 26 Jan 2026 18:34:58 +0100 Subject: [PATCH 14/21] add commitments for multi group Signed-off-by: Ahmad-Wahid --- .../models/planning/tests/test_commitments.py | 105 +++++++++++++++--- 1 file changed, 91 insertions(+), 14 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 4d856c733c..26349f0ff6 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -1,8 +1,11 @@ import pandas as pd import numpy as np -from flexmeasures.data.models.planning import StockCommitment -from flexmeasures.data.models.planning.utils import initialize_index +from flexmeasures.data.models.planning import StockCommitment, FlowCommitment +from flexmeasures.data.models.planning.utils import ( + initialize_index, + add_tiny_price_slope, +) from flexmeasures.data.models.planning.linear_optimization import device_scheduler @@ -19,23 +22,36 @@ def test_multi_feed_device_scheduler_shared_buffer(): # ---- device grouping device_group = pd.Series( { - 0: "shared thermal buffer", # gas boiler - 1: "shared thermal buffer", # "heat pump power" - 2: "battery SoC", #"battery power" + 0: "shared thermal buffer", # gas boiler + 1: "shared thermal buffer", # "heat pump power" + 2: "battery SoC", # "battery power" } ) - + device_commodity = pd.Series( + { + 0: "gas", # gas boiler + 1: "electricity", # "heat pump power" + 2: "electricity", # "battery power" + } + ) + equals = pd.Series(np.nan, index=index) + equals[-1] = 100 # ---- trivial device constraints (stocks unconstrained individually) device_constraints = [] - for _ in devices: + for d, device_name in enumerate(devices): + # 0 and 1 : derivative min 0 + # 2 : derivative min = - production capacity + df = pd.DataFrame( { - "min": -np.inf, - "max": np.inf, + "min": 0, + "max": 100, "equals": np.nan, - "derivative min": -np.inf, - "derivative max": np.inf, + "derivative min": 0 if d in (0, 1) else -20, + "derivative max": 20, "derivative equals": np.nan, + "derivative down efficiency": 0.9, + "derivative up efficiency": 0.9, }, index=index, ) @@ -44,8 +60,8 @@ def test_multi_feed_device_scheduler_shared_buffer(): # ---- no EMS-level constraints ems_constraints = pd.DataFrame( { - "derivative min": -np.inf, - "derivative max": np.inf, + "derivative min": -40, + "derivative max": 40, }, index=index, ) @@ -53,9 +69,36 @@ def test_multi_feed_device_scheduler_shared_buffer(): # ---- shared buffer max = 100 (soft) max_soc = 100.0 breach_price = 1_000.0 + min_soc = pd.Series(0, index=index) + min_soc[-1] = 100 + + # default commodity: electricity + # choice: electricity or gas + gas_price = pd.Series(300, index=index) + electricity_price = pd.Series(600, index=index, name="event_value") + electricity_price.iloc[12:14] = 200 + prices = {"gas": gas_price, "electricity": electricity_price} + + sloped_prices = ( + add_tiny_price_slope(electricity_price.to_frame()) + - electricity_price.to_frame() + ) commitments = [] - for d,dev in enumerate(devices): + + # todo: fix this commitment for the group[boiler, heat pump] to reach 100 kW together + commitments.append( + StockCommitment( + name="buffer min", + index=index, + quantity=min_soc, + upwards_deviation_price=0, + downwards_deviation_price=-breach_price, + device=None, + device_group=device_group, + ) + ) + for d, dev in enumerate(devices): commitments.append( StockCommitment( name="buffer max", @@ -67,6 +110,29 @@ def test_multi_feed_device_scheduler_shared_buffer(): device_group=device_group, ) ) + commitments.append( + FlowCommitment( + name=device_commodity[d], + index=index, + quantity=0, + upwards_deviation_price=prices[device_commodity[d]], + downwards_deviation_price=prices[device_commodity[d]], + device=pd.Series(d, index=index), + device_group=device_commodity, + ) + ) + + commitments.append( + FlowCommitment( + name="preferred_charge_sooner", + index=index, + quantity=0, + upwards_deviation_price=sloped_prices, + downwards_deviation_price=sloped_prices, + device=pd.Series(d, index=index), + device_group=device_commodity, + ) + ) # ---- run scheduler planned_power, planned_costs, results, model = device_scheduler( @@ -85,6 +151,17 @@ def test_multi_feed_device_scheduler_shared_buffer(): # # i.e. NOT three (which would indicate per-device baselines) commitment_groups = set(commitments[0].device_group.values) + + # commitment_costs = [ + # { + # "name": "commitment_costs", + # "data": { + # c.name: costs + # for c, costs in zip(commitments, model.commitment_costs.values()) + # }, + # }, + # ] + assert commitment_groups == {"shared thermal buffer"} # ---- key behavioural check: From 81bb9ecd47e9d50512b18c04d7cafb0c9fcd6b63 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 27 Jan 2026 20:19:44 +0100 Subject: [PATCH 15/21] fix: get unique list of devices for a frame column Signed-off-by: Ahmad-Wahid --- .../data/models/planning/linear_optimization.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 38caeb127e..37bada383b 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -213,15 +213,20 @@ def convert_commitments_to_subcommitments( if "device_group" not in df.columns or "device" not in df.columns: continue - device_group_lookup[c] = {} + rows = df[["device", "device_group"]].dropna() - # keep only relevant rows - rows = df[["device", "device_group"]].dropna().drop_duplicates() + device_group_lookup[c] = {} for _, row in rows.iterrows(): g = row["device_group"] - d = row["device"] # NOTE: must match model.d indexing - device_group_lookup[c].setdefault(g, set()).add(d) + 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)[ From 334e4d38b289097a1d3cbf5f91f9c446c5013847 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 27 Jan 2026 20:23:28 +0100 Subject: [PATCH 16/21] fix: create util functions that extract devices for a list of values and map them to the respective group id Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/__init__.py | 51 +++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py index df7e6dbd3f..a1e7cfa747 100644 --- a/flexmeasures/data/models/planning/__init__.py +++ b/flexmeasures/data/models/planning/__init__.py @@ -4,7 +4,7 @@ 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 @@ -351,7 +351,7 @@ def _init_device_group(self): # Extract device universe if isinstance(self.device, pd.Series): - devices = self.device.unique() + devices = extract_devices(self.device) else: devices = [self.device] @@ -414,7 +414,7 @@ def to_frame(self) -> pd.DataFrame: ) # map device → device_group if self.device is not None: - df["device_group"] = self.device.map(self.device_group) + df["device_group"] = map_device_to_group(self.device, self.device_group) else: df["device_group"] = 0 @@ -440,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) From f738d9f32a9604f72cf1ad92f8e466f4df9ea062 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 27 Jan 2026 20:25:13 +0100 Subject: [PATCH 17/21] fix: create a series for a list of grouped devices Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/tests/test_commitments.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 26349f0ff6..fd67097da6 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -86,7 +86,6 @@ def test_multi_feed_device_scheduler_shared_buffer(): commitments = [] - # todo: fix this commitment for the group[boiler, heat pump] to reach 100 kW together commitments.append( StockCommitment( name="buffer min", @@ -94,7 +93,9 @@ def test_multi_feed_device_scheduler_shared_buffer(): quantity=min_soc, upwards_deviation_price=0, downwards_deviation_price=-breach_price, - device=None, + # instead of device=None, I considered to create a series for the devices that we need for this + # specific commitment. + device=pd.Series([[0, 1]] * len(index), index=index), device_group=device_group, ) ) From 620c6dc350b278053f9c79b5c01559517e1ea819 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Sat, 31 Jan 2026 17:23:27 +0100 Subject: [PATCH 18/21] drop outdated comments --- flexmeasures/data/models/planning/tests/test_commitments.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index fd67097da6..8d4df6e720 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -36,7 +36,6 @@ def test_multi_feed_device_scheduler_shared_buffer(): ) equals = pd.Series(np.nan, index=index) equals[-1] = 100 - # ---- trivial device constraints (stocks unconstrained individually) device_constraints = [] for d, device_name in enumerate(devices): # 0 and 1 : derivative min 0 @@ -57,7 +56,6 @@ def test_multi_feed_device_scheduler_shared_buffer(): ) device_constraints.append(df) - # ---- no EMS-level constraints ems_constraints = pd.DataFrame( { "derivative min": -40, From 40eb747b61321d1d47a9c448fcee2a9ca4ac0ca6 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Sat, 31 Jan 2026 19:59:01 +0100 Subject: [PATCH 19/21] use commitment costs and add asserts for electricity and gas Signed-off-by: Ahmad-Wahid --- .../models/planning/tests/test_commitments.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 8d4df6e720..078ae3d502 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -151,15 +151,15 @@ def test_multi_feed_device_scheduler_shared_buffer(): # i.e. NOT three (which would indicate per-device baselines) commitment_groups = set(commitments[0].device_group.values) - # commitment_costs = [ - # { - # "name": "commitment_costs", - # "data": { - # c.name: costs - # for c, costs in zip(commitments, model.commitment_costs.values()) - # }, - # }, - # ] + commitment_costs = { + "name": "commitment_costs", + "data": { + c.name: costs + for c, costs in zip(commitments, model.commitment_costs.values()) + }, + } + assert commitment_costs["data"]["electricity"] == -11440.0 + assert round(commitment_costs["data"]["gas"], 2) == 21333.33 assert commitment_groups == {"shared thermal buffer"} From 708d86949420d6662c06818c4fe40e6cf4b5d046 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Sat, 31 Jan 2026 21:47:09 +0100 Subject: [PATCH 20/21] and an assert for commodity costs Signed-off-by: Ahmad-Wahid --- .../data/models/planning/tests/test_commitments.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 078ae3d502..56179ff8f2 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -150,6 +150,12 @@ def test_multi_feed_device_scheduler_shared_buffer(): # # i.e. NOT three (which would indicate per-device baselines) commitment_groups = set(commitments[0].device_group.values) + commodity_commitments = { + c.name + for c in commitments + if isinstance(c, FlowCommitment) and c.name in {"gas", "electricity"} + } + assert commodity_commitments == {"gas", "electricity"} commitment_costs = { "name": "commitment_costs", @@ -158,8 +164,10 @@ def test_multi_feed_device_scheduler_shared_buffer(): for c, costs in zip(commitments, model.commitment_costs.values()) }, } - assert commitment_costs["data"]["electricity"] == -11440.0 - assert round(commitment_costs["data"]["gas"], 2) == 21333.33 + commodity_costs = { + k: v for k, v in commitment_costs["data"].items() if k in {"gas", "electricity"} + } + assert set(commodity_costs.keys()) == {"gas", "electricity"} assert commitment_groups == {"shared thermal buffer"} From ce307f82474d690cb115d2664447cc344bc96666 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Sat, 31 Jan 2026 22:13:22 +0100 Subject: [PATCH 21/21] add an extra assert on costs Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/tests/test_commitments.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 56179ff8f2..639813a090 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -177,3 +177,5 @@ def test_multi_feed_device_scheduler_shared_buffer(): # If baselines were duplicated, cost would be ~2x for the shared buffer. expected_max_cost = len(index) * breach_price * 2 assert planned_costs <= expected_max_cost + total_commodity_cost = sum(commodity_costs.values()) + assert total_commodity_cost <= planned_costs