From 47acb73aa9aeb1c25fdaf69929a2915f586dd179 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 7 Aug 2026 15:56:55 +0200 Subject: [PATCH 1/7] Cover the committed quantity in Mc, and tighten it The big-M bounding commitment deviations summed the absolute device flow limits over all devices, time steps and flow columns, which is orders of magnitude above the largest possible flow deviation, weakening the LP relaxation whenever a non-convex cost curve adds the commitment-sign constraints. It also ignored the committed quantities altogether: a committed quantity far beyond the devices' flow limits needs a deviation larger than Mc, which made the problem infeasible. The bound now adds the largest absolute committed quantity to the devices' flow limits, summed per time step for flow commitments and over the horizon where stock commitments are present (their deviations accumulate flows since the start). Co-Authored-By: Claude Fable 5 Signed-off-by: F.N. Claessen --- documentation/changelog.rst | 1 + .../models/planning/scheduling_problem.py | 35 +++++- .../data/models/planning/tests/test_big_m.py | 118 ++++++++++++++++++ 3 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 flexmeasures/data/models/planning/tests/test_big_m.py diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 511cc365d8..367b18ad64 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -87,6 +87,7 @@ Infrastructure / Support Bugfixes ----------- +* The big-M bounding commitment deviations now accounts for the committed quantity, so a committed quantity far beyond the devices' flow limits no longer renders the problem infeasible when a non-convex cost curve activates the commitment-sign constraints; the bound is also tighter (covering one time step rather than the whole horizon, for flow commitments), which improves MIP numerics [see `PR #XXXX `_] * Show icons for more asset types in the UI's asset structure view, which previously fell back to a question mark: the ``wind``, ``process`` and ``heat-storage`` types that FlexMeasures seeds by default, and EV infrastructure under its various names (such as ``one-way_evse``, ``two-way_evse``, ``evse``, ``charging_station`` and ``charging_hub``) and building services equipment (``hvac``, ``ahu``, ``dhw``, ``heatpump``, ``chiller``, ``lighting`` and ``other-loads``). Asset type names are now matched ignoring case and separators, so an asset type named ``charge-point`` gets the same icon as ``chargepoint`` [see `PR #2391 `_] * Replaying a chart for a past window no longer shows annotations that were only recorded later; annotation searches and the ``chart_annotations`` endpoints can now be scoped by recording (belief) time [see `PR #2367 `_] * Continuing the query-parameter cleanup started in PR #2352: the chart-related endpoints now use ``prior``, ``start``, ``end`` and hyphenated field names, with a new ``duration`` field to derive a missing ``start``/``end``; old spellings keep working as legacy aliases [see `PR #2367 `_] diff --git a/flexmeasures/data/models/planning/scheduling_problem.py b/flexmeasures/data/models/planning/scheduling_problem.py index f54a82d130..ff10e3da9c 100644 --- a/flexmeasures/data/models/planning/scheduling_problem.py +++ b/flexmeasures/data/models/planning/scheduling_problem.py @@ -24,7 +24,11 @@ from flask import current_app from pandas.tseries.frequencies import to_offset -from flexmeasures.data.models.planning import Commitment, FlowCommitment +from flexmeasures.data.models.planning import ( + Commitment, + FlowCommitment, + StockCommitment, +) from flexmeasures.data.models.planning.utils import initialize_df, initialize_series infinity = float("inf") @@ -503,9 +507,34 @@ def prepare_scheduling_problem( # noqa C901 bigM_columns = ["derivative max", "derivative min", "derivative equals"] # Compute a good value for our Big-Ms # Md is used to constrain the search space for device power - # Mc is used to constrain the search space for commitment deviations Md = np.nanmax([np.nanmax(d[bigM_columns].abs()) for d in device_constraints]) - Mc = np.nansum([np.nansum(d[bigM_columns].abs()) for d in device_constraints]) + + # Mc is used to constrain the search space for commitment deviations. + # A deviation makes up the gap between a committed quantity and an aggregated device flow (or stock change), + # so it is bounded by the largest absolute committed quantity plus the devices' summed flow limits: + # summed at any one time step for flow commitments, + # and summed over the whole horizon for stock commitments (a stock change accumulates flows since the start). + # A device without flow limits at some time step contributes nothing there (as it did before this bound was tightened), + # its power being unbounded anyway. + per_device_step_limits = [ + d[bigM_columns].astype(float).abs().max(axis=1).fillna(0).to_numpy() + for d in device_constraints + ] + per_step_total = sum(per_device_step_limits) + Mc = float(np.max(per_step_total)) + has_stock_commitment = any( + "class" in c.columns and (c["class"] == StockCommitment).any() + for c in commitments + ) + if has_stock_commitment: + Mc = max(Mc, float(np.sum(per_step_total))) + if commitments: + quantities = np.abs( + np.concatenate([c["quantity"].to_numpy(dtype=float) for c in commitments]) + ) + finite_quantities = quantities[np.isfinite(quantities)] + if len(finite_quantities): + Mc += float(np.max(finite_quantities)) # Both Md and Mc have to be 1 MW, at least Md = max(Md, 1) diff --git a/flexmeasures/data/models/planning/tests/test_big_m.py b/flexmeasures/data/models/planning/tests/test_big_m.py new file mode 100644 index 0000000000..812de25369 --- /dev/null +++ b/flexmeasures/data/models/planning/tests/test_big_m.py @@ -0,0 +1,118 @@ +"""Tests for the big-M values bounding the scheduler's search space.""" + +from __future__ import annotations + +from datetime import timedelta + +import numpy as np +import pandas as pd + +from flexmeasures.data.models.planning import FlowCommitment, StockCommitment +from flexmeasures.data.models.planning.linear_optimization import device_scheduler +from flexmeasures.data.models.planning.scheduling_problem import ( + prepare_scheduling_problem, +) +from flexmeasures.data.models.planning.utils import initialize_df + +#: Run every test in this module under both scheduler backends (see conftest). +RUN_UNDER_EACH_SOLVER = True + +COLUMNS = [ + "equals", + "max", + "min", + "efficiency", + "derivative equals", + "derivative max", + "derivative min", + "derivative down efficiency", + "derivative up efficiency", + "stock delta", +] + +START = pd.Timestamp("2020-01-01T00:00:00") +END = pd.Timestamp("2020-01-01T04:00:00") +RESOLUTION = timedelta(hours=1) + + +def make_device_constraints(power_capacity: float) -> pd.DataFrame: + device_constraints = initialize_df(COLUMNS, START, END, RESOLUTION) + device_constraints["derivative max"] = power_capacity + device_constraints["derivative min"] = -power_capacity + return device_constraints + + +def make_index() -> pd.DatetimeIndex: + return initialize_df(COLUMNS, START, END, RESOLUTION).index + + +def test_Mc_covers_flow_limits_per_step_plus_the_committed_quantity(): + """For flow commitments, a deviation spans at most the committed quantity plus one time step's summed flow limits.""" + commitment = FlowCommitment( + name="energy", + quantity=-100, + upwards_deviation_price=1, + downwards_deviation_price=-1, + index=make_index(), + ) + problem = prepare_scheduling_problem( + device_constraints=[make_device_constraints(0.5), make_device_constraints(2)], + ems_constraints=initialize_df(COLUMNS, START, END, RESOLUTION), + commitments=[commitment], + ) + assert problem.Md == 2 + assert problem.Mc == 0.5 + 2 + 100 + + +def test_Mc_covers_the_horizon_for_stock_commitments(): + """A stock commitment's deviation accumulates flows since the start, so Mc must cover the whole horizon.""" + index = make_index() + commitment = StockCommitment( + name="soc", + quantity=0.5, + upwards_deviation_price=1, + downwards_deviation_price=-1, + device=pd.Series(0, index=index), + index=index, + ) + problem = prepare_scheduling_problem( + device_constraints=[make_device_constraints(0.5), make_device_constraints(2)], + ems_constraints=initialize_df(COLUMNS, START, END, RESOLUTION), + commitments=[commitment], + ) + # 4 time steps of 0.5 + 2 flow limits each, plus the committed quantity + assert problem.Mc == 4 * 2.5 + 0.5 + + +def test_Mc_is_at_least_one(): + problem = prepare_scheduling_problem( + device_constraints=[make_device_constraints(0.001)], + ems_constraints=initialize_df(COLUMNS, START, END, RESOLUTION), + ) + assert problem.Mc == 1 + + +def test_large_committed_quantity_remains_feasible_under_a_non_convex_cost_curve(): + """A committed quantity far beyond the devices' flow limits must not be cut off by Mc. + + The non-convex prices (summed upwards price below summed downwards price) activate the commitment-sign constraints, + in which Mc caps the deviations. + Before Mc accounted for the committed quantity, the required deviation exceeded Mc and the problem was infeasible. + """ + commitment = FlowCommitment( + name="energy", + quantity=-100, + upwards_deviation_price=-1, + downwards_deviation_price=1, + index=make_index(), + ) + schedule, costs, results, model = device_scheduler( + device_constraints=[make_device_constraints(0.5)], + ems_constraints=initialize_df(COLUMNS, START, END, RESOLUTION), + commitments=[commitment], + ) + assert results.solver.termination_condition == "optimal" + # The upwards deviation earns 1 per unit, so the device consumes at full power + np.testing.assert_allclose(schedule[0].values, 0.5, atol=1e-6) + # Each of the 4 steps deviates upwards by 100.5 at price -1 + assert costs == -4 * 100.5 From da16b14356ee7c2d62303510fef4a6da60029222 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 7 Aug 2026 16:04:49 +0200 Subject: [PATCH 2/7] Reference PR #2411 in the changelog entry Signed-off-by: F.N. Claessen --- documentation/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 367b18ad64..e68b3b2e4c 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -87,7 +87,7 @@ Infrastructure / Support Bugfixes ----------- -* The big-M bounding commitment deviations now accounts for the committed quantity, so a committed quantity far beyond the devices' flow limits no longer renders the problem infeasible when a non-convex cost curve activates the commitment-sign constraints; the bound is also tighter (covering one time step rather than the whole horizon, for flow commitments), which improves MIP numerics [see `PR #XXXX `_] +* The big-M bounding commitment deviations now accounts for the committed quantity, so a committed quantity far beyond the devices' flow limits no longer renders the problem infeasible when a non-convex cost curve activates the commitment-sign constraints; the bound is also tighter (covering one time step rather than the whole horizon, for flow commitments), which improves MIP numerics [see `PR #2411 `_] * Show icons for more asset types in the UI's asset structure view, which previously fell back to a question mark: the ``wind``, ``process`` and ``heat-storage`` types that FlexMeasures seeds by default, and EV infrastructure under its various names (such as ``one-way_evse``, ``two-way_evse``, ``evse``, ``charging_station`` and ``charging_hub``) and building services equipment (``hvac``, ``ahu``, ``dhw``, ``heatpump``, ``chiller``, ``lighting`` and ``other-loads``). Asset type names are now matched ignoring case and separators, so an asset type named ``charge-point`` gets the same icon as ``chargepoint`` [see `PR #2391 `_] * Replaying a chart for a past window no longer shows annotations that were only recorded later; annotation searches and the ``chart_annotations`` endpoints can now be scoped by recording (belief) time [see `PR #2367 `_] * Continuing the query-parameter cleanup started in PR #2352: the chart-related endpoints now use ``prior``, ``start``, ``end`` and hyphenated field names, with a new ``duration`` field to derive a missing ``start``/``end``; old spellings keep working as legacy aliases [see `PR #2367 `_] From 3104c261cea4d374ae423fd456901cda575b81a6 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 7 Aug 2026 16:24:22 +0200 Subject: [PATCH 3/7] Assert the aggregate commitment's cost instead of the degenerate device split The tightened Mc changes the model coefficients, which lands the solver on another vertex of the same optimum: total costs, the energy commitment's cost and the sample supply commitment's reward are unchanged, but the EV/battery split (both devices face the same prices) moves. The split was standing in for the aggregate commitment semantics of issue #2379, which the sample commitment's own reported cost captures directly: per-device binding would reward the battery's supply also while the site is net-consuming. Co-Authored-By: Claude Fable 5 Signed-off-by: F.N. Claessen --- .../tests/test_scheduling_simultaneous.py | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/flexmeasures/data/tests/test_scheduling_simultaneous.py b/flexmeasures/data/tests/test_scheduling_simultaneous.py index ebf4c4cd8e..07df22ec9d 100644 --- a/flexmeasures/data/tests/test_scheduling_simultaneous.py +++ b/flexmeasures/data/tests/test_scheduling_simultaneous.py @@ -134,13 +134,6 @@ def test_create_simultaneous_jobs( # Define expected costs based on resolution expected_total_cost = -3.2775 - # Aggregate (unscoped) commitment semantics (issue #2379): the sample commitment - # rewarding supply binds the site's *aggregate* flow, so it stays inactive while - # the site is net-consuming and does not bias the per-device dispatch. Under the - # earlier per-device binding it wrongly rewarded the battery's supply, shifting the - # EV/battery split (EV costs were €2.3125); the total cost is unchanged either way. - expected_ev_costs = 2.2375 - expected_battery_costs = expected_total_cost - expected_ev_costs # Check costs np.testing.assert_approx_equal( @@ -149,17 +142,21 @@ def test_create_simultaneous_jobs( 4, f"Total costs should be €{expected_total_cost}, got €{total_cost}", ) + # Aggregate (unscoped) commitment semantics (issue #2379): the sample commitment + # rewarding supply binds the site's *aggregate* flow, + # so it collects a reward only where the site as a whole net-produces. + # Under the earlier per-device binding it wrongly rewarded the battery's own supply, + # also while the site was net-consuming. + # The EV/battery split itself is not asserted: + # the optimum is degenerate (both devices face the same prices), + # so the split depends on the solver path rather than on the model's semantics. np.testing.assert_approx_equal( - ev_costs, - expected_ev_costs, - 4, - f"EV costs should be €{expected_ev_costs}, got €{ev_costs}", - ) - np.testing.assert_approx_equal( - battery_costs, - expected_battery_costs, + job.meta["scheduler_info"]["commitment_costs"][ + "a sample commitment rewarding supply" + ], + -0.038, 4, - f"Battery costs should be €{expected_battery_costs}, got €{battery_costs}", + "the aggregate flow commitment should be rewarded for the site's aggregate net supply only", ) np.testing.assert_approx_equal( job.meta["scheduler_info"]["commitment_costs"]["electricity net energy"], From faf6cf2c0b2809b65e00f337ea5189265979a2b3 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 7 Aug 2026 16:37:38 +0200 Subject: [PATCH 4/7] Address Copilot review: scale stock-change bounds by conversion efficiencies A stock change is not a raw flow: it passes through the derivative efficiencies and includes the explicit stock delta, so with a conversion gain above one (or a nonzero stock delta) the horizon-summed flow limits under-bounded a stock commitment's deviation. Co-Authored-By: Claude Fable 5 Signed-off-by: F.N. Claessen --- .../models/planning/scheduling_problem.py | 29 ++++++++++++++++++- .../data/models/planning/tests/test_big_m.py | 25 ++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/scheduling_problem.py b/flexmeasures/data/models/planning/scheduling_problem.py index ca0a907d7b..dfcc580936 100644 --- a/flexmeasures/data/models/planning/scheduling_problem.py +++ b/flexmeasures/data/models/planning/scheduling_problem.py @@ -527,7 +527,34 @@ def prepare_scheduling_problem( # noqa C901 for c in commitments ) if has_stock_commitment: - Mc = max(Mc, float(np.sum(per_step_total))) + # A stock change is not a raw flow: + # it passes through the derivative efficiencies (P_up * eta_up + P_down / eta_down) and includes the explicit stock delta, + # so each device's flow limits are scaled by its worst-case conversion gain, and its stock deltas are added, + # before summing over the horizon. + horizon_stock_change_limit = 0.0 + for d, flow_limits in zip(device_constraints, per_device_step_limits): + gain = np.ones(len(flow_limits)) + if "derivative up efficiency" in d.columns: + gain = np.maximum( + gain, + d["derivative up efficiency"].astype(float).fillna(1).to_numpy(), + ) + if "derivative down efficiency" in d.columns: + gain = np.maximum( + gain, + 1 + / d["derivative down efficiency"] + .astype(float) + .fillna(1) + .to_numpy(), + ) + deltas = ( + d["stock delta"].astype(float).fillna(0).abs().to_numpy() + if "stock delta" in d.columns + else 0.0 + ) + horizon_stock_change_limit += float(np.sum(flow_limits * gain + deltas)) + Mc = max(Mc, horizon_stock_change_limit) if commitments: quantities = np.abs( np.concatenate([c["quantity"].to_numpy(dtype=float) for c in commitments]) diff --git a/flexmeasures/data/models/planning/tests/test_big_m.py b/flexmeasures/data/models/planning/tests/test_big_m.py index 812de25369..5b016c97b9 100644 --- a/flexmeasures/data/models/planning/tests/test_big_m.py +++ b/flexmeasures/data/models/planning/tests/test_big_m.py @@ -84,6 +84,31 @@ def test_Mc_covers_the_horizon_for_stock_commitments(): assert problem.Mc == 4 * 2.5 + 0.5 +def test_Mc_scales_stock_changes_by_conversion_efficiencies_and_stock_deltas(): + """A stock change passes through the derivative efficiencies and includes the stock delta, unlike a raw flow.""" + index = make_index() + commitment = StockCommitment( + name="soc", + quantity=0.5, + upwards_deviation_price=1, + downwards_deviation_price=-1, + device=pd.Series(0, index=index), + index=index, + ) + device_constraints = make_device_constraints(0.5) + device_constraints["derivative up efficiency"] = 2 + device_constraints["derivative down efficiency"] = 0.5 + device_constraints["stock delta"] = 0.25 + problem = prepare_scheduling_problem( + device_constraints=[device_constraints], + ems_constraints=initialize_df(COLUMNS, START, END, RESOLUTION), + commitments=[commitment], + ) + # 4 time steps of 0.5 flow scaled by the worst-case conversion gain max(2, 1/0.5) plus a 0.25 stock delta, + # plus the committed quantity + assert problem.Mc == 4 * (0.5 * 2 + 0.25) + 0.5 + + def test_Mc_is_at_least_one(): problem = prepare_scheduling_problem( device_constraints=[make_device_constraints(0.001)], From d8262d0d72c70fd2547764773b48a3d230e41c4e Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 7 Aug 2026 16:41:30 +0200 Subject: [PATCH 5/7] End comment lines at punctuation, per the docstring conventions Co-Authored-By: Claude Fable 5 Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/tests/test_big_m.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_big_m.py b/flexmeasures/data/models/planning/tests/test_big_m.py index 5b016c97b9..5833baf214 100644 --- a/flexmeasures/data/models/planning/tests/test_big_m.py +++ b/flexmeasures/data/models/planning/tests/test_big_m.py @@ -80,7 +80,7 @@ def test_Mc_covers_the_horizon_for_stock_commitments(): ems_constraints=initialize_df(COLUMNS, START, END, RESOLUTION), commitments=[commitment], ) - # 4 time steps of 0.5 + 2 flow limits each, plus the committed quantity + # 4 time steps of 0.5 + 2 flow limits each, plus the committed quantity. assert problem.Mc == 4 * 2.5 + 0.5 @@ -105,7 +105,7 @@ def test_Mc_scales_stock_changes_by_conversion_efficiencies_and_stock_deltas(): commitments=[commitment], ) # 4 time steps of 0.5 flow scaled by the worst-case conversion gain max(2, 1/0.5) plus a 0.25 stock delta, - # plus the committed quantity + # plus the committed quantity. assert problem.Mc == 4 * (0.5 * 2 + 0.25) + 0.5 @@ -137,7 +137,7 @@ def test_large_committed_quantity_remains_feasible_under_a_non_convex_cost_curve commitments=[commitment], ) assert results.solver.termination_condition == "optimal" - # The upwards deviation earns 1 per unit, so the device consumes at full power + # The upwards deviation earns 1 per unit, so the device consumes at full power. np.testing.assert_allclose(schedule[0].values, 0.5, atol=1e-6) - # Each of the 4 steps deviates upwards by 100.5 at price -1 + # Each of the 4 steps deviates upwards by 100.5 at price -1. assert costs == -4 * 100.5 From b1e960eea8c64b18ea774daa0ca65ff1632f2b4c Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 7 Aug 2026 16:54:11 +0200 Subject: [PATCH 6/7] Address suppressed review notes: comment line breaks and approximate cost equality Co-Authored-By: Claude Fable 5 Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/tests/test_big_m.py | 3 ++- flexmeasures/data/tests/test_scheduling_simultaneous.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_big_m.py b/flexmeasures/data/models/planning/tests/test_big_m.py index 5833baf214..b0b18a90df 100644 --- a/flexmeasures/data/models/planning/tests/test_big_m.py +++ b/flexmeasures/data/models/planning/tests/test_big_m.py @@ -6,6 +6,7 @@ import numpy as np import pandas as pd +import pytest from flexmeasures.data.models.planning import FlowCommitment, StockCommitment from flexmeasures.data.models.planning.linear_optimization import device_scheduler @@ -140,4 +141,4 @@ def test_large_committed_quantity_remains_feasible_under_a_non_convex_cost_curve # The upwards deviation earns 1 per unit, so the device consumes at full power. np.testing.assert_allclose(schedule[0].values, 0.5, atol=1e-6) # Each of the 4 steps deviates upwards by 100.5 at price -1. - assert costs == -4 * 100.5 + assert costs == pytest.approx(-4 * 100.5) diff --git a/flexmeasures/data/tests/test_scheduling_simultaneous.py b/flexmeasures/data/tests/test_scheduling_simultaneous.py index 07df22ec9d..5953482011 100644 --- a/flexmeasures/data/tests/test_scheduling_simultaneous.py +++ b/flexmeasures/data/tests/test_scheduling_simultaneous.py @@ -142,8 +142,8 @@ def test_create_simultaneous_jobs( 4, f"Total costs should be €{expected_total_cost}, got €{total_cost}", ) - # Aggregate (unscoped) commitment semantics (issue #2379): the sample commitment - # rewarding supply binds the site's *aggregate* flow, + # Aggregate (unscoped) commitment semantics (issue #2379): + # the sample commitment rewarding supply binds the site's *aggregate* flow, # so it collects a reward only where the site as a whole net-produces. # Under the earlier per-device binding it wrongly rewarded the battery's own supply, # also while the site was net-consuming. From ab7ba78d3a29c8fdbf91db1d22201c085bcf2c49 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 7 Aug 2026 17:33:33 +0200 Subject: [PATCH 7/7] Restore the EV/battery cost split as a fairness benchmark Requested in review: the split records which vertex of the degenerate optimum the solver lands on, so it stays asserted as a benchmark of how model changes impact fairness, with the expectation updated deliberately when a change moves it. The tightened Mc moves the EV costs from 2.2375 to 2.3125. Co-Authored-By: Claude Fable 5 Signed-off-by: F.N. Claessen --- .../tests/test_scheduling_simultaneous.py | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/tests/test_scheduling_simultaneous.py b/flexmeasures/data/tests/test_scheduling_simultaneous.py index 5953482011..a83e0f0b5c 100644 --- a/flexmeasures/data/tests/test_scheduling_simultaneous.py +++ b/flexmeasures/data/tests/test_scheduling_simultaneous.py @@ -142,14 +142,32 @@ def test_create_simultaneous_jobs( 4, f"Total costs should be €{expected_total_cost}, got €{total_cost}", ) + # Fairness benchmark: how the total cost is split between the devices. + # The optimum is degenerate (both devices face the same prices), + # so the split records which vertex the solver lands on, and model changes may move it. + # When one does, update the expectation deliberately, as a record of the change's fairness impact. + # PR #2411 (tightened Mc, changing the sign-constraint coefficients) moved the EV costs from €2.2375 to €2.3125, + # which happens to match the split seen under the old per-device commitment binding of issue #2379; + # that semantics is now pinned directly by the commitment-cost assertion below, rather than by the split. + expected_ev_costs = 2.3125 + expected_battery_costs = expected_total_cost - expected_ev_costs + np.testing.assert_approx_equal( + ev_costs, + expected_ev_costs, + 4, + f"EV costs should be €{expected_ev_costs}, got €{ev_costs}", + ) + np.testing.assert_approx_equal( + battery_costs, + expected_battery_costs, + 4, + f"Battery costs should be €{expected_battery_costs}, got €{battery_costs}", + ) # Aggregate (unscoped) commitment semantics (issue #2379): # the sample commitment rewarding supply binds the site's *aggregate* flow, # so it collects a reward only where the site as a whole net-produces. # Under the earlier per-device binding it wrongly rewarded the battery's own supply, # also while the site was net-consuming. - # The EV/battery split itself is not asserted: - # the optimum is degenerate (both devices face the same prices), - # so the split depends on the solver path rather than on the model's semantics. np.testing.assert_approx_equal( job.meta["scheduler_info"]["commitment_costs"][ "a sample commitment rewarding supply"