From d4f32fc007759c50533a1a5f3a8cc86fa756d62c Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:42:42 -0700 Subject: [PATCH 1/2] fix(scheduling): bind EMS-level flow commitments EMS-level commitments currently create an unbounded constraint tuple, leaving their deviation variables uncoupled from device flow. Mirror grouped commitments when selecting bounds and cover aggregate scheduling with a regression test. Signed-off-by: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> --- documentation/changelog.rst | 1 + .../models/planning/linear_optimization.py | 4 +- .../models/planning/tests/test_commitments.py | 49 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 48087bf29b..6d4b390f44 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -61,6 +61,7 @@ Infrastructure / Support Bugfixes ----------- +* Ensure EMS-level flow commitments constrain aggregate device flow [see `issue #2326 `_] * Scheduling jobs no longer print ``Job ... made schedule.`` before ``scheduler.compute()`` runs (only after a successful schedule) [see `PR #2342 `_] * ``flexmeasures add user --roles`` now correctly accepts a comma-separated list of roles (and repeated ``--roles`` options) instead of creating one role whose name contains commas [see `PR #2339 `_] * Raise a clear ``ValueError`` when a flex-model references a missing sensor ID instead of ``AttributeError: 'NoneType' object has no attribute 'asset_id'`` [see `PR #2343 `_] diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 2627eb01f9..4100a8cecc 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -766,12 +766,12 @@ def ems_flow_commitment_equalities(m, c, j): return Constraint.Skip return ( - None, + 0 if "upwards deviation price" in commitments[c].columns else None, m.commitment_quantity[c, j] + m.commitment_downwards_deviation[c] + m.commitment_upwards_deviation[c] - sum(m.ems_power[d, j] for d in devices), - None, + 0 if "downwards deviation price" in commitments[c].columns else None, ) def device_derivative_equalities(m, d, j): diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 7c37334299..3e585f4159 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -1988,6 +1988,55 @@ def test_stock_scoped_commitment_binds_group_stock(named_device): np.testing.assert_allclose(total_energy, 20.0, atol=1e-6) +def test_ems_flow_commitment_binds_all_devices(): + start = pd.Timestamp("2026-01-01T00:00+01") + end = pd.Timestamp("2026-01-01T01:00+01") + resolution = pd.Timedelta("PT1H") + index = initialize_index(start=start, end=end, resolution=resolution) + + device_constraints = [ + pd.DataFrame( + { + "min": -100.0, + "max": 100.0, + "equals": np.nan, + "derivative min": -10.0, + "derivative max": 10.0, + "derivative equals": np.nan, + "derivative down efficiency": 1.0, + "derivative up efficiency": 1.0, + }, + index=index, + ) + for _ in range(2) + ] + ems_constraints = pd.DataFrame( + {"derivative min": -100.0, "derivative max": 100.0}, index=index + ) + commitments = [ + FlowCommitment( + name="EMS target", + index=index, + quantity=5, + upwards_deviation_price=10, + downwards_deviation_price=-10, + ) + ] + + planned_power, planned_costs, results, _ = device_scheduler( + device_constraints=device_constraints, + ems_constraints=ems_constraints, + commitments=commitments, + initial_stock=0, + ) + + assert results.solver.termination_condition == "optimal" + np.testing.assert_allclose( + sum(schedule.iloc[0] for schedule in planned_power), 5.0, atol=1e-6 + ) + assert planned_costs == pytest.approx(0) + + def test_commitment_commodity_does_not_bind_other_commodity_devices(): """A commitment listed under the flex-context's `commitments` should only bind devices of its own From 2e4e79c38a7d9e276661850b51ac5d586fcb5c6f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 3 Aug 2026 15:40:35 +0200 Subject: [PATCH 2/2] Do not bind a device-scoped commitment at EMS level as well Giving ems_flow_commitment_equalities bounds exposed an overlap with grouped_commitment_equalities that did not matter while the rows were free. PR #2380, merged after this PR was opened, binds a regular flex-context commitment to the aggregate of its commodity's devices by giving it a "device" and "device_group". Such a commitment is therefore already bound, once per device group. Binding it here as well constrains the same commitment_downwards_deviation/commitment_upwards_deviation variables a second time, against a different device set, which over-constrains the problem: 9 device-, group- and stock-scoped commitment tests failed, including the one PR #2380 added. Skip commitments that have a device group, so this constraint family applies only to commitments naming no device -- the EMS-level case this PR is about. The test added here still exercises that case, as an EMS-level FlowCommitment gets no device_group_lookup entry. Co-authored-by: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/linear_optimization.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 3548297a89..b42c9924e2 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -780,6 +780,15 @@ def ems_flow_commitment_equalities(m, c, j): if commitments[c]["class"].iloc[0] != FlowCommitment: return Constraint.Skip + # A device-scoped commitment is already bound, once per device group, by + # grouped_commitment_equalities. Now that this constraint family actually + # has bounds, binding such a commitment here as well would constrain the + # same deviation variables a second time, against a different device set + # (the whole EMS, or the whole commodity). Only genuinely EMS-level + # commitments -- those naming no device -- belong here. + if device_group_lookup.get(c): + return Constraint.Skip + # Legacy behavior: no commodity → sum over all devices if "commodity" not in commitments[c].columns: devices = m.d