From cbbf7ad04a8cdba8d22ba2891ad1329e8d53bd2a Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 14 Apr 2026 15:48:43 +0100 Subject: [PATCH 1/5] fix: skip SoC preference for non-storage devices Context: - PR #2023 introduced a StockCommitment that assumes each preferred device has soc_max and soc_at_start. - Mixed-device schedules can include non-storage devices such as PV, which do not have state-of-charge values. Change: - Guard the full-SoC preference so it only applies when both soc_max and soc_at_start are defined. - Prevent mixed-device scheduling from crashing on NoneType subtraction. Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/models/planning/storage.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index dc0549517d..d50227f5fb 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -438,7 +438,13 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 for d, (prefer_charging_sooner_d, prefer_curtailing_later_d) in enumerate( zip(prefer_charging_sooner, prefer_curtailing_later) ): - if prefer_charging_sooner_d: + # Mixed-device schedules can include non-storage devices such as PV. + # These do not have a state of charge, so there is nothing to "prefer full". + if ( + prefer_charging_sooner_d + and soc_max[d] is not None + and soc_at_start[d] is not None + ): tiny_price_slope = ( add_tiny_price_slope( up_deviation_prices, "event_value", order="desc" From 99844236fa4bf5346868135c1d694f15fbdd563a Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 14 Apr 2026 15:48:51 +0100 Subject: [PATCH 2/5] test: cover mixed storage and non-storage scheduling Context: - The scheduler regression was exposed by HEMS-style mixed-device flex-models that combine storage with PV. Change: - Add a regression test covering a mixed battery plus PV schedule. - Verify scheduling computes without applying SoC-only preferences to the PV device. Signed-off-by: Mohamed Belhsan Hmida --- .../data/models/planning/tests/test_solver.py | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index d31aa7c82d..13a062ea29 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -2891,6 +2891,74 @@ def initialize_combined_commitments(num_devices: int): ), "Individual costs mismatch: Costs for one or more devices are not calculated as expected." +def test_prefer_full_storage_skips_non_storage_devices(db, building): + """Do not apply SoC-based storage preferences to non-storage devices such as PV.""" + + battery = Sensor( + name="mixed battery power sensor", + generic_asset=building, + event_resolution=timedelta(hours=1), + unit="MW", + ) + pv = Sensor( + name="mixed pv power sensor", + generic_asset=building, + event_resolution=timedelta(hours=1), + unit="MW", + attributes={"is_strictly_non_positive": True}, + ) + db.session.add_all([battery, pv]) + db.session.commit() + + start = pd.Timestamp("2020-01-01T00:00:00", tz="Europe/Amsterdam") + end = start + timedelta(hours=4) + resolution = timedelta(hours=1) + + scheduler = StorageScheduler( + asset_or_sensor=building, + start=start, + end=end, + resolution=resolution, + flex_model=[ + { + "sensor": battery, + "soc_at_start": 1.0, + "soc_min": 0.0, + "soc_max": 2.0, + "power_capacity_in_mw": ur.Quantity("1 MW"), + "consumption_capacity": ur.Quantity("1 MW"), + "production_capacity": ur.Quantity("1 MW"), + "prefer_charging_sooner": True, + "prefer_curtailing_later": True, + }, + { + "sensor": pv, + "power_capacity_in_mw": ur.Quantity("1 MW"), + "consumption_capacity": ur.Quantity("0 MW"), + "production_capacity": ur.Quantity("1 MW"), + "prefer_charging_sooner": True, + "prefer_curtailing_later": True, + }, + ], + flex_context={ + "consumption_price": ur.Quantity("100 EUR/MWh"), + "production_price": ur.Quantity("100 EUR/MWh"), + "shared_currency_unit": "EUR", + "ems_power_capacity_in_mw": ur.Quantity("2 MW"), + }, + return_multiple=True, + ) + scheduler.config_deserialized = True + + schedule = scheduler.compute() + + assert isinstance(schedule, list) + assert any( + result.get("name") == "storage_schedule" and result.get("sensor") == battery + for result in schedule + ) + + def test_multiple_devices_sequential_scheduler(): start = pd.Timestamp("2023-01-01T00:00:00") end = pd.Timestamp("2023-01-02T00:00:00") From 8b81be6969e53c99bca54066a75de84c7515113d Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 14 Apr 2026 16:09:36 +0100 Subject: [PATCH 3/5] docs: add changelog entry for mixed-device scheduling fix Context: - PR #2108 fixes a mixed-device scheduling regression in StorageScheduler. Change: - Document the fix in the unreleased v0.32.0 bugfixes section. Signed-off-by: Mohamed Belhsan Hmida --- documentation/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 5da39a5a6a..1c8130e150 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -41,6 +41,7 @@ Infrastructure / Support Bugfixes ----------- +* Fix mixed-device scheduling regression where SoC-based storage preferences could crash on non-storage assets such as PV [see `PR #2108 `_] v0.31.3 | April 11, 2026 From 0bf6d079f067a9d13fac0f30623ec0c52c5e1112 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 14 Apr 2026 17:43:00 +0200 Subject: [PATCH 4/5] docs: move the changelog entry for an unreleased regression Signed-off-by: F.N. Claessen --- documentation/changelog.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 1c8130e150..41b5bc12da 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -18,7 +18,7 @@ New features * Support forecasting from a given time in the past, by allowing to specify a ``prior`` belief time in the forecasting API endpoint (as already possible with CLI command) [see `PR #1978 `_] * UI support for editing JSON attributes on sensors, assets and accounts [see `PR #2093 `_] * 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 `_] +* Separate the ``StorageScheduler``'s tie-breaking preference for a full :abbr:`SoC (state of charge)` from its reported energy costs [see `PR #2023 `_ and `PR #2108 `_] * Improve asset graph hover interaction with a vertical ruler across subcharts, while keeping hover dots for easier visual tracking [see `PR #2079 `_] * Improve asset audit log messages for JSON field edits (especially ``sensors_to_show`` and nested flex-config values) [see `PR #2055 `_] * Added a form on the UI for deleting sensor data sources [see `PR #2095 `_] @@ -41,7 +41,6 @@ Infrastructure / Support Bugfixes ----------- -* Fix mixed-device scheduling regression where SoC-based storage preferences could crash on non-storage assets such as PV [see `PR #2108 `_] v0.31.3 | April 11, 2026 From bfda69cac5bcf0851a7eb5a6440dd4854332ac70 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 14 Apr 2026 17:47:16 +0200 Subject: [PATCH 5/5] fix: fixture create the wrong asset type Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/tests/conftest.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/conftest.py b/flexmeasures/data/models/planning/tests/conftest.py index 5c1aa380a5..d829f326cc 100644 --- a/flexmeasures/data/models/planning/tests/conftest.py +++ b/flexmeasures/data/models/planning/tests/conftest.py @@ -102,8 +102,7 @@ def building(db, setup_accounts, setup_markets) -> GenericAsset: select(GenericAssetType).filter_by(name="building") ).scalar_one_or_none() if not building_type: - # create_test_battery_assets might have created it already - building_type = GenericAssetType(name="battery") + building_type = GenericAssetType(name="building") db.session.add(building_type) building = GenericAsset( name="building",