diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 3db27c7dab..bbe5a5ad80 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -32,7 +32,7 @@ New features * Sensor references in flex-model and flex-context support various ways of filtering by source [see `PR #2209 `_] * Let storage scheduling infer missing ``power-capacity`` from directional device capacities before falling back to site capacity, and default the missing opposite capacity to zero when only a non-zero ``consumption-capacity`` or ``production-capacity`` is configured [see `PR #2222 `_] * Support multiple feeders to a shared storage [see `PR #2001 `_, `PR #2321 `_, `PR #2322 `_ and `PR #2325 `_] -* The flex-context can now define multiple commodities, each specifying their own prices and grid capacities [see `PR #1946 `_, `PR #2172 `_, `PR #2235 `_, `PR #2271 `_ and `PR #2380 `_] +* The flex-context can now define multiple commodities, each specifying their own prices and grid capacities [see `PR #1946 `_, `PR #2172 `_, `PR #2235 `_, `PR #2271 `_, `PR #2355 `_ and `PR #2380 `_] * In the UI, the flex-context editor supports editing commitments (name, baseline and deviation prices, each accepting a fixed value or a sensor), also within each commodity context; the commitment's commodity follows the commodity tab being edited, and new commitments start with zero-valued baseline and prices [see `PR #2287 `_] * A commitment in the flex-context now requires a ``baseline`` and at least one deviation price (``up-price`` and/or ``down-price``), as already documented; commitment costs are reported in the scheduling results under the user-given name (with a ``(custom)`` suffix in the rare case the name collides with a scheduler-internal commitment name) [see `PR #2287 `_] * Commodity contexts that omit grid-connection fields (prices and site capacities) now get smart defaults instead of failing or silently leaving the grid unconstrained — for instance, a bare ``{"commodity": "gas"}`` is treated as having no grid connection; see :ref:`commodity_context_defaults` for the full rules [see `PR #2272 `_] diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 6a5f4fe80d..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 @@ -793,12 +802,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 7a57f96cfe..90663dd847 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -2029,6 +2029,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