From 82f807ec91da001378a9a7a822e5e03f4bb59ccc Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 13 Mar 2026 16:21:40 +0100 Subject: [PATCH 01/25] fix: wrong timezone; the test relied on the preference to charge sooner and discharge later, rather than on the EPEX price transition, as the inline test documentation advertised Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/tests/test_solver.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 919936d315..1f0a5baa27 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -2235,11 +2235,14 @@ def test_battery_storage_different_units( battery_name="Test battery", power_sensor_name=power_sensor_name, ) - tz = pytz.timezone("Europe/Amsterdam") + tz = pytz.timezone(epex_da.timezone) # transition from cheap to expensive (90 -> 100) start = tz.localize(datetime(2015, 1, 2, 14, 0, 0)) end = tz.localize(datetime(2015, 1, 2, 16, 0, 0)) + assert len(epex_da.search_beliefs(start, end)) == 2 + assert epex_da.search_beliefs(start, end).values[0][0] == 90 + assert epex_da.search_beliefs(start, end).values[1][0] == 100 resolution = timedelta(minutes=15) flex_model = { From 128550f5b03f76e50bae7ec8f87b31162fb25d9d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 13 Mar 2026 17:17:24 +0100 Subject: [PATCH 02/25] feat: move preference to charge sooner and discharge later into a StockCommitment to prefer being full Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 35 +++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 6fb7aa5e94..dd53db19fc 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -198,17 +198,6 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 start = pd.Timestamp(start).tz_convert("UTC") end = pd.Timestamp(end).tz_convert("UTC") - # Add tiny price slope to prefer charging now rather than later, and discharging later rather than now. - # We penalise future consumption and reward future production with at most 1 per thousand times the energy price spread. - # todo: move to flow or stock commitment per device - if any(prefer_charging_sooner): - up_deviation_prices = add_tiny_price_slope( - up_deviation_prices, "event_value" - ) - down_deviation_prices = add_tiny_price_slope( - down_deviation_prices, "event_value" - ) - # Create Series with EMS capacities ems_power_capacity_in_mw = get_continuous_series_sensor_or_quantity( variable_quantity=self.flex_context.get("ems_power_capacity_in_mw"), @@ -463,6 +452,28 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 ) commitments.append(commitment) + # Use a tiny price slope to prefer a fuller SoC sooner rather than later + # This corresponds to a preference for charging now rather than later, and discharging later rather than now. + # We penalise future consumption and reward future production with at most 1 per thousand times the energy price spread. + for d, prefer_charging_sooner_d in enumerate(prefer_charging_sooner): + if prefer_charging_sooner_d: + tiny_price_slope = ( + add_tiny_price_slope(up_deviation_prices, "event_value") + - up_deviation_prices + ) + commitment = StockCommitment( + name=f"prefer charging device {d} sooner", + quantity=soc_max[d] - soc_at_start[d], + # Prefer curtailing consumption later by penalizing later consumption + upwards_deviation_price=0, + # Prefer curtailing production later by penalizing later production + downwards_deviation_price=-0.00000001, + # downwards_deviation_price=-tiny_price_slope / 1000000,#0.00000001, + index=index, + device=d, + ) + commitments.append(commitment) + # Set up device constraints: scheduled flexible devices for this EMS (from index 0 to D-1), plus the forecasted inflexible devices (at indices D to n). device_constraints = [ initialize_df(StorageScheduler.COLUMNS, start, end, resolution) @@ -940,7 +951,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 def convert_to_commitments( self, **timing_kwargs, - ) -> list[FlowCommitment]: + ) -> list[FlowCommitment | StockCommitment]: """Convert list of commitment specifications (dicts) to a list of FlowCommitments.""" commitment_specs = self.flex_context.get("commitments", []) if len(commitment_specs) == 0: From 130b9dd6528291903cb7582d2763e843ca94241b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 13 Mar 2026 17:38:42 +0100 Subject: [PATCH 03/25] fix: test case no longer relies on arbitrage opportunity coming from artificial price slope Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/tests/test_solver.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 1f0a5baa27..d85a45243b 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -816,8 +816,8 @@ def compute_schedule(flex_model): # soc maxima and soc minima soc_maxima = [ - {"datetime": "2015-01-02T15:00:00+01:00", "value": 1.0}, - {"datetime": "2015-01-02T16:00:00+01:00", "value": 1.0}, + {"datetime": "2015-01-02T12:00:00+01:00", "value": 1.0}, + {"datetime": "2015-01-02T13:00:00+01:00", "value": 1.0}, ] soc_minima = [{"datetime": "2015-01-02T08:00:00+01:00", "value": 3.5}] @@ -853,7 +853,7 @@ def compute_schedule(flex_model): # test for soc_maxima # check that the local maximum constraint is respected - assert soc_schedule_2.loc["2015-01-02T15:00:00+01:00"] <= 1.0 + assert soc_schedule_2.loc["2015-01-02T13:00:00+01:00"] <= 1.0 # test for soc_targets # check that the SOC target (at 19 pm, local time) is met From 1eab8282406d85d343ed6b22f73f781f68e45777 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 13 Mar 2026 17:40:42 +0100 Subject: [PATCH 04/25] feat: check for optimal schedule Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/tests/test_solver.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index d85a45243b..6ee32f8b59 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -270,6 +270,7 @@ def run_test_charge_discharge_sign( for soc_at_start_d in soc_at_start ], ) + assert results.solver.termination_condition == "optimal" device_power_sign = pd.Series(model.device_power_sign.extract_values())[0] device_power_up = pd.Series(model.device_power_up.extract_values())[0] From b9bc4a9c0786907c25737a2b5c20492501cf79ec Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 13 Mar 2026 17:58:51 +0100 Subject: [PATCH 05/25] feat: prefer a full storage earlier over later Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 6 ++++-- flexmeasures/data/models/planning/utils.py | 19 +++++++++++++++---- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index dd53db19fc..b473ed650a 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -458,7 +458,9 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 for d, prefer_charging_sooner_d in enumerate(prefer_charging_sooner): if prefer_charging_sooner_d: tiny_price_slope = ( - add_tiny_price_slope(up_deviation_prices, "event_value") + add_tiny_price_slope( + up_deviation_prices, "event_value", order="desc" + ) - up_deviation_prices ) commitment = StockCommitment( @@ -467,7 +469,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 # Prefer curtailing consumption later by penalizing later consumption upwards_deviation_price=0, # Prefer curtailing production later by penalizing later production - downwards_deviation_price=-0.00000001, + downwards_deviation_price=-tiny_price_slope, # downwards_deviation_price=-tiny_price_slope / 1000000,#0.00000001, index=index, device=d, diff --git a/flexmeasures/data/models/planning/utils.py b/flexmeasures/data/models/planning/utils.py index 549ef6a01a..c7e20860f0 100644 --- a/flexmeasures/data/models/planning/utils.py +++ b/flexmeasures/data/models/planning/utils.py @@ -2,6 +2,7 @@ from packaging import version from datetime import date, datetime, timedelta +from typing import Literal from flask import current_app import pandas as pd @@ -67,7 +68,10 @@ def initialize_index( def add_tiny_price_slope( - orig_prices: pd.DataFrame, col_name: str = "event_value", d: float = 10**-4 + orig_prices: pd.DataFrame, + col_name: str = "event_value", + d: float = 10**-4, + order: Literal["asc", "desc"] = "asc", ) -> pd.DataFrame: """Add tiny price slope to col_name to represent e.g. inflation as a simple linear price increase. This is meant to break ties, when multiple time slots have equal prices, in favour of acting sooner. @@ -79,9 +83,16 @@ def add_tiny_price_slope( max_penalty = price_spread * d else: max_penalty = d - prices[col_name] = prices[col_name] + np.linspace( - 0, max_penalty, prices[col_name].size - ) + if order == "asc": + prices[col_name] = prices[col_name] + np.linspace( + 0, max_penalty, prices[col_name].size + ) + elif order == "desc": + prices[col_name] = prices[col_name] + np.linspace( + max_penalty, 0, prices[col_name].size + ) + else: + raise ValueError("order must be 'asc' or 'desc'") return prices From 57df5c31d9f6e3e9a868c3e35bff4db7fda2d003 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 13 Mar 2026 18:04:18 +0100 Subject: [PATCH 06/25] docs: update commitment name and inline comments Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index b473ed650a..3c2388d435 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -464,11 +464,10 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 - up_deviation_prices ) commitment = StockCommitment( - name=f"prefer charging device {d} sooner", + name=f"prefer a full storage {d} sooner", quantity=soc_max[d] - soc_at_start[d], - # Prefer curtailing consumption later by penalizing later consumption upwards_deviation_price=0, - # Prefer curtailing production later by penalizing later production + # Penalize not being full, with lower penalties later downwards_deviation_price=-tiny_price_slope, # downwards_deviation_price=-tiny_price_slope / 1000000,#0.00000001, index=index, From ce71637238b1d4c1eb81a400edb1fce9a7c9a67c Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sat, 14 Mar 2026 11:30:46 +0100 Subject: [PATCH 07/25] docs: touch up test explanation Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/tests/test_solver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 6ee32f8b59..b46ee0f0b1 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -1788,7 +1788,7 @@ def test_battery_stock_delta_sensor( - Battery of size 2 MWh. - Consumption capacity of the battery is 2 MW. - The battery cannot discharge. - With these settings, the battery needs to charge at a power or greater than the usage forecast + With these settings, the battery needs to charge at a power equal or greater than the usage forecast to keep the SOC within bounds ([0, 2 MWh]). """ _, battery = get_sensors_from_db(db, add_battery_assets) From f6183df2df24639858907f7ae681636673d8140b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sat, 14 Mar 2026 11:43:55 +0100 Subject: [PATCH 08/25] fix: update test case given preference for a full battery Signed-off-by: F.N. Claessen --- .../data/models/planning/tests/test_solver.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index b46ee0f0b1..e810e8c854 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -1791,7 +1791,7 @@ def test_battery_stock_delta_sensor( With these settings, the battery needs to charge at a power equal or greater than the usage forecast to keep the SOC within bounds ([0, 2 MWh]). """ - _, battery = get_sensors_from_db(db, add_battery_assets) + epex_da, battery = get_sensors_from_db(db, add_battery_assets) tz = pytz.timezone("Europe/Amsterdam") start = tz.localize(datetime(2015, 1, 1)) end = tz.localize(datetime(2015, 1, 2)) @@ -1836,9 +1836,21 @@ def test_battery_stock_delta_sensor( with pytest.raises(InfeasibleProblemException): scheduler.compute() elif stock_delta_sensor is None: - # No usage -> the battery does not charge + # No usage -> the battery only charges when energy is free + free_hour = "2015-01-01 17:00:00+00:00" + prices = epex_da.search_beliefs(start, end) + zero_prices = prices[prices.event_value == 0] + assert len(zero_prices) == 1, "this test assumes a single hour of free energy" + assert ( + len(zero_prices[zero_prices.event_starts == free_hour]) == 1 + ), "this test assumes free energy from 5 to 6 PM UTC" schedule = scheduler.compute() - assert all(schedule == 0) + assert all( + schedule[schedule.index != free_hour] == 0 + ), "no charging expected when energy is not free, given no soc-usage" + assert all( + schedule[schedule.index == free_hour] == capacity + ), "max charging expected when energy is free, because of preference to have a full SoC" else: # Some usage -> the battery needs to charge schedule = scheduler.compute() From ed471a8b836c60da2964e2a666c4613db98bcf5d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sat, 14 Mar 2026 11:46:52 +0100 Subject: [PATCH 09/25] delete: clean up comment Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 1 - 1 file changed, 1 deletion(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 3c2388d435..4d1e551c95 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -469,7 +469,6 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 upwards_deviation_price=0, # Penalize not being full, with lower penalties later downwards_deviation_price=-tiny_price_slope, - # downwards_deviation_price=-tiny_price_slope / 1000000,#0.00000001, index=index, device=d, ) From 7611fb9eadbde61efa317f8ace3e7adbb9d971de Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sat, 14 Mar 2026 11:59:56 +0100 Subject: [PATCH 10/25] feat: model the preference to curtail later within the same StockCommitment, using a tiny price slope to prefer a fuller SoC sooner rather than later, by lowering penalties later Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 38 +++++++------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 4d1e551c95..44024c0cb8 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -430,32 +430,13 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 # Take the contracted capacity as a hard constraint ems_constraints["derivative min"] = ems_production_capacity - # Flow commitments per device + # Commitments per device - # Add tiny price slope to prefer curtailing later rather than now. - # The price slope is half of the slope to prefer charging sooner - for d, prefer_curtailing_later_d in enumerate(prefer_curtailing_later): - if prefer_curtailing_later_d: - tiny_price_slope = ( - add_tiny_price_slope(up_deviation_prices, "event_value") - - up_deviation_prices - ) - tiny_price_slope *= 0.5 - commitment = FlowCommitment( - name=f"prefer curtailing device {d} later", - # Prefer curtailing consumption later by penalizing later consumption - upwards_deviation_price=tiny_price_slope, - # Prefer curtailing production later by penalizing later production - downwards_deviation_price=-tiny_price_slope, - index=index, - device=d, - ) - commitments.append(commitment) - - # Use a tiny price slope to prefer a fuller SoC sooner rather than later + # StockCommitment per device to prefer a full storage by penalizing not being full # This corresponds to a preference for charging now rather than later, and discharging later rather than now. - # We penalise future consumption and reward future production with at most 1 per thousand times the energy price spread. - for d, prefer_charging_sooner_d in enumerate(prefer_charging_sooner): + for d, (prefer_charging_sooner_d, prefer_curtailing_later_d) in enumerate( + zip(prefer_charging_sooner, prefer_curtailing_later) + ): if prefer_charging_sooner_d: tiny_price_slope = ( add_tiny_price_slope( @@ -463,12 +444,17 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 ) - up_deviation_prices ) + if prefer_curtailing_later: + # Use a tiny price slope to prefer a fuller SoC sooner rather than later, by lowering penalties later + penalty = tiny_price_slope + else: + # Constant penalty + penalty = tiny_price_slope[0] commitment = StockCommitment( name=f"prefer a full storage {d} sooner", quantity=soc_max[d] - soc_at_start[d], upwards_deviation_price=0, - # Penalize not being full, with lower penalties later - downwards_deviation_price=-tiny_price_slope, + downwards_deviation_price=-penalty, index=index, device=d, ) From bf16e63606ed5a2889fe25b45c0fd4f28a40b486 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sat, 14 Mar 2026 12:17:35 +0100 Subject: [PATCH 11/25] fix: reduce tiny price slope Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 44024c0cb8..5ef5517b36 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -440,7 +440,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 if prefer_charging_sooner_d: tiny_price_slope = ( add_tiny_price_slope( - up_deviation_prices, "event_value", order="desc" + up_deviation_prices, "event_value", d=10**-7, order="desc" ) - up_deviation_prices ) From 06c30dc297b85f266b47e990523ccd4b90b28e1f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 16 Mar 2026 13:00:41 +0100 Subject: [PATCH 12/25] docs: delete duplicate changelog entry Signed-off-by: F.N. Claessen --- documentation/changelog.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 0890c4d01a..4c768d68bd 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -68,7 +68,6 @@ New features * Improved the UX for creating sensors, clicking on ``Enter`` now validates and creates a sensor [see `PR #1876 `_] * Show zero values in bar charts even though they have 0 area [see `PR #1932 `_ and `PR #1936 `_] * Added ``root`` and ``depth`` fields to the `[GET] /assets` endpoint for listing assets, to allow selecting descendants of a given root asset up to a given depth [see `PR #1874 `_] -* Give ability to edit sensor timezone from the UI [see `PR #1900 `_] * Support creating schedules with only information known prior to some time, now also via the CLI (the API already supported it) [see `PR #1871 `_]. * Added capability to update an asset's parent from the UI [`PR #1957 `_] * Add ``fields`` param to the asset-listing endpoints, to save bandwidth in response data [see `PR #1884 `_] From d99089b2844a90667ec3e789fbb6f9d3d26e8c6d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 18 Mar 2026 15:47:47 +0100 Subject: [PATCH 13/25] docs: fix broken link Signed-off-by: F.N. Claessen --- documentation/dev/setup-and-guidelines.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/dev/setup-and-guidelines.rst b/documentation/dev/setup-and-guidelines.rst index 3b7ae6e246..d5d9b7458d 100644 --- a/documentation/dev/setup-and-guidelines.rst +++ b/documentation/dev/setup-and-guidelines.rst @@ -63,7 +63,7 @@ On Linux and Windows, everything will be installed using Python packages. On MacOS, this will install all test dependencies, and locally install the HiGHS solver. For this to work, make sure you have `Homebrew `_ installed. -Besides the HiGHS solver (as the current default), the CBC solver is required for tests as well. See `The install instructions `_ for more information. Configuration ^^^^^^^^^^^^^ From cf01f1d16df71c214733062c4b9b75a6df24a9d3 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 18 Mar 2026 16:56:39 +0100 Subject: [PATCH 14/25] Revert "fix: reduce tiny price slope" This reverts commit bf16e63606ed5a2889fe25b45c0fd4f28a40b486. Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 5ef5517b36..44024c0cb8 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -440,7 +440,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 if prefer_charging_sooner_d: tiny_price_slope = ( add_tiny_price_slope( - up_deviation_prices, "event_value", d=10**-7, order="desc" + up_deviation_prices, "event_value", order="desc" ) - up_deviation_prices ) From bdbdead8337d44d3442a2cf5e05940874a8592aa Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 18 Mar 2026 17:42:53 +0100 Subject: [PATCH 15/25] fix: soc unit conversion Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 44024c0cb8..5a22572311 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -452,7 +452,8 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 penalty = tiny_price_slope[0] commitment = StockCommitment( name=f"prefer a full storage {d} sooner", - quantity=soc_max[d] - soc_at_start[d], + quantity=(soc_max[d] - soc_at_start[d]) + * (timedelta(hours=1) / resolution), upwards_deviation_price=0, downwards_deviation_price=-penalty, index=index, From f987706a7e70273c3a817b4494e63af137624322 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 18 Mar 2026 17:49:42 +0100 Subject: [PATCH 16/25] fix: adapt test to check for 1 hour of free energy at 15-min scheduling resolution Signed-off-by: F.N. Claessen --- .../data/models/planning/tests/test_solver.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index e810e8c854..a45a3dd2d9 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -1838,18 +1838,17 @@ def test_battery_stock_delta_sensor( elif stock_delta_sensor is None: # No usage -> the battery only charges when energy is free free_hour = "2015-01-01 17:00:00+00:00" - prices = epex_da.search_beliefs(start, end) + prices = epex_da.search_beliefs(start, end, resolution=resolution) zero_prices = prices[prices.event_value == 0] - assert len(zero_prices) == 1, "this test assumes a single hour of free energy" - assert ( - len(zero_prices[zero_prices.event_starts == free_hour]) == 1 - ), "this test assumes free energy from 5 to 6 PM UTC" + assert all( + zero_prices.event_starts.hour == pd.Timestamp(free_hour).hour + ), "this test assumes a single hour of free energy from 5 to 6 PM UTC" schedule = scheduler.compute() assert all( - schedule[schedule.index != free_hour] == 0 + schedule[~schedule.index.isin(zero_prices.event_starts)] == 0 ), "no charging expected when energy is not free, given no soc-usage" assert all( - schedule[schedule.index == free_hour] == capacity + schedule[schedule.index.isin(zero_prices.event_starts)] == capacity ), "max charging expected when energy is free, because of preference to have a full SoC" else: # Some usage -> the battery needs to charge From 05aed7e2939b93065dc9b31058254c0a8909cfb4 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Mar 2026 12:20:22 +0100 Subject: [PATCH 17/25] fix: check curtailment preference per distinct device Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 5a22572311..dc8335e885 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -444,7 +444,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 ) - up_deviation_prices ) - if prefer_curtailing_later: + if prefer_curtailing_later_d: # Use a tiny price slope to prefer a fuller SoC sooner rather than later, by lowering penalties later penalty = tiny_price_slope else: From e55f638097a5e9b70f4bc4ac8081d769274891ce Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Mar 2026 14:20:56 +0100 Subject: [PATCH 18/25] fix: set tight tolerance for HiGHS solver Signed-off-by: F.N. Claessen --- .../data/models/planning/linear_optimization.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 005940456e..0cc5e1ea65 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -626,12 +626,26 @@ def cost_function(m): if cbc_path is not None: solver.set_executable(cbc_path) + # Set tight tolerance for HiGHS solver + profile = {} + if "highs" in solver_name.lower(): + profile = { + "mip_rel_gap": "0", + "mip_abs_gap": "0", + "primal_feasibility_tolerance": "1e-9", + "dual_feasibility_tolerance": "1e-9", + "mip_feasibility_tolerance": "1e-9", + } + # disable logs for the HiGHS solver in case that LOGGING_LEVEL is INFO if current_app.config["LOGGING_LEVEL"] == "INFO" and ( "highs" in solver_name.lower() ): solver.options["output_flag"] = "false" + for option_name, option_value in profile.items(): + solver.options[option_name] = option_value + # load_solutions=False to avoid a RuntimeError exception in appsi solvers when solving an infeasible problem. results = solver.solve(model, load_solutions=False) From 764712f8fc0aeca4128f494978d53d54abe20fb3 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Mar 2026 14:21:44 +0100 Subject: [PATCH 19/25] refactor: merge if-blocks Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/linear_optimization.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 0cc5e1ea65..93b919d6bb 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -636,12 +636,9 @@ def cost_function(m): "dual_feasibility_tolerance": "1e-9", "mip_feasibility_tolerance": "1e-9", } - - # disable logs for the HiGHS solver in case that LOGGING_LEVEL is INFO - if current_app.config["LOGGING_LEVEL"] == "INFO" and ( - "highs" in solver_name.lower() - ): - solver.options["output_flag"] = "false" + # disable logs for the HiGHS solver in case that LOGGING_LEVEL is INFO + if current_app.config["LOGGING_LEVEL"] == "INFO": + profile["output_flag"] = "false" for option_name, option_value in profile.items(): solver.options[option_name] = option_value From 9070eaea798346278d855980f322608855375a1d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 23 Mar 2026 09:33:57 +0100 Subject: [PATCH 20/25] fix: use iloc Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index dc8335e885..ebf03c21ed 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -449,7 +449,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 penalty = tiny_price_slope else: # Constant penalty - penalty = tiny_price_slope[0] + penalty = tiny_price_slope.iloc[0][0] commitment = StockCommitment( name=f"prefer a full storage {d} sooner", quantity=(soc_max[d] - soc_at_start[d]) From 857e9c18504efa60249c0236eda9c59298d77552 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 23 Mar 2026 09:45:26 +0100 Subject: [PATCH 21/25] fix: diminish tiny price slope by number of planning steps Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index ebf03c21ed..cfc74c200e 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -440,7 +440,10 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 if prefer_charging_sooner_d: tiny_price_slope = ( add_tiny_price_slope( - up_deviation_prices, "event_value", order="desc" + up_deviation_prices, + "event_value", + d=10**-4 / len(index), + order="desc", ) - up_deviation_prices ) From 54c9c36ee9dcfb61ba5842e266116cc54d0eb5df Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 23 Mar 2026 09:48:58 +0100 Subject: [PATCH 22/25] refactor: always diminish tiny price slope by number of planning steps, such that its relative weight does not grow with the number of steps Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 5 +---- flexmeasures/data/models/planning/utils.py | 7 ++++--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index cfc74c200e..ebf03c21ed 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -440,10 +440,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 if prefer_charging_sooner_d: tiny_price_slope = ( add_tiny_price_slope( - up_deviation_prices, - "event_value", - d=10**-4 / len(index), - order="desc", + up_deviation_prices, "event_value", order="desc" ) - up_deviation_prices ) diff --git a/flexmeasures/data/models/planning/utils.py b/flexmeasures/data/models/planning/utils.py index c7e20860f0..74d01c15b5 100644 --- a/flexmeasures/data/models/planning/utils.py +++ b/flexmeasures/data/models/planning/utils.py @@ -75,14 +75,15 @@ def add_tiny_price_slope( ) -> pd.DataFrame: """Add tiny price slope to col_name to represent e.g. inflation as a simple linear price increase. This is meant to break ties, when multiple time slots have equal prices, in favour of acting sooner. - We penalise the future with at most d times the price spread (1 per thousand by default). + We penalise the future with at most d times the price spread (1 per thousand by default), + divided over the number of planning steps. """ prices = orig_prices.copy() price_spread = prices[col_name].max() - prices[col_name].min() if price_spread > 0: - max_penalty = price_spread * d + max_penalty = price_spread * d / len(prices) else: - max_penalty = d + max_penalty = d / len(prices) if order == "asc": prices[col_name] = prices[col_name] + np.linspace( 0, max_penalty, prices[col_name].size From 158c7a0ed41b799877a09742c8bffb7ac26b77d0 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 23 Mar 2026 09:50:41 +0100 Subject: [PATCH 23/25] chore: increment StorageScheduler version Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index ebf03c21ed..2a37cd600e 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1291,7 +1291,7 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: class StorageScheduler(MetaStorageScheduler): - __version__ = "7" + __version__ = "8" __author__ = "Seita" fallback_scheduler_class: Type[Scheduler] = StorageFallbackScheduler From a3c44dcf6b42474b013e4863f365ff53788a1c04 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 23 Mar 2026 11:34:12 +0100 Subject: [PATCH 24/25] docs: changelog entry Signed-off-by: F.N. Claessen --- documentation/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 5a45260864..ccd08e4861 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -13,6 +13,7 @@ New features * Support saving state-of-charge schedules to sensors with ``"%"`` unit, using the ``soc-max`` flex-model field as the capacity for unit conversion [see `PR #1996 `_] * Version headers (for server and API) in API responses [see `PR #2021 `_] * Show sensor attributes on sensor page, if not empty [see `PR #2015 `_] +* Separate the ``StorageScheduler``'s tie-breaking preference for a full :abbr:`SoC (state of charge)` from its reported energy costs [see `PR #2023 `_] Infrastructure / Support ---------------------- From aac76f16ac335fa7d4dae1cd633cfea9655fa939 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 23 Mar 2026 16:27:22 +0100 Subject: [PATCH 25/25] feat: improve error message (from review suggestion) Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/utils.py b/flexmeasures/data/models/planning/utils.py index 74d01c15b5..6e726e9b31 100644 --- a/flexmeasures/data/models/planning/utils.py +++ b/flexmeasures/data/models/planning/utils.py @@ -93,7 +93,7 @@ def add_tiny_price_slope( max_penalty, 0, prices[col_name].size ) else: - raise ValueError("order must be 'asc' or 'desc'") + raise ValueError(f"order must be 'asc' or 'desc', got '{order}'") return prices