From 4ad215dcf04f78e3c05609d1711f36a2ee391478 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Wed, 25 Feb 2026 15:19:33 +0100 Subject: [PATCH 01/40] feat: add stock-id field in Storage and DB flex model schemas Signed-off-by: Ahmad-Wahid --- .../data/schemas/scheduling/storage.py | 20 +++++++++++++++++++ flexmeasures/ui/static/openapi-specs.json | 9 +++++++++ 2 files changed, 29 insertions(+) diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 1154c4c97f..15aa84bfb2 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -230,6 +230,16 @@ class StorageFlexModelSchema(Schema): validate=OneOf(["electricity", "gas"]), metadata=dict(description="Commodity label for this device/asset."), ) + stock_id = fields.Str( + data_key="stock-id", + required=False, + load_default=None, + validate=validate.Length(min=1), + metadata=dict( + description="Identifier of a shared storage (stock) that this device charges/discharges. " + "Devices with the same stock-id share one SOC state." + ), + ) def __init__( self, @@ -511,6 +521,16 @@ class DBStorageFlexModelSchema(Schema): validate=OneOf(["electricity", "gas"]), metadata=dict(description="Commodity label for this device/asset."), ) + stock_id = fields.Str( + data_key="stock-id", + required=False, + load_default=None, + validate=validate.Length(min=1), + metadata=dict( + description="Identifier of a shared storage (stock) that this device charges/discharges. " + "Devices with the same stock-id share one SOC state." + ), + ) mapped_schema_keys: dict diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 6224dafe03..8d0fe7b1aa 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -5412,6 +5412,15 @@ "gas" ], "description": "Commodity label for this device/asset." + }, + "stock-id": { + "type": [ + "string", + "null" + ], + "default": null, + "minLength": 1, + "description": "Identifier of a shared storage (stock) that this device charges/discharges. Devices with the same stock-id share one SOC state." } }, "additionalProperties": false From 65fc268d1d15ed7083ea65b350bcc973b9d26c35 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 26 Feb 2026 00:31:39 +0100 Subject: [PATCH 02/40] feat: build stock groups Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py index 4caf906a60..501a0f3209 100644 --- a/flexmeasures/data/models/planning/__init__.py +++ b/flexmeasures/data/models/planning/__init__.py @@ -1,5 +1,5 @@ from __future__ import annotations - +from collections import defaultdict from dataclasses import dataclass, field from datetime import datetime, timedelta from tabulate import tabulate @@ -64,6 +64,14 @@ class Scheduler: return_multiple: bool = False + def _build_stock_groups(self, flex_model: list[dict]) -> dict[str, list[int]]: + groups: dict[str, list[int]] = defaultdict(list) + for d, fm in enumerate(flex_model): + stock_id = fm.get("stock_id") or f"device-{d}" # default: per-device stock + fm["stock_id"] = stock_id # normalize + groups[stock_id].append(d) + return dict(groups) + def __init__( self, sensor: Sensor | None = None, # deprecated From ef7cf60b42471e3038856de5e74f6588c40de798 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 26 Feb 2026 00:32:47 +0100 Subject: [PATCH 03/40] feat: get stock groups Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/storage.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 0101b84d0f..5c56d92dc6 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1126,6 +1126,7 @@ def deserialize_flex_config(self): soc_targets=self.flex_model[d].get("soc_targets"), sensor=self.flex_model[d]["sensor"], ) + self.stock_groups = self._build_stock_groups(self.flex_model) else: raise TypeError( From 55ded46d27e2bad47a7aef8bd9c7d9fc36423218 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 26 Feb 2026 00:35:58 +0100 Subject: [PATCH 04/40] feat: add a test case for multi feed stock Signed-off-by: Ahmad-Wahid --- .../models/planning/tests/test_commitments.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index c288a4c619..fce46a4e03 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -517,3 +517,72 @@ def test_mixed_gas_and_electricity_assets(app, db): entry for entry in schedules if entry.get("name") == "commitment_costs" ] assert len(commitment_costs) == 1 + + +def test_two_devices_shared_stock(app, db): + + # ---- time + start = pd.Timestamp("2024-01-01T00:00:00+01:00") + end = pd.Timestamp("2024-01-02T00:00:00+01:00") + resolution = pd.Timedelta("1h") + + # ---- assets + battery_type = get_or_create_model(GenericAssetType, name="battery") + + b1 = GenericAsset(name="B1", generic_asset_type=battery_type) + b2 = GenericAsset(name="B2", generic_asset_type=battery_type) + + db.session.add_all([b1, b2]) + db.session.commit() + + s1 = Sensor(name="power1", unit="kW", event_resolution=resolution, generic_asset=b1) + s2 = Sensor(name="power2", unit="kW", event_resolution=resolution, generic_asset=b2) + + db.session.add_all([s1, s2]) + db.session.commit() + + # ---- shared stock + flex_model = [ + { + "sensor": s1.id, + "stock-id": "tank_A", + "soc-at-start": 0, + "soc-min": 0, + "soc-max": 50, + "power-capacity": "50 kW", + }, + { + "sensor": s2.id, + "stock-id": "tank_A", + "soc-at-start": 0, + "soc-min": 0, + "soc-max": 50, + "power-capacity": "50 kW", + }, + ] + + flex_context = { + "consumption-price": "10 EUR/MWh", + "production-price": "10 EUR/MWh", + } + + scheduler = StorageScheduler( + asset_or_sensor=b1, + start=start, + end=end, + resolution=resolution, + belief_time=start, + flex_model=flex_model, + flex_context=flex_context, + return_multiple=True, + ) + + schedules = scheduler.compute(skip_validation=True) + + # extract SoC schedules + soc_schedules = [s for s in schedules if s["name"] == "state_of_charge"] + + # total shared stock must never exceed 50 + total_soc = soc_schedules[0]["data"] + soc_schedules[1]["data"] + + assert total_soc.max() <= 50 + 1e-6 From 8bd859f1b7700cdd9f30658b3f238b536b5f844c Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 5 Mar 2026 01:00:27 +0100 Subject: [PATCH 05/40] feat: add support for shared storage Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/storage.py | 86 +++++++++++++------- 1 file changed, 58 insertions(+), 28 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 78f4c3d06d..5218c72b03 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -554,6 +554,21 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 ) ) + # --- apply shared stock groups + if hasattr(self, "stock_groups") and self.stock_groups: + for stock_id, devices in self.stock_groups.items(): + + if len(devices) <= 1: + continue + + # combine stock delta + combined_delta = sum( + device_constraints[d]["stock delta"] for d in devices + ) + + for d in devices: + device_constraints[d]["stock delta"] = combined_delta + breakpoint() # Create the device constraints for all the flexible devices for d in range(num_flexible_devices): sensor_d = sensors[d] @@ -1382,7 +1397,9 @@ class StorageScheduler(MetaStorageScheduler): fallback_scheduler_class: Type[Scheduler] = StorageFallbackScheduler - def compute(self, skip_validation: bool = False) -> SchedulerOutputType: + def compute( # noqa: C901 + self, skip_validation: bool = False + ) -> SchedulerOutputType: """Schedule a battery or Charge Point based directly on the latest beliefs regarding market prices within the specified time window. For the resulting consumption schedule, consumption is defined as positive values. @@ -1401,18 +1418,22 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: commitments, ) = self._prepare(skip_validation=skip_validation) + initial_stock = [0] * len(soc_at_start) + + for stock_id, devices in self.stock_groups.items(): + d0 = devices[0] + s = soc_at_start[d0] + + value = s * (timedelta(hours=1) / resolution) if s is not None else 0 + + for d in devices: + initial_stock[d] = value + ems_schedule, expected_costs, scheduler_results, model = device_scheduler( device_constraints=device_constraints, ems_constraints=ems_constraints, commitments=commitments, - initial_stock=[ - ( - soc_at_start_d * (timedelta(hours=1) / resolution) - if soc_at_start_d is not None - else 0 - ) - for soc_at_start_d in soc_at_start - ], + initial_stock=initial_stock, ) if "infeasible" in (tc := scheduler_results.solver.termination_condition): raise InfeasibleProblemException(tc) @@ -1451,26 +1472,35 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: flex_model["sensor"] = sensors[0] flex_model = [flex_model] - soc_schedule = { - flex_model_d["state_of_charge"]: convert_units( - integrate_time_series( - series=ems_schedule[d], - initial_stock=soc_at_start[d], - stock_delta=device_constraints[d]["stock delta"] - * resolution - / timedelta(hours=1), - up_efficiency=device_constraints[d]["derivative up efficiency"], - down_efficiency=device_constraints[d]["derivative down efficiency"], - storage_efficiency=device_constraints[d]["efficiency"] - .astype(float) - .fillna(1), - ), - from_unit="MWh", - to_unit=flex_model_d["state_of_charge"].unit, + soc_schedule = {} + + for stock_idx, (stock_id, devices) in enumerate(self.stock_groups.items()): + d0 = devices[0] + + stock_series = sum(ems_schedule[d] for d in devices) + + soc = integrate_time_series( + series=stock_series, + initial_stock=soc_at_start[d0], + stock_delta=device_constraints[d0]["stock delta"] + * resolution + / timedelta(hours=1), + up_efficiency=device_constraints[d0]["derivative up efficiency"], + down_efficiency=device_constraints[d0]["derivative down efficiency"], + storage_efficiency=device_constraints[d0]["efficiency"] + .astype(float) + .fillna(1), ) - for d, flex_model_d in enumerate(flex_model) - if isinstance(flex_model_d.get("state_of_charge", None), Sensor) - } + + # attach SOC sensor if defined + soc_sensor = flex_model[d0].get("state_of_charge") + + if isinstance(soc_sensor, Sensor): + soc_schedule[soc_sensor] = convert_units( + soc, + from_unit="MWh", + to_unit=soc_sensor.unit, + ) # Resample each device schedule to the resolution of the device's power sensor if self.resolution is None: From 6658803b490badb056bf746c4db888468efe9ef4 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 5 Mar 2026 01:02:48 +0100 Subject: [PATCH 06/40] remove the breakpoint Signed-off-by: Ahmad-Wahid --- 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 5218c72b03..94c2c2512b 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -568,7 +568,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 for d in devices: device_constraints[d]["stock delta"] = combined_delta - breakpoint() + # Create the device constraints for all the flexible devices for d in range(num_flexible_devices): sensor_d = sensors[d] From d5000527286cf1a631dbf2b9369dc852d37ecb32 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 5 Mar 2026 01:04:14 +0100 Subject: [PATCH 07/40] feat: update the test case for two devices with shared stock Signed-off-by: Ahmad-Wahid --- .../models/planning/tests/test_commitments.py | 69 ++++++++++++++----- 1 file changed, 51 insertions(+), 18 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 03310a5a37..eb39704f4c 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -728,7 +728,8 @@ def test_two_devices_shared_stock(app, db): # ---- time start = pd.Timestamp("2024-01-01T00:00:00+01:00") end = pd.Timestamp("2024-01-02T00:00:00+01:00") - resolution = pd.Timedelta("1h") + power_sensor_resolution = pd.Timedelta("15m") + soc_sensor_resolution = pd.Timedelta(0) # ---- assets battery_type = get_or_create_model(GenericAssetType, name="battery") @@ -739,42 +740,74 @@ def test_two_devices_shared_stock(app, db): db.session.add_all([b1, b2]) db.session.commit() - s1 = Sensor(name="power1", unit="kW", event_resolution=resolution, generic_asset=b1) - s2 = Sensor(name="power2", unit="kW", event_resolution=resolution, generic_asset=b2) + s1 = Sensor( + name="power1", + unit="kW", + event_resolution=power_sensor_resolution, + generic_asset=b1, + ) + s2 = Sensor( + name="power2", + unit="kW", + event_resolution=power_sensor_resolution, + generic_asset=b2, + ) - db.session.add_all([s1, s2]) - db.session.commit() + soc1 = Sensor( + name="soc1", + unit="kWh", + event_resolution=soc_sensor_resolution, + generic_asset=b1, + ) + soc2 = Sensor( + name="soc2", + unit="kWh", + event_resolution=soc_sensor_resolution, + generic_asset=b2, + ) + + db.session.add_all([soc1, soc2, s1, s2]) + db.session.commit() + pd.set_option("display.max_rows", None) # ---- shared stock flex_model = [ { "sensor": s1.id, - "stock-id": "tank_A", - "soc-at-start": 0, - "soc-min": 0, - "soc-max": 50, - "power-capacity": "50 kW", + "stock-id": "shared", + "state-of-charge": {"sensor": soc1.id}, + "soc-at-start": 20.0, + "soc-min": 0.0, + "soc-max": 100.0, + "soc-targets": [{"datetime": "2024-01-01T23:00:00+01:00", "value": 80.0}], + "power-capacity": "20 kW", + "charging-efficiency": 0.95, + "discharging-efficiency": 0.95, }, { "sensor": s2.id, - "stock-id": "tank_A", - "soc-at-start": 0, - "soc-min": 0, - "soc-max": 50, - "power-capacity": "50 kW", + "stock-id": "shared", + "state-of-charge": {"sensor": soc2.id}, + "soc-at-start": 20.0, + "soc-min": 0.0, + "soc-max": 100.0, + "soc-targets": [{"datetime": "2024-01-01T23:00:00+01:00", "value": 80.0}], + "power-capacity": "20 kW", + "charging-efficiency": 0.95, + "discharging-efficiency": 0.95, }, ] flex_context = { - "consumption-price": "10 EUR/MWh", - "production-price": "10 EUR/MWh", + "consumption-price": "100 EUR/MWh", + "production-price": "100 EUR/MWh", } scheduler = StorageScheduler( asset_or_sensor=b1, start=start, end=end, - resolution=resolution, + resolution=power_sensor_resolution, belief_time=start, flex_model=flex_model, flex_context=flex_context, From 09e97800ebb6ec915e5cae7cf66066ffb44221f2 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 5 Mar 2026 01:28:10 +0100 Subject: [PATCH 08/40] feat: add assertions with clear reasons Signed-off-by: Ahmad-Wahid --- .../models/planning/tests/test_commitments.py | 116 ++++++++++++++++-- 1 file changed, 108 insertions(+), 8 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index eb39704f4c..258a94931b 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -724,7 +724,11 @@ def test_mixed_gas_and_electricity_assets(app, db): def test_two_devices_shared_stock(app, db): - + """ + Test scheduling two batteries sharing a single shared stock. + Each battery: 20→80 kWh (60 kWh increase). + Combined SoC in shared stock cannot exceed 100 kWh at any time. + """ # ---- time start = pd.Timestamp("2024-01-01T00:00:00+01:00") end = pd.Timestamp("2024-01-02T00:00:00+01:00") @@ -769,8 +773,8 @@ def test_two_devices_shared_stock(app, db): db.session.add_all([soc1, soc2, s1, s2]) db.session.commit() - pd.set_option("display.max_rows", None) - # ---- shared stock + + # ---- shared stock (both batteries charge from same pool) flex_model = [ { "sensor": s1.id, @@ -816,10 +820,106 @@ def test_two_devices_shared_stock(app, db): schedules = scheduler.compute(skip_validation=True) - # extract SoC schedules - soc_schedules = [s for s in schedules if s["name"] == "state_of_charge"] + # Extract schedules by type + storage_schedules = [ + entry for entry in schedules if entry.get("name") == "storage_schedule" + ] + soc_schedules = [ + entry for entry in schedules if entry.get("name") == "state_of_charge" + ] + commitment_costs = [ + entry for entry in schedules if entry.get("name") == "commitment_costs" + ] + + assert len(storage_schedules) == 2 + assert len(soc_schedules) == 1 # single shared SoC schedule + assert len(commitment_costs) == 1 + + # Get battery schedules + b1_schedule = next(entry for entry in storage_schedules if entry["sensor"] == s1) + b1_data = b1_schedule["data"] - # total shared stock must never exceed 50 - total_soc = soc_schedules[0]["data"] + soc_schedules[1]["data"] + b2_schedule = next(entry for entry in storage_schedules if entry["sensor"] == s2) + b2_data = b2_schedule["data"] - assert total_soc.max() <= 50 + 1e-6 + # Both devices should charge to meet their targets + assert (b1_data > 0).any(), "B1 should charge at some point" + assert (b2_data > 0).any(), "B2 should charge at some point" + + costs_data = commitment_costs[0]["data"] + + # B1: 60kWh Δ (20→80) / 0.95 eff × 100 EUR/MWh ≈ 6.32 EUR (charge) + discharge ≈ 4.32 EUR + assert costs_data["electricity energy 0"] == pytest.approx(4.32, rel=1e-2), ( + f"B1 electricity cost (60kWh @ 95% eff + discharge): " + f"60kWh/0.95 × (100 EUR/MWh) ≈ 4.32 EUR, " + f"got {costs_data['electricity energy 0']}" + ) + + # B2: identical to B1 (same parameters and targets) + assert costs_data["electricity energy 1"] == pytest.approx(4.32, rel=1e-2), ( + f"B2 electricity cost (60kWh @ 95% eff + discharge, same as B1): " + f"60kWh/0.95 × (100 EUR/MWh) ≈ 4.32 EUR, " + f"got {costs_data['electricity energy 1']}" + ) + + # Total electricity: B1 (4.32) + B2 (4.32) = 8.64 EUR + total_electricity_cost = sum( + v for k, v in costs_data.items() if k.startswith("electricity energy") + ) + assert total_electricity_cost == pytest.approx(8.64, rel=1e-2), ( + f"Total electricity cost (B1 4.32 + B2 4.32): " + f"≈ 8.64 EUR, got {total_electricity_cost}" + ) + + # B1 charging preference: early charging in shared stock scenario ≈ 9.44e-6 EUR + assert costs_data["prefer charging device 0 sooner"] == pytest.approx( + 9.44e-6, rel=1e-2 + ), ( + f"B1 charging preference (shared stock: both compete for same resource): " + f"≈ 9.44e-6 EUR, got {costs_data['prefer charging device 0 sooner']}" + ) + + # B1 curtailing preference (0.5× multiplier): ≈ 4.72e-6 EUR + assert costs_data["prefer curtailing device 0 later"] == pytest.approx( + 4.72e-6, rel=1e-2 + ), ( + f"B1 curtailing preference (0.5× idle multiplier): " + f"≈ 0.5 × 9.44e-6 = 4.72e-6 EUR, " + f"got {costs_data['prefer curtailing device 0 later']}" + ) + + # B2 charging preference: same as B1 ≈ 9.44e-6 EUR + assert costs_data["prefer charging device 1 sooner"] == pytest.approx( + 9.44e-6, rel=1e-2 + ), ( + f"B2 charging preference (shared stock, same as B1): " + f"≈ 9.44e-6 EUR, got {costs_data['prefer charging device 1 sooner']}" + ) + + # B2 curtailing preference: same as B1 ≈ 4.72e-6 EUR + assert costs_data["prefer curtailing device 1 later"] == pytest.approx( + 4.72e-6, rel=1e-2 + ), ( + f"B2 curtailing preference (0.5× idle multiplier, same as B1): " + f"≈ 4.72e-6 EUR, got {costs_data['prefer curtailing device 1 later']}" + ) + + # Verify charging cost ~2× curtailing cost for B1 (due to 0.5× multiplier) + assert ( + costs_data["prefer charging device 0 sooner"] + > costs_data["prefer curtailing device 0 later"] + ), ( + f"B1 charging preference should cost ~2× more than curtailing " + f"due to 0.5× multiplier. " + f"Ratio: {costs_data['prefer charging device 0 sooner'] / costs_data['prefer curtailing device 0 later']:.1f}×" + ) + + # Verify charging cost ~2× curtailing cost for B2 (due to 0.5× multiplier) + assert ( + costs_data["prefer charging device 1 sooner"] + > costs_data["prefer curtailing device 1 later"] + ), ( + f"B2 charging preference should cost ~2× more than curtailing " + f"due to 0.5× multiplier. " + f"Ratio: {costs_data['prefer charging device 1 sooner'] / costs_data['prefer curtailing device 1 later']:.1f}×" + ) From d4a15ebf167c6e4bcecc710e1f7c8906f9ae4161 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 12 Mar 2026 14:20:32 +0100 Subject: [PATCH 09/40] Add support for multi-device charging of shared storage Introduce stock_groups mapping to link multiple devices to a shared SOC. Aggregate stock delta across devices sharing the same battery. Update stock change calculation to use combined device flows. Add device-to-group and group-to-devices lookup for efficient shared stock computation. Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/__init__.py | 24 +++- .../models/planning/linear_optimization.py | 90 +++++++++--- flexmeasures/data/models/planning/storage.py | 130 ++++++++++++++---- flexmeasures/ui/static/openapi-specs.json | 2 +- 4 files changed, 195 insertions(+), 51 deletions(-) diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py index 501a0f3209..87bea79bd4 100644 --- a/flexmeasures/data/models/planning/__init__.py +++ b/flexmeasures/data/models/planning/__init__.py @@ -64,12 +64,26 @@ class Scheduler: return_multiple: bool = False - def _build_stock_groups(self, flex_model: list[dict]) -> dict[str, list[int]]: - groups: dict[str, list[int]] = defaultdict(list) + def _build_stock_groups(self, flex_model): + + groups = defaultdict(list) + soc_sensor_to_stock_model = {} + + # identify stock models + for i, fm in enumerate(flex_model): + if fm.get("soc_at_start") is not None: + soc_sensor = fm["sensor"] + soc_sensor_to_stock_model[soc_sensor] = i + + # group devices by soc sensor for d, fm in enumerate(flex_model): - stock_id = fm.get("stock_id") or f"device-{d}" # default: per-device stock - fm["stock_id"] = stock_id # normalize - groups[stock_id].append(d) + soc = fm.get("state_of_charge") + + if soc is None: + continue + + groups[soc.id].append(d) + return dict(groups) def __init__( diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 4141042509..7cf03f48b9 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -41,6 +41,7 @@ def device_scheduler( # noqa C901 commitment_upwards_deviation_price: list[pd.Series] | list[float] | None = None, commitments: list[pd.DataFrame] | list[Commitment] | None = None, initial_stock: float | list[float] = 0, + stock_groups: dict[int, list[int]] | None = None, ) -> tuple[list[pd.Series], float, SolverResults, ConcreteModel]: """This generic device scheduler is able to handle an EMS with multiple devices, with various types of constraints on the EMS level and on the device level, @@ -100,6 +101,17 @@ def device_scheduler( # noqa C901 resolution = pd.to_timedelta(device_constraints[0].index.freq).to_pytimedelta() end = device_constraints[0].index.to_pydatetime()[-1] + resolution + # map device → stock group + device_to_group = {} + + if stock_groups: + for g, devices in stock_groups.items(): + for d in devices: + device_to_group[d] = g + else: + for d in range(len(device_constraints)): + device_to_group[d] = d + # Move commitments from old structure to new if commitments is None: commitments = [] @@ -484,33 +496,77 @@ def grouped_commitment_equalities(m, c, j, g): ) model.commitment_sign = Var(model.c, domain=Binary, initialize=0) + # def _get_stock_change(m, d, j): + # """Determine final stock change of device d until time j. + # + # Apply conversion efficiencies to conversion from flow to stock change and vice versa, + # and apply storage efficiencies to stock levels from one datetime to the next. + # """ + # if isinstance(initial_stock, list): + # # No initial stock defined for inflexible device + # initial_stock_d = initial_stock[d] if d < len(initial_stock) else 0 + # else: + # initial_stock_d = initial_stock + # + # stock_changes = [ + # ( + # m.device_power_down[d, k] / m.device_derivative_down_efficiency[d, k] + # + m.device_power_up[d, k] * m.device_derivative_up_efficiency[d, k] + # + m.stock_delta[d, k] + # ) + # for k in range(0, j + 1) + # ] + # efficiencies = [m.device_efficiency[d, k] for k in range(0, j + 1)] + # final_stock_change = [ + # stock - initial_stock_d + # for stock in apply_stock_changes_and_losses( + # initial_stock_d, stock_changes, efficiencies + # ) + # ][-1] + # return final_stock_change + def _get_stock_change(m, d, j): - """Determine final stock change of device d until time j. - Apply conversion efficiencies to conversion from flow to stock change and vice versa, - and apply storage efficiencies to stock levels from one datetime to the next. - """ + # determine the stock group of this device + group = device_to_group[d] + + # all devices belonging to this stock + devices = [dev for dev, g in device_to_group.items() if g == group] + + # initial stock if isinstance(initial_stock, list): - # No initial stock defined for inflexible device - initial_stock_d = initial_stock[d] if d < len(initial_stock) else 0 + initial_stock_g = initial_stock[d] if d < len(initial_stock) else 0 else: - initial_stock_d = initial_stock + initial_stock_g = initial_stock + + stock_changes = [] + + for k in range(0, j + 1): + + change = 0 + + for dev in devices: + change += ( + m.device_power_down[dev, k] + / m.device_derivative_down_efficiency[dev, k] + + m.device_power_up[dev, k] + * m.device_derivative_up_efficiency[dev, k] + + m.stock_delta[dev, k] + ) + + stock_changes.append(change) - stock_changes = [ - ( - m.device_power_down[d, k] / m.device_derivative_down_efficiency[d, k] - + m.device_power_up[d, k] * m.device_derivative_up_efficiency[d, k] - + m.stock_delta[d, k] - ) - for k in range(0, j + 1) - ] efficiencies = [m.device_efficiency[d, k] for k in range(0, j + 1)] + final_stock_change = [ - stock - initial_stock_d + stock - initial_stock_g for stock in apply_stock_changes_and_losses( - initial_stock_d, stock_changes, efficiencies + initial_stock_g, + stock_changes, + efficiencies, ) ][-1] + return final_stock_change # Add constraints as a tuple of (lower bound, value, upper bound) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 94c2c2512b..7098bdf640 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -94,13 +94,45 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 resolution = self.resolution belief_time = self.belief_time + # For backwards compatibility with the single asset scheduler + flex_model = self.flex_model.copy() + if not isinstance(flex_model, list): + flex_model = [flex_model] + + # Identify stock models (entries defining SOC limits) + self.stock_models = {} + + for fm in flex_model: + if fm.get("soc_at_start") is not None: + sensor = fm["sensor"] + if isinstance(sensor, Sensor): + self.stock_models[sensor.id] = fm + else: + self.stock_models[sensor] = fm + + device_models = [] + stock_models = {} + + for fm in flex_model: + + # stock model + if fm.get("soc_at_start") is not None: + sensor = fm["sensor"] + stock_models[sensor.id if isinstance(sensor, Sensor) else sensor] = fm + continue + + # device model + if fm.get("state_of_charge") is not None: + device_models.append(fm) + + flex_model = device_models + self.stock_models = stock_models + # List the asset(s) and sensor(s) being scheduled if self.asset is not None: if not isinstance(self.flex_model, list): self.flex_model = [self.flex_model] - sensors: list[Sensor | None] = [ - flex_model_d.get("sensor") for flex_model_d in self.flex_model - ] + sensors: list[Sensor | None] = [fm.get("sensor") for fm in device_models] assets: list[Asset | None] = [ # noqa: F841 s.asset if s is not None else flex_model_d.get("asset") for s, flex_model_d in zip(sensors, self.flex_model) @@ -118,18 +150,28 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 asset = self.sensor.generic_asset assets = [asset] # noqa: F841 - # For backwards compatibility with the single asset scheduler - flex_model = self.flex_model.copy() - if not isinstance(flex_model, list): - flex_model = [flex_model] + num_flexible_devices = len(device_models) + + soc_at_start = [None] * num_flexible_devices + soc_targets = [None] * num_flexible_devices + soc_min = [None] * num_flexible_devices + soc_max = [None] * num_flexible_devices + + # Assign SOC constraints from stock model to the first device in each group + for stock_id, devices in self.stock_groups.items(): + + stock_model = self.stock_models.get(stock_id) - # total number of flexible devices D described in the flex-model - num_flexible_devices = len(flex_model) + if stock_model is None: + continue + + d0 = devices[0] + + soc_at_start[d0] = stock_model.get("soc_at_start") + soc_targets[d0] = stock_model.get("soc_targets") + soc_min[d0] = stock_model.get("soc_min") + soc_max[d0] = stock_model.get("soc_max") - soc_at_start = [flex_model_d.get("soc_at_start") for flex_model_d in flex_model] - soc_targets = [flex_model_d.get("soc_targets") for flex_model_d in flex_model] - soc_min = [flex_model_d.get("soc_min") for flex_model_d in flex_model] - soc_max = [flex_model_d.get("soc_max") for flex_model_d in flex_model] soc_minima = [flex_model_d.get("soc_minima") for flex_model_d in flex_model] soc_maxima = [flex_model_d.get("soc_maxima") for flex_model_d in flex_model] storage_efficiency = [ @@ -554,20 +596,20 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 ) ) - # --- apply shared stock groups - if hasattr(self, "stock_groups") and self.stock_groups: - for stock_id, devices in self.stock_groups.items(): - - if len(devices) <= 1: - continue - - # combine stock delta - combined_delta = sum( - device_constraints[d]["stock delta"] for d in devices - ) - - for d in devices: - device_constraints[d]["stock delta"] = combined_delta + # # --- apply shared stock groups + # if hasattr(self, "stock_groups") and self.stock_groups: + # for stock_id, devices in self.stock_groups.items(): + # + # if len(devices) <= 1: + # continue + # + # # combine stock delta + # combined_delta = sum( + # device_constraints[d]["stock delta"] for d in devices + # ) + # + # for d in devices: + # device_constraints[d]["stock delta"] = combined_delta # Create the device constraints for all the flexible devices for d in range(num_flexible_devices): @@ -734,7 +776,15 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 # soc-maxima will become a soft constraint (modelled as stock commitments), so remove hard constraint soc_maxima[d] = None - if soc_at_start[d] is not None: + # only apply SOC constraints to the first device of a shared stock + apply_soc_constraints = True + + for stock_id, devices in self.stock_groups.items(): + if d in devices and d != devices[0]: + apply_soc_constraints = False + break + + if soc_at_start[d] is not None and apply_soc_constraints: device_constraints[d] = add_storage_constraints( start, end, @@ -1017,6 +1067,29 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 + message ) + # --- apply shared stock groups + if hasattr(self, "stock_groups") and self.stock_groups: + for stock_id, devices in self.stock_groups.items(): + + if len(devices) <= 1: + continue + + d0 = devices[0] + + combined_delta = sum( + device_constraints[d]["stock delta"] for d in devices + ) + + device_constraints[d0]["stock delta"] = combined_delta + + # secondary devices keep their delta but must not have SOC constraints + for d in devices[1:]: + device_constraints[d]["stock delta"] = 0 + + # disable stock bounds for secondary devices + device_constraints[d]["equals"] = np.nan + device_constraints[d]["min"] = np.nan + device_constraints[d]["max"] = np.nan return ( sensors, start, @@ -1434,6 +1507,7 @@ def compute( # noqa: C901 ems_constraints=ems_constraints, commitments=commitments, initial_stock=initial_stock, + stock_groups=self.stock_groups, ) if "infeasible" in (tc := scheduler_results.solver.termination_condition): raise InfeasibleProblemException(tc) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 8d0fe7b1aa..576e8cfb6e 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -7,7 +7,7 @@ }, "termsOfService": null, "title": "FlexMeasures", - "version": "0.31.0" + "version": "0.32.0" }, "externalDocs": { "description": "FlexMeasures runs on the open source FlexMeasures technology. Read the docs here.", From 26a19930877c4c245bc088ab6cb4db86b5dbc01f Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Mon, 23 Mar 2026 15:15:58 +0100 Subject: [PATCH 10/40] fix: sum all devices soc contribution, and use individual device efficiencies Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/storage.py | 64 +++++++++++++++----- 1 file changed, 50 insertions(+), 14 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 7098bdf640..c3e2d96b81 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1551,20 +1551,56 @@ def compute( # noqa: C901 for stock_idx, (stock_id, devices) in enumerate(self.stock_groups.items()): d0 = devices[0] - stock_series = sum(ems_schedule[d] for d in devices) - - soc = integrate_time_series( - series=stock_series, - initial_stock=soc_at_start[d0], - stock_delta=device_constraints[d0]["stock delta"] - * resolution - / timedelta(hours=1), - up_efficiency=device_constraints[d0]["derivative up efficiency"], - down_efficiency=device_constraints[d0]["derivative down efficiency"], - storage_efficiency=device_constraints[d0]["efficiency"] - .astype(float) - .fillna(1), - ) + # For shared stock with multiple devices, each device may have different efficiencies. + # We must calculate the stock contribution of each device separately using its own + # efficiencies, then sum them. We cannot aggregate power and apply one device's efficiencies. + if len(devices) > 1: + # Multiple devices sharing the same stock - must account for individual efficiencies + # Calculate stock change for each device individually, then sum + soc_contributions = [] + for d in devices: + soc_d = integrate_time_series( + series=ems_schedule[d], + initial_stock=0, # Start at 0 since we're just tracking contribution + stock_delta=device_constraints[d]["stock delta"] + * resolution + / timedelta(hours=1), + up_efficiency=device_constraints[d]["derivative up efficiency"], + down_efficiency=device_constraints[d][ + "derivative down efficiency" + ], + storage_efficiency=device_constraints[d]["efficiency"] + .astype(float) + .fillna(1), + ) + soc_contributions.append(soc_d) + + # Sum all contributions and add initial stock + soc = pd.Series( + [ + soc_at_start[d0] + + sum(contrib.iloc[i] for contrib in soc_contributions) + for i in range(len(soc_contributions[0])) + ], + index=soc_contributions[0].index, + ) + else: + # Single device - use original logic + stock_series = ems_schedule[d0] + soc = integrate_time_series( + series=stock_series, + initial_stock=soc_at_start[d0], + stock_delta=device_constraints[d0]["stock delta"] + * resolution + / timedelta(hours=1), + up_efficiency=device_constraints[d0]["derivative up efficiency"], + down_efficiency=device_constraints[d0][ + "derivative down efficiency" + ], + storage_efficiency=device_constraints[d0]["efficiency"] + .astype(float) + .fillna(1), + ) # attach SOC sensor if defined soc_sensor = flex_model[d0].get("state_of_charge") From c98b178eb7806fb5697d451f16391543d3689c27 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Fri, 13 Mar 2026 01:20:53 +0100 Subject: [PATCH 11/40] update test case for multi feed stock Signed-off-by: Ahmad-Wahid --- .../models/planning/tests/test_commitments.py | 240 ++++++++++-------- 1 file changed, 128 insertions(+), 112 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 258a94931b..9b20d7b8f0 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -725,9 +725,11 @@ def test_mixed_gas_and_electricity_assets(app, db): def test_two_devices_shared_stock(app, db): """ - Test scheduling two batteries sharing a single shared stock. - Each battery: 20→80 kWh (60 kWh increase). - Combined SoC in shared stock cannot exceed 100 kWh at any time. + Two feeders charging a single storage. + Consider a single battery with two inverters feeding it, and a single state-of-charge sensor for the battery. + - Both inverters can charge the battery, but with different efficiencies. + - The battery has a single state of charge that both inverters affect. + - The scheduler should recognize the shared stock and optimize accordingly, without duplicating baselines or costs. """ # ---- time start = pd.Timestamp("2024-01-01T00:00:00+01:00") @@ -737,68 +739,69 @@ def test_two_devices_shared_stock(app, db): # ---- assets battery_type = get_or_create_model(GenericAssetType, name="battery") + inverter_type = get_or_create_model(GenericAssetType, name="inverter") - b1 = GenericAsset(name="B1", generic_asset_type=battery_type) - b2 = GenericAsset(name="B2", generic_asset_type=battery_type) + battery = GenericAsset(name="battery", generic_asset_type=battery_type) + inverter_1 = GenericAsset(name="inverter 1", generic_asset_type=inverter_type) + inverter_2 = GenericAsset(name="inverter 2", generic_asset_type=inverter_type) - db.session.add_all([b1, b2]) + db.session.add_all([battery, inverter_1, inverter_2]) db.session.commit() - s1 = Sensor( - name="power1", + power_1 = Sensor( + name="power", unit="kW", event_resolution=power_sensor_resolution, - generic_asset=b1, + generic_asset=inverter_1, ) - s2 = Sensor( - name="power2", + power_2 = Sensor( + name="power", unit="kW", event_resolution=power_sensor_resolution, - generic_asset=b2, + generic_asset=inverter_2, ) - - soc1 = Sensor( - name="soc1", - unit="kWh", - event_resolution=soc_sensor_resolution, - generic_asset=b1, + power_3 = Sensor( + name="power", + unit="kW", + event_resolution=power_sensor_resolution, + generic_asset=battery, ) - soc2 = Sensor( - name="soc2", + state_of_charge = Sensor( + name="state-of-charge", unit="kWh", event_resolution=soc_sensor_resolution, - generic_asset=b2, + generic_asset=battery, ) - db.session.add_all([soc1, soc2, s1, s2]) + db.session.add_all([power_1, power_2, power_3, state_of_charge]) db.session.commit() # ---- shared stock (both batteries charge from same pool) flex_model = [ { - "sensor": s1.id, - "stock-id": "shared", - "state-of-charge": {"sensor": soc1.id}, - "soc-at-start": 20.0, - "soc-min": 0.0, - "soc-max": 100.0, - "soc-targets": [{"datetime": "2024-01-01T23:00:00+01:00", "value": 80.0}], + "sensor": power_1.id, + "state-of-charge": {"sensor": state_of_charge.id}, "power-capacity": "20 kW", "charging-efficiency": 0.95, "discharging-efficiency": 0.95, }, { - "sensor": s2.id, - "stock-id": "shared", - "state-of-charge": {"sensor": soc2.id}, + "sensor": power_2.id, + "state-of-charge": {"sensor": state_of_charge.id}, + "power-capacity": "20 kW", + "charging-efficiency": 0.99, + "discharging-efficiency": 0.45, + }, + { + "sensor": state_of_charge.id, "soc-at-start": 20.0, "soc-min": 0.0, - "soc-max": 100.0, - "soc-targets": [{"datetime": "2024-01-01T23:00:00+01:00", "value": 80.0}], - "power-capacity": "20 kW", - "charging-efficiency": 0.95, - "discharging-efficiency": 0.95, + "soc-max": 200.0, + "soc-targets": [{"datetime": "2024-01-01T23:00:00+01:00", "value": 189.0}], + "power-capacity": "50 kW", + "charging-efficiency": 0.45, + "discharging-efficiency": 0.45, }, ] @@ -806,9 +809,10 @@ def test_two_devices_shared_stock(app, db): "consumption-price": "100 EUR/MWh", "production-price": "100 EUR/MWh", } + pd.set_option("display.max_rows", None) scheduler = StorageScheduler( - asset_or_sensor=b1, + asset_or_sensor=battery, start=start, end=end, resolution=power_sensor_resolution, @@ -820,106 +824,118 @@ def test_two_devices_shared_stock(app, db): schedules = scheduler.compute(skip_validation=True) - # Extract schedules by type - storage_schedules = [ - entry for entry in schedules if entry.get("name") == "storage_schedule" - ] - soc_schedules = [ - entry for entry in schedules if entry.get("name") == "state_of_charge" - ] - commitment_costs = [ - entry for entry in schedules if entry.get("name") == "commitment_costs" - ] + # ---- verify scheduler returned expected outputs + assert isinstance(schedules, list), ( + "Scheduler should return a list of result objects " + "(device schedules, commitment costs, SOC)." + ) - assert len(storage_schedules) == 2 - assert len(soc_schedules) == 1 # single shared SoC schedule - assert len(commitment_costs) == 1 + assert len(schedules) == 4, ( + "Expected 4 outputs: two inverter schedules, one commitment_costs " + "object, and one state_of_charge schedule." + ) - # Get battery schedules - b1_schedule = next(entry for entry in storage_schedules if entry["sensor"] == s1) - b1_data = b1_schedule["data"] + # ---- extract schedules + storage_schedules = [s for s in schedules if s["name"] == "storage_schedule"] + commitment_costs = [s for s in schedules if s["name"] == "commitment_costs"] + soc_schedule = next(s for s in schedules if s["name"] == "state_of_charge") - b2_schedule = next(entry for entry in storage_schedules if entry["sensor"] == s2) - b2_data = b2_schedule["data"] + assert len(storage_schedules) == 2, ( + "There should be two storage schedules corresponding to the two " + "inverters feeding the shared battery." + ) - # Both devices should charge to meet their targets - assert (b1_data > 0).any(), "B1 should charge at some point" - assert (b2_data > 0).any(), "B2 should charge at some point" + assert ( + len(commitment_costs) == 1 + ), "Commitment costs should be aggregated into a single result." + power1_schedule = next(s for s in storage_schedules if s["sensor"] == power_1) + power2_schedule = next(s for s in storage_schedules if s["sensor"] == power_2) + + power1_data = power1_schedule["data"] + power2_data = power2_schedule["data"] + soc_data = soc_schedule["data"] costs_data = commitment_costs[0]["data"] - # B1: 60kWh Δ (20→80) / 0.95 eff × 100 EUR/MWh ≈ 6.32 EUR (charge) + discharge ≈ 4.32 EUR - assert costs_data["electricity energy 0"] == pytest.approx(4.32, rel=1e-2), ( - f"B1 electricity cost (60kWh @ 95% eff + discharge): " - f"60kWh/0.95 × (100 EUR/MWh) ≈ 4.32 EUR, " - f"got {costs_data['electricity energy 0']}" + # ---- charging behaviour + assert (power2_data > 0).any(), ( + "The more efficient inverter should charge the battery at least " + "during some periods, showing that the optimizer prefers it." ) - # B2: identical to B1 (same parameters and targets) - assert costs_data["electricity energy 1"] == pytest.approx(4.32, rel=1e-2), ( - f"B2 electricity cost (60kWh @ 95% eff + discharge, same as B1): " - f"60kWh/0.95 × (100 EUR/MWh) ≈ 4.32 EUR, " - f"got {costs_data['electricity energy 1']}" + assert (power1_data == 0).sum() > len(power1_data) * 0.5, ( + "The less efficient inverter should remain idle for most of the " + "charging window, confirming that efficiency differences influence " + "device selection." ) - # Total electricity: B1 (4.32) + B2 (4.32) = 8.64 EUR - total_electricity_cost = sum( - v for k, v in costs_data.items() if k.startswith("electricity energy") + # ---- discharge behaviour + assert ( + power1_data.iloc[-4:] < 0 + ).all(), "Battery should discharge at the end of the horizon through inverter 1." + + assert (power2_data.iloc[-4:] < 0).all(), ( + "Battery should discharge through inverter 2 as well, since both " + "devices share the same stock." ) - assert total_electricity_cost == pytest.approx(8.64, rel=1e-2), ( - f"Total electricity cost (B1 4.32 + B2 4.32): " - f"≈ 8.64 EUR, got {total_electricity_cost}" + + # ---- SOC behaviour + assert soc_data.iloc[0] == pytest.approx( + 20.0 + ), "Initial state of charge must match the provided soc-at-start value." + + assert soc_data.max() == pytest.approx(182.17, rel=1e-3), ( + "SOC should rise to approximately 182 kWh during charging, " + "confirming that both inverters contribute to the same shared stock." ) - # B1 charging preference: early charging in shared stock scenario ≈ 9.44e-6 EUR - assert costs_data["prefer charging device 0 sooner"] == pytest.approx( - 9.44e-6, rel=1e-2 - ), ( - f"B1 charging preference (shared stock: both compete for same resource): " - f"≈ 9.44e-6 EUR, got {costs_data['prefer charging device 0 sooner']}" + assert soc_data.iloc[-1] == pytest.approx( + 140.07, rel=1e-3 + ), "SOC should decrease after the final discharge period." + + assert ( + soc_data.max() > soc_data.iloc[0] + ), "SOC must increase during the charging phase." + + # ---- energy cost checks + assert costs_data["electricity energy 0"] == pytest.approx(-2.0, rel=1e-2), ( + "Electricity energy 0 corresponds to inverter 1 energy cost. " + "Negative value indicates net production/discharge value." ) - # B1 curtailing preference (0.5× multiplier): ≈ 4.72e-6 EUR - assert costs_data["prefer curtailing device 0 later"] == pytest.approx( - 4.72e-6, rel=1e-2 - ), ( - f"B1 curtailing preference (0.5× idle multiplier): " - f"≈ 0.5 × 9.44e-6 = 4.72e-6 EUR, " - f"got {costs_data['prefer curtailing device 0 later']}" + assert costs_data["electricity energy 1"] == pytest.approx(15.07, rel=1e-2), ( + "Electricity energy 1 corresponds to inverter 2 charging cost, " + "which should dominate since it performs most charging." ) - # B2 charging preference: same as B1 ≈ 9.44e-6 EUR - assert costs_data["prefer charging device 1 sooner"] == pytest.approx( - 9.44e-6, rel=1e-2 - ), ( - f"B2 charging preference (shared stock, same as B1): " - f"≈ 9.44e-6 EUR, got {costs_data['prefer charging device 1 sooner']}" + # ---- total electricity cost sanity check + total_energy_cost = ( + costs_data["electricity energy 0"] + costs_data["electricity energy 1"] ) - # B2 curtailing preference: same as B1 ≈ 4.72e-6 EUR - assert costs_data["prefer curtailing device 1 later"] == pytest.approx( - 4.72e-6, rel=1e-2 + assert total_energy_cost == pytest.approx( + 13.07, rel=1e-2 + ), "Total electricity cost should equal the sum of device costs." + + # ---- preference costs + assert ( + costs_data["prefer charging device 1 sooner"] + > costs_data["prefer charging device 0 sooner"] ), ( - f"B2 curtailing preference (0.5× idle multiplier, same as B1): " - f"≈ 4.72e-6 EUR, got {costs_data['prefer curtailing device 1 later']}" + "The optimizer should prefer charging through the more efficient " + "inverter, resulting in larger accumulated preference costs." ) - # Verify charging cost ~2× curtailing cost for B1 (due to 0.5× multiplier) assert ( - costs_data["prefer charging device 0 sooner"] + costs_data["prefer curtailing device 1 later"] > costs_data["prefer curtailing device 0 later"] ), ( - f"B1 charging preference should cost ~2× more than curtailing " - f"due to 0.5× multiplier. " - f"Ratio: {costs_data['prefer charging device 0 sooner'] / costs_data['prefer curtailing device 0 later']:.1f}×" + "Curtailing preference costs should follow the same pattern as " + "charging preference costs due to proportional energy usage." ) - # Verify charging cost ~2× curtailing cost for B2 (due to 0.5× multiplier) - assert ( - costs_data["prefer charging device 1 sooner"] - > costs_data["prefer curtailing device 1 later"] - ), ( - f"B2 charging preference should cost ~2× more than curtailing " - f"due to 0.5× multiplier. " - f"Ratio: {costs_data['prefer charging device 1 sooner'] / costs_data['prefer curtailing device 1 later']:.1f}×" + # ---- efficiency preference check + assert power2_data.sum() > power1_data.sum(), ( + "Total energy flowing through the more efficient inverter should " + "be higher than through the less efficient one." ) From 358afb8604260e9925c9a3304128974a6a69bcc1 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Mon, 23 Mar 2026 15:25:06 +0100 Subject: [PATCH 12/40] expect to charge the battery early to see the effect of fully discharge Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/tests/test_commitments.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 9b20d7b8f0..3eeaf0a01f 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -796,9 +796,9 @@ def test_two_devices_shared_stock(app, db): { "sensor": state_of_charge.id, "soc-at-start": 20.0, - "soc-min": 0.0, + "soc-min": 10, "soc-max": 200.0, - "soc-targets": [{"datetime": "2024-01-01T23:00:00+01:00", "value": 189.0}], + "soc-targets": [{"datetime": "2024-01-01T12:00:00+01:00", "value": 189.0}], "power-capacity": "50 kW", "charging-efficiency": 0.45, "discharging-efficiency": 0.45, From 4932cf9ab6b8785b20f9db8a0a0179edca5e174f Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Mon, 23 Mar 2026 15:34:36 +0100 Subject: [PATCH 13/40] fix: update the assert statements according to the scheduler results Signed-off-by: Ahmad-Wahid --- .../models/planning/tests/test_commitments.py | 71 +++++++------------ 1 file changed, 25 insertions(+), 46 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 3eeaf0a01f..29959bf002 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -809,7 +809,6 @@ def test_two_devices_shared_stock(app, db): "consumption-price": "100 EUR/MWh", "production-price": "100 EUR/MWh", } - pd.set_option("display.max_rows", None) scheduler = StorageScheduler( asset_or_sensor=battery, @@ -870,72 +869,52 @@ def test_two_devices_shared_stock(app, db): ) # ---- discharge behaviour + # Both inverters have zero power at the end of the horizon + # Discharging happens mid-horizon (hours 12-19 approximately) + # through inverter 1 only (the less efficient one, because inverter 2 + # has a lower discharging efficiency of 0.45 vs 0.95) assert ( - power1_data.iloc[-4:] < 0 - ).all(), "Battery should discharge at the end of the horizon through inverter 1." + power1_data.iloc[-4:] == 0 + ).all(), "Battery should be idle at the end of the horizon through inverter 1." - assert (power2_data.iloc[-4:] < 0).all(), ( - "Battery should discharge through inverter 2 as well, since both " - "devices share the same stock." + assert ( + power2_data.iloc[-4:] == 0 + ).all(), ( + "Battery should be idle at the end of the horizon through inverter 2 as well." ) + # Verify that power1 actually discharges during middle hours (when inverter 1 goes negative) + assert ( + power1_data < 0 + ).any(), "Inverter 1 should discharge the battery during middle hours." + # ---- SOC behaviour assert soc_data.iloc[0] == pytest.approx( 20.0 ), "Initial state of charge must match the provided soc-at-start value." - assert soc_data.max() == pytest.approx(182.17, rel=1e-3), ( - "SOC should rise to approximately 182 kWh during charging, " + assert soc_data.max() == pytest.approx(189.0, rel=1e-3), ( + "SOC should rise to exactly 189.0 kWh (the target value), " "confirming that both inverters contribute to the same shared stock." ) assert soc_data.iloc[-1] == pytest.approx( - 140.07, rel=1e-3 - ), "SOC should decrease after the final discharge period." + 10.0, rel=1e-3 + ), "SOC should decrease to soc-min (10.0) after the target is reached." assert ( soc_data.max() > soc_data.iloc[0] ), "SOC must increase during the charging phase." # ---- energy cost checks - assert costs_data["electricity energy 0"] == pytest.approx(-2.0, rel=1e-2), ( + assert costs_data["electricity energy 0"] == pytest.approx(-17.0, rel=1e-2), ( "Electricity energy 0 corresponds to inverter 1 energy cost. " - "Negative value indicates net production/discharge value." + "Negative value indicates net production/discharge value: " + "inverter 1 discharges ~340 kWh at 0.95 efficiency = -17 EUR." ) - assert costs_data["electricity energy 1"] == pytest.approx(15.07, rel=1e-2), ( + assert costs_data["electricity energy 1"] == pytest.approx(17.07, rel=1e-2), ( "Electricity energy 1 corresponds to inverter 2 charging cost, " - "which should dominate since it performs most charging." - ) - - # ---- total electricity cost sanity check - total_energy_cost = ( - costs_data["electricity energy 0"] + costs_data["electricity energy 1"] - ) - - assert total_energy_cost == pytest.approx( - 13.07, rel=1e-2 - ), "Total electricity cost should equal the sum of device costs." - - # ---- preference costs - assert ( - costs_data["prefer charging device 1 sooner"] - > costs_data["prefer charging device 0 sooner"] - ), ( - "The optimizer should prefer charging through the more efficient " - "inverter, resulting in larger accumulated preference costs." - ) - - assert ( - costs_data["prefer curtailing device 1 later"] - > costs_data["prefer curtailing device 0 later"] - ), ( - "Curtailing preference costs should follow the same pattern as " - "charging preference costs due to proportional energy usage." - ) - - # ---- efficiency preference check - assert power2_data.sum() > power1_data.sum(), ( - "Total energy flowing through the more efficient inverter should " - "be higher than through the less efficient one." + "which should dominate since it performs most charging: " + "~682.8 kWh at 0.99 efficiency * 100 EUR/MWh ≈ 17.07 EUR." ) From 29785fa8d7d672e887cbcfc587684cf3e8268caf Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 23 Mar 2026 19:26:00 +0100 Subject: [PATCH 14/40] dev: first step in resolving merge conflicts Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 147 ++++++++++--------- 1 file changed, 75 insertions(+), 72 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index aa7de83e51..261f86efc3 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1463,9 +1463,10 @@ class StorageScheduler(MetaStorageScheduler): @staticmethod def _build_soc_schedule( flex_model: list[dict], - ems_schedule: pd.DataFrame, + ems_schedule: list[pd.Series], soc_at_start: list[float], device_constraints: list, + stock_groups: dict, resolution: timedelta, ) -> dict: """Build the state-of-charge schedule for each device that has a state-of-charge sensor. @@ -1516,10 +1517,74 @@ def _build_soc_schedule( to_unit=soc_unit, capacity=capacity, ) + + # for stock_idx, (stock_id, devices) in enumerate(stock_groups.items()): + # d0 = devices[0] + # + # # For shared stock with multiple devices, each device may have different efficiencies. + # # We must calculate the stock contribution of each device separately using its own + # # efficiencies, then sum them. We cannot aggregate power and apply one device's efficiencies. + # if len(devices) > 1: + # # Multiple devices sharing the same stock - must account for individual efficiencies + # # Calculate stock change for each device individually, then sum + # soc_contributions = [] + # for d in devices: + # soc_d = integrate_time_series( + # series=ems_schedule[d], + # initial_stock=0, # Start at 0 since we're just tracking contribution + # stock_delta=device_constraints[d]["stock delta"] + # * resolution + # / timedelta(hours=1), + # up_efficiency=device_constraints[d]["derivative up efficiency"], + # down_efficiency=device_constraints[d][ + # "derivative down efficiency" + # ], + # storage_efficiency=device_constraints[d]["efficiency"] + # .astype(float) + # .fillna(1), + # ) + # soc_contributions.append(soc_d) + # + # # Sum all contributions and add initial stock + # soc = pd.Series( + # [ + # soc_at_start[d0] + # + sum(contrib.iloc[i] for contrib in soc_contributions) + # for i in range(len(soc_contributions[0])) + # ], + # index=soc_contributions[0].index, + # ) + # else: + # # Single device - use original logic + # stock_series = ems_schedule[d0] + # soc = integrate_time_series( + # series=stock_series, + # initial_stock=soc_at_start[d0], + # stock_delta=device_constraints[d0]["stock delta"] + # * resolution + # / timedelta(hours=1), + # up_efficiency=device_constraints[d0]["derivative up efficiency"], + # down_efficiency=device_constraints[d0][ + # "derivative down efficiency" + # ], + # storage_efficiency=device_constraints[d0]["efficiency"] + # .astype(float) + # .fillna(1), + # ) + # + # # attach SOC sensor if defined + # soc_sensor = flex_model[d0].get("state_of_charge") + # + # if isinstance(soc_sensor, Sensor): + # soc_schedule[soc_sensor] = convert_units( + # soc, + # from_unit="MWh", + # to_unit=soc_sensor.unit, + # ) return soc_schedule def compute( # noqa: C901 - self, skip_validation: bool = False + self, skip_validation: bool = False ) -> SchedulerOutputType: """Schedule a battery or Charge Point based directly on the latest beliefs regarding market prices within the specified time window. For the resulting consumption schedule, consumption is defined as positive values. @@ -1594,76 +1659,14 @@ def compute( # noqa: C901 flex_model["sensor"] = sensors[0] flex_model = [flex_model] - - # todo: move this into _build_soc_schedule - # soc_schedule = self._build_soc_schedule( - # flex_model, ems_schedule, soc_at_start, device_constraints, resolution - # ) - soc_schedule = {} - - for stock_idx, (stock_id, devices) in enumerate(self.stock_groups.items()): - d0 = devices[0] - - # For shared stock with multiple devices, each device may have different efficiencies. - # We must calculate the stock contribution of each device separately using its own - # efficiencies, then sum them. We cannot aggregate power and apply one device's efficiencies. - if len(devices) > 1: - # Multiple devices sharing the same stock - must account for individual efficiencies - # Calculate stock change for each device individually, then sum - soc_contributions = [] - for d in devices: - soc_d = integrate_time_series( - series=ems_schedule[d], - initial_stock=0, # Start at 0 since we're just tracking contribution - stock_delta=device_constraints[d]["stock delta"] - * resolution - / timedelta(hours=1), - up_efficiency=device_constraints[d]["derivative up efficiency"], - down_efficiency=device_constraints[d][ - "derivative down efficiency" - ], - storage_efficiency=device_constraints[d]["efficiency"] - .astype(float) - .fillna(1), - ) - soc_contributions.append(soc_d) - - # Sum all contributions and add initial stock - soc = pd.Series( - [ - soc_at_start[d0] - + sum(contrib.iloc[i] for contrib in soc_contributions) - for i in range(len(soc_contributions[0])) - ], - index=soc_contributions[0].index, - ) - else: - # Single device - use original logic - stock_series = ems_schedule[d0] - soc = integrate_time_series( - series=stock_series, - initial_stock=soc_at_start[d0], - stock_delta=device_constraints[d0]["stock delta"] - * resolution - / timedelta(hours=1), - up_efficiency=device_constraints[d0]["derivative up efficiency"], - down_efficiency=device_constraints[d0][ - "derivative down efficiency" - ], - storage_efficiency=device_constraints[d0]["efficiency"] - .astype(float) - .fillna(1), - ) - - # attach SOC sensor if defined - soc_sensor = flex_model[d0].get("state_of_charge") - - if isinstance(soc_sensor, Sensor): - soc_schedule[soc_sensor] = convert_units( - soc, - from_unit="MWh", - to_unit=soc_sensor.unit, - ) + soc_schedule = self._build_soc_schedule( + flex_model=flex_model, + ems_schedule=ems_schedule, + soc_at_start=soc_at_start, + device_constraints=device_constraints, + stock_groups=self.stock_groups, + resolution=resolution, + ) # Resample each device schedule to the resolution of the device's power sensor if self.resolution is None: From cefe507ce2b37e9153510ab805f8e7da967ca717 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 23 Mar 2026 19:58:09 +0100 Subject: [PATCH 15/40] chore: code annotation Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py index 678067ca2a..708c89ae34 100644 --- a/flexmeasures/data/models/planning/__init__.py +++ b/flexmeasures/data/models/planning/__init__.py @@ -53,6 +53,7 @@ class Scheduler: flex_model: list[dict] | dict | None = None flex_context: dict | None = None + stock_groups: dict | None = None fallback_scheduler_class: "Type[Scheduler] | None" = None info: dict | None = None @@ -65,7 +66,8 @@ class Scheduler: return_multiple: bool = False - def _build_stock_groups(self, flex_model): + @staticmethod + def _build_stock_groups(self, flex_model: list[dict]) -> dict: groups = defaultdict(list) soc_sensor_to_stock_model = {} From 118587bb8bded0c711fbd9bd52af60983563f154 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 23 Mar 2026 19:58:26 +0100 Subject: [PATCH 16/40] fix: not all flex-models have sensors Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 261f86efc3..8444220dc1 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -99,16 +99,15 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 if not isinstance(flex_model, list): flex_model = [flex_model] - # Identify stock models (entries defining SOC limits) + # Identify stock models (entries defining SOC limits and a (state-of-charge) sensor) self.stock_models = {} for fm in flex_model: - if fm.get("soc_at_start") is not None: - sensor = fm["sensor"] - if isinstance(sensor, Sensor): - self.stock_models[sensor.id] = fm + if fm.get("soc_at_start") is not None and (soc_sensor := fm.get("sensor")): + if isinstance(soc_sensor, Sensor): + self.stock_models[soc_sensor.id] = fm else: - self.stock_models[sensor] = fm + self.stock_models[soc_sensor] = fm device_models = [] stock_models = {} @@ -116,9 +115,8 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 for fm in flex_model: # stock model - if fm.get("soc_at_start") is not None: - sensor = fm["sensor"] - stock_models[sensor.id if isinstance(sensor, Sensor) else sensor] = fm + if fm.get("soc_at_start") is not None and (soc_sensor := fm.get("sensor")): + stock_models[soc_sensor.id if isinstance(soc_sensor, Sensor) else soc_sensor] = fm continue # device model From 74b665f57988df1eeb6bf9e7900e2b323d67ca16 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 31 Mar 2026 11:30:42 +0200 Subject: [PATCH 17/40] fix: static method has no self Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py index 708c89ae34..ee1787878e 100644 --- a/flexmeasures/data/models/planning/__init__.py +++ b/flexmeasures/data/models/planning/__init__.py @@ -67,7 +67,7 @@ class Scheduler: return_multiple: bool = False @staticmethod - def _build_stock_groups(self, flex_model: list[dict]) -> dict: + def _build_stock_groups(flex_model: list[dict]) -> dict: groups = defaultdict(list) soc_sensor_to_stock_model = {} From fbcf2e57eb35ab90cd9158156335d9266931ecf7 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 31 Mar 2026 11:31:24 +0200 Subject: [PATCH 18/40] delete: remove inapplicable fields for stock model Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/tests/test_commitments.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index e7d04f1851..ffd1cfdd75 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -753,9 +753,6 @@ def test_two_devices_shared_stock(app, db): "soc-min": 10, "soc-max": 200.0, "soc-targets": [{"datetime": "2024-01-01T12:00:00+01:00", "value": 189.0}], - "power-capacity": "50 kW", - "charging-efficiency": 0.45, - "discharging-efficiency": 0.45, }, ] From 4259ffae95b2ddb784b8563fbdf9755b65304648 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 31 Mar 2026 11:32:36 +0200 Subject: [PATCH 19/40] fix: fix interpretation of test results Signed-off-by: F.N. Claessen --- .../models/planning/tests/test_commitments.py | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index ffd1cfdd75..18b68131e8 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -820,24 +820,26 @@ def test_two_devices_shared_stock(app, db): ) # ---- discharge behaviour - # Both inverters have zero power at the end of the horizon - # Discharging happens mid-horizon (hours 12-19 approximately) - # through inverter 1 only (the less efficient one, because inverter 2 - # has a lower discharging efficiency of 0.45 vs 0.95) + # Both inverters have zero power in the middle of the horizon + # Charging happens through inverter 2 (more efficient) as soon as possible (full SoC is preferred) + # Discharging happens through inverter 1 (more efficient) as late as possible (full SoC is preferred) assert ( - power1_data.iloc[-4:] == 0 - ).all(), "Battery should be idle at the end of the horizon through inverter 1." + power1_data.iloc[0 : int(96 / 2 + 13)] == 0 + ).all(), "Inverter 1 should be idle at the beginning of the scheduling period." assert ( - power2_data.iloc[-4:] == 0 - ).all(), ( - "Battery should be idle at the end of the horizon through inverter 2 as well." - ) - - # Verify that power1 actually discharges during middle hours (when inverter 1 goes negative) - assert ( - power1_data < 0 - ).any(), "Inverter 1 should discharge the battery during middle hours." + power2_data.iloc[int(96 / 2 - 13) : -1] == 0 + ).all(), "Inverter 2 should be idle at the end of the scheduling period." + + # Verify that inverter 1 actually discharges + assert (power1_data < 0).any(), "Inverter 1 should discharge the battery." + # Verify that inverter 1 never charges + assert not (power1_data > 0).any(), "Inverter 1 should not charge the battery." + + # Verify that inverter 2 actually charges + assert (power2_data > 0).any(), "Inverter 2 should charge the battery." + # Verify that inverter 1 never charges + assert not (power2_data < 0).any(), "Inverter 2 should not discharge the battery." # ---- SOC behaviour assert soc_data.iloc[0] == pytest.approx( From bc3991acf4d9c80d0c047a528ccbe98476aef8bb Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 31 Mar 2026 14:15:16 +0200 Subject: [PATCH 20/40] fix: move initialization of ems_constraints Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 8444220dc1..d673eeb0f2 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -309,6 +309,10 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 / pd.Timedelta("1h") ) + ems_constraints = initialize_df( + StorageScheduler.COLUMNS, start, end, resolution + ) + # Set up commitments DataFrame for d, flex_model_d in enumerate(flex_model): commodity = flex_model_d.get("commodity", "electricity") @@ -423,9 +427,6 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 "ems_production_breach_price" ) - ems_constraints = initialize_df( - StorageScheduler.COLUMNS, start, end, resolution - ) if ems_consumption_breach_price is not None: # Convert to Series From aefaf0df4ca81dd989143135ed607cc0be062583 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 31 Mar 2026 14:16:50 +0200 Subject: [PATCH 21/40] fix: resolve merge conflicts on _build_soc_schedule, copied from Ahmad Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 172 +++++++++---------- 1 file changed, 80 insertions(+), 92 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index d673eeb0f2..df416fa5fd 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1465,121 +1465,109 @@ def _build_soc_schedule( ems_schedule: list[pd.Series], soc_at_start: list[float], device_constraints: list, - stock_groups: dict, resolution: timedelta, + stock_groups: dict[int, list[int]], ) -> dict: - """Build the state-of-charge schedule for each device that has a state-of-charge sensor. + """Build the state-of-charge schedule for each stock group. - Converts the integrated power schedule from MWh to the sensor's unit. - For sensors with a '%' unit, the soc-max flex-model field is used as capacity. - If soc-max is missing or zero for a '%' sensor, the schedule is skipped with a warning. + Supports both: + - original logic: one device per stock group + - local/shared-stock logic: multiple devices contribute to one shared stock - Note: soc-max is a QuantityField (not a VariableQuantityField), so it is always a float - after deserialization and cannot be a sensor reference. The isinstance guard below is - therefore a defensive check for forward-compatibility. + For shared stock groups, each device contribution is integrated separately with + its own efficiencies and stock delta, then summed on top of the shared initial stock. + + Converts the integrated stock schedule from MWh to the state-of-charge sensor unit. + For '%' sensors, the soc-max flex-model field is used as capacity. """ soc_schedule = {} - for d, flex_model_d in enumerate(flex_model): - state_of_charge_sensor = flex_model_d.get("state_of_charge", None) + + for stock_id, devices in stock_groups.items(): + if not devices: + continue + + d0 = devices[0] + flex_model_d0 = flex_model[d0] + + state_of_charge_sensor = flex_model_d0.get("state_of_charge") if not isinstance(state_of_charge_sensor, Sensor): continue + + # Build the SoC series for this stock group + if len(devices) > 1: + soc_contributions = [] + reference_index = None + + for d in devices: + contribution = integrate_time_series( + series=ems_schedule[d], + initial_stock=0, + stock_delta=device_constraints[d]["stock delta"] + * resolution + / timedelta(hours=1), + up_efficiency=device_constraints[d]["derivative up efficiency"], + down_efficiency=device_constraints[d][ + "derivative down efficiency" + ], + storage_efficiency=device_constraints[d]["efficiency"] + .astype(float) + .fillna(1), + ) + soc_contributions.append(contribution) + + if reference_index is None: + reference_index = contribution.index + + initial_stock = soc_at_start[d0] if soc_at_start[d0] is not None else 0 + soc = pd.Series( + [ + initial_stock + + sum(contrib.iloc[i] for contrib in soc_contributions) + for i in range(len(soc_contributions[0])) + ], + index=reference_index, + ) + else: + soc = integrate_time_series( + series=ems_schedule[d0], + initial_stock=soc_at_start[d0], + stock_delta=device_constraints[d0]["stock delta"] + * resolution + / timedelta(hours=1), + up_efficiency=device_constraints[d0]["derivative up efficiency"], + down_efficiency=device_constraints[d0][ + "derivative down efficiency" + ], + storage_efficiency=device_constraints[d0]["efficiency"] + .astype(float) + .fillna(1), + ) + + # Convert to sensor unit soc_unit = state_of_charge_sensor.unit capacity = None if soc_unit == "%": - soc_max = flex_model_d.get("soc_max") + soc_max = flex_model_d0.get("soc_max") if isinstance(soc_max, Sensor): raise ValueError( - f"Cannot convert state-of-charge schedule to '%' unit for sensor {state_of_charge_sensor.id}: " - "soc-max as a sensor reference is not supported for '%' unit conversion. " - "Skipping state-of-charge schedule." + f"Cannot convert state-of-charge schedule to '%' unit for sensor " + f"{state_of_charge_sensor.id}: soc-max as a sensor reference is " + "not supported for '%' unit conversion." ) if not soc_max: raise ValueError( - f"Cannot convert state-of-charge schedule to '%' unit for sensor {state_of_charge_sensor.id}: " - "soc-max is missing or zero. Skipping state-of-charge schedule." + f"Cannot convert state-of-charge schedule to '%' unit for sensor " + f"{state_of_charge_sensor.id}: soc-max is missing or zero." ) - capacity = f"{soc_max} MWh" # all flex model fields are in MWh by now + capacity = f"{soc_max} MWh" + soc_schedule[state_of_charge_sensor] = convert_units( - integrate_time_series( - series=ems_schedule[d], - initial_stock=soc_at_start[d], - stock_delta=device_constraints[d]["stock delta"] - * resolution - / timedelta(hours=1), - up_efficiency=device_constraints[d]["derivative up efficiency"], - down_efficiency=device_constraints[d]["derivative down efficiency"], - storage_efficiency=device_constraints[d]["efficiency"] - .astype(float) - .fillna(1), - ), + soc, from_unit="MWh", to_unit=soc_unit, capacity=capacity, ) - # for stock_idx, (stock_id, devices) in enumerate(stock_groups.items()): - # d0 = devices[0] - # - # # For shared stock with multiple devices, each device may have different efficiencies. - # # We must calculate the stock contribution of each device separately using its own - # # efficiencies, then sum them. We cannot aggregate power and apply one device's efficiencies. - # if len(devices) > 1: - # # Multiple devices sharing the same stock - must account for individual efficiencies - # # Calculate stock change for each device individually, then sum - # soc_contributions = [] - # for d in devices: - # soc_d = integrate_time_series( - # series=ems_schedule[d], - # initial_stock=0, # Start at 0 since we're just tracking contribution - # stock_delta=device_constraints[d]["stock delta"] - # * resolution - # / timedelta(hours=1), - # up_efficiency=device_constraints[d]["derivative up efficiency"], - # down_efficiency=device_constraints[d][ - # "derivative down efficiency" - # ], - # storage_efficiency=device_constraints[d]["efficiency"] - # .astype(float) - # .fillna(1), - # ) - # soc_contributions.append(soc_d) - # - # # Sum all contributions and add initial stock - # soc = pd.Series( - # [ - # soc_at_start[d0] - # + sum(contrib.iloc[i] for contrib in soc_contributions) - # for i in range(len(soc_contributions[0])) - # ], - # index=soc_contributions[0].index, - # ) - # else: - # # Single device - use original logic - # stock_series = ems_schedule[d0] - # soc = integrate_time_series( - # series=stock_series, - # initial_stock=soc_at_start[d0], - # stock_delta=device_constraints[d0]["stock delta"] - # * resolution - # / timedelta(hours=1), - # up_efficiency=device_constraints[d0]["derivative up efficiency"], - # down_efficiency=device_constraints[d0][ - # "derivative down efficiency" - # ], - # storage_efficiency=device_constraints[d0]["efficiency"] - # .astype(float) - # .fillna(1), - # ) - # - # # attach SOC sensor if defined - # soc_sensor = flex_model[d0].get("state_of_charge") - # - # if isinstance(soc_sensor, Sensor): - # soc_schedule[soc_sensor] = convert_units( - # soc, - # from_unit="MWh", - # to_unit=soc_sensor.unit, - # ) return soc_schedule def compute( # noqa: C901 From 63b6bd7990335aa26489f193d8c77a785b52280f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 31 Mar 2026 14:17:27 +0200 Subject: [PATCH 22/40] fix: remove redundant code block Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index df416fa5fd..c83a9243e0 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -102,13 +102,6 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 # Identify stock models (entries defining SOC limits and a (state-of-charge) sensor) self.stock_models = {} - for fm in flex_model: - if fm.get("soc_at_start") is not None and (soc_sensor := fm.get("sensor")): - if isinstance(soc_sensor, Sensor): - self.stock_models[soc_sensor.id] = fm - else: - self.stock_models[soc_sensor] = fm - device_models = [] stock_models = {} From 0be435f7f4b2946886ae9c55d4dbd72d0c976360 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 31 Mar 2026 14:17:57 +0200 Subject: [PATCH 23/40] dev: use "state-of-charge" key instead of "sensor" key for stock models Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 32 +++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index c83a9243e0..40c5428ed1 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -99,7 +99,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 if not isinstance(flex_model, list): flex_model = [flex_model] - # Identify stock models (entries defining SOC limits and a (state-of-charge) sensor) + # Identify stock models: entries not defining a power sensor, but only a (state-of-charge) sensor self.stock_models = {} device_models = [] @@ -107,12 +107,34 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 for fm in flex_model: - # stock model - if fm.get("soc_at_start") is not None and (soc_sensor := fm.get("sensor")): - stock_models[soc_sensor.id if isinstance(soc_sensor, Sensor) else soc_sensor] = fm + # stock model: entry in the flex-model list where the sensor key is the state-of-charge sensor of the device (e.g. a stock) + if fm.get("sensor") is None and (soc_sensor := fm.get("state_of_charge")): + stock_models[ + soc_sensor.id if isinstance(soc_sensor, Sensor) else soc_sensor + ] = fm continue - # device model + """ + [ + { + "sensor": 1, + "charging-efficiency": 0.9, + "state-of-charge": {"sensor": 2}, + }, + { + "sensor": 3, + "charging-efficiency": 0.9, + "state-of-charge": {"sensor": 2}, + }, + { + "state-of-charge": {"sensor": 2}, + "storage-efficiency": 0.99, + }, + + ] + """ + + # device model: entry in the flex-model list where the sensor key is the power sensor of the device (e.g. a feeder) if fm.get("state_of_charge") is not None: device_models.append(fm) From 123f543be4019c65c4606ee9a6b111c1257475fb Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 31 Mar 2026 14:19:05 +0200 Subject: [PATCH 24/40] fix: skip StockCommitment for device models that outsource their stock model to a separately modeled device Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 40c5428ed1..8adac05a01 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -561,6 +561,11 @@ 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) ): + soc_max_d = soc_max[d] + soc_at_start_d = soc_at_start[d] + + if soc_max_d is None or soc_at_start_d is None: + continue if prefer_charging_sooner_d: tiny_price_slope = ( add_tiny_price_slope( From f02e2ee147df4835806d6363eeee9552557feaac Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 31 Mar 2026 14:43:52 +0200 Subject: [PATCH 25/40] fix: old flex models that describe a device that serves both as a feeder and stock are both categorized as device models and stock models Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/__init__.py | 3 +++ flexmeasures/data/models/planning/storage.py | 11 +++++++---- .../data/models/planning/tests/test_commitments.py | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py index ee1787878e..fd633a3a9b 100644 --- a/flexmeasures/data/models/planning/__init__.py +++ b/flexmeasures/data/models/planning/__init__.py @@ -79,10 +79,13 @@ def _build_stock_groups(flex_model: list[dict]) -> dict: soc_sensor_to_stock_model[soc_sensor] = i # group devices by soc sensor + missing_soc_sensor_i = -len(flex_model) for d, fm in enumerate(flex_model): soc = fm.get("state_of_charge") if soc is None: + groups[missing_soc_sensor_i].append(d) + missing_soc_sensor_i += 1 continue groups[soc.id].append(d) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 8adac05a01..bc18ba66dc 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -102,9 +102,10 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 # Identify stock models: entries not defining a power sensor, but only a (state-of-charge) sensor self.stock_models = {} - device_models = [] - stock_models = {} + device_models = [] # everything except stock models + stock_models = {} # stock models only + missing_soc_sensor_i = -len(flex_model) for fm in flex_model: # stock model: entry in the flex-model list where the sensor key is the state-of-charge sensor of the device (e.g. a stock) @@ -135,8 +136,10 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 """ # device model: entry in the flex-model list where the sensor key is the power sensor of the device (e.g. a feeder) - if fm.get("state_of_charge") is not None: - device_models.append(fm) + device_models.append(fm) + if fm.get("state_of_charge") is None: + stock_models[missing_soc_sensor_i] = fm + missing_soc_sensor_i += 1 flex_model = device_models self.stock_models = stock_models diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 18b68131e8..93c5b541c0 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -482,7 +482,7 @@ def test_two_flexible_assets_with_commodity(app, db): # Preference costs should reflect this energy ratio battery_total_pref = costs_data["prefer a full storage 0 sooner"] hp_total_pref = costs_data["prefer a full storage 1 sooner"] - assert battery_total_pref == 2 * hp_total_pref, ( + assert battery_total_pref == pytest.approx(2 * hp_total_pref, rel=1e-9), ( f"Battery preference costs ({battery_total_pref:.2e}) should be twice the " f"heat pump ({hp_total_pref:.2e}) preference costs, since battery moves more energy (60 kWh vs 30 kWh)" ) From 5fb576e653151f2b06012f83640088376081165d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 31 Mar 2026 14:51:19 +0200 Subject: [PATCH 26/40] fix: model stock devices using the state-of-charge field instead of the sensor field Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/tests/test_commitments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 93c5b541c0..a7d62ac346 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -748,7 +748,7 @@ def test_two_devices_shared_stock(app, db): "discharging-efficiency": 0.45, }, { - "sensor": state_of_charge.id, + "state-of-charge": state_of_charge.id, "soc-at-start": 20.0, "soc-min": 10, "soc-max": 200.0, From cb110a90cc7e84fecc6da2e3b59665589ea9177a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 31 Mar 2026 15:11:36 +0200 Subject: [PATCH 27/40] fix: identify asset to merge with db flex-model Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/__init__.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py index fd633a3a9b..a6a4350677 100644 --- a/flexmeasures/data/models/planning/__init__.py +++ b/flexmeasures/data/models/planning/__init__.py @@ -230,12 +230,19 @@ def collect_flex_config(self): # Listify the flex-model for the next code block, which actually does the merging with the db_flex_model flex_model = [flex_model] + # Find which asset is relevant for a given device model in the flex-model from the trigger message for flex_model_d in flex_model: asset_id = flex_model_d.get("asset") if asset_id is None: - sensor_id = flex_model_d["sensor"] - sensor = db.session.get(Sensor, sensor_id) - asset_id = sensor.asset_id + sensor_id = flex_model_d.get("sensor") + if sensor_id is not None: + sensor = db.session.get(Sensor, sensor_id) + asset_id = sensor.asset_id + else: + soc_sensor_ref = flex_model_d.get("state-of-charge") + if soc_sensor_ref is not None: + soc_sensor = db.session.get(Sensor, soc_sensor_ref["sensor"]) + asset_id = soc_sensor.asset_id if asset_id in db_flex_model: flex_model_d = {**db_flex_model[asset_id], **flex_model_d} amended_flex_model.append(flex_model_d) From b5bb77ea575c65278027c13a49ea132f10489e1d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 31 Mar 2026 15:12:00 +0200 Subject: [PATCH 28/40] fix: validation Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/scheduling/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 5463d116e8..1da124e4e0 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -904,7 +904,13 @@ def ensure_sensor_or_asset(self, data, **kwargs): and data["sensor"].asset != data["asset"] ): raise ValidationError("Sensor does not belong to asset.") - if "sensor" not in data and "asset" not in data: + if ( + "state-of-charge" in data["sensor_flex_model"] + and "asset" in data + and data["sensor_flex_model"]["state-of-charge"].asset != data["asset"] + ): + raise ValidationError("Sensor does not belong to asset.") + if "sensor" not in data and "state-of-charge" not in data["sensor_flex_model"] and "asset" not in data: raise ValidationError("Specify either a sensor or an asset.") @pre_load From 816eda79579393ac9d87b26620568e2c986537d0 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 31 Mar 2026 15:12:25 +0200 Subject: [PATCH 29/40] fix: flex-model setup in test Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/tests/test_commitments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index a7d62ac346..3de09bf568 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -748,7 +748,7 @@ def test_two_devices_shared_stock(app, db): "discharging-efficiency": 0.45, }, { - "state-of-charge": state_of_charge.id, + "state-of-charge": {"sensor": state_of_charge.id}, "soc-at-start": 20.0, "soc-min": 10, "soc-max": 200.0, From 1d5433f324701fb7fedf00e2ca9fd4f38543bb66 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Sat, 4 Apr 2026 22:57:19 +0200 Subject: [PATCH 30/40] fix: create stock group Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/__init__.py | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py index a6a4350677..e085c976d5 100644 --- a/flexmeasures/data/models/planning/__init__.py +++ b/flexmeasures/data/models/planning/__init__.py @@ -68,27 +68,36 @@ class Scheduler: @staticmethod def _build_stock_groups(flex_model: list[dict]) -> dict: - + """ + Build stock groups where devices sharing the same state-of-charge sensor are grouped together. + """ groups = defaultdict(list) - soc_sensor_to_stock_model = {} - - # identify stock models - for i, fm in enumerate(flex_model): - if fm.get("soc_at_start") is not None: - soc_sensor = fm["sensor"] - soc_sensor_to_stock_model[soc_sensor] = i + soc_usage = defaultdict(list) - # group devices by soc sensor - missing_soc_sensor_i = -len(flex_model) for d, fm in enumerate(flex_model): + if fm.get("sensor") is None: + continue + soc = fm.get("state_of_charge") + if soc is not None: + if hasattr(soc, "id"): + soc_id = soc.id + elif isinstance(soc, dict) and "sensor" in soc: + sensor = soc["sensor"] + soc_id = sensor.id if hasattr(sensor, "id") else sensor + else: + soc_id = soc + + soc_usage[soc_id].append(d) - if soc is None: + for soc_id, device_list in soc_usage.items(): + groups[soc_id] = device_list + + missing_soc_sensor_i = -len(flex_model) + for d, fm in enumerate(flex_model): + if fm.get("sensor") is not None and fm.get("state_of_charge") is None: groups[missing_soc_sensor_i].append(d) missing_soc_sensor_i += 1 - continue - - groups[soc.id].append(d) return dict(groups) From eeffbf3bb35f058dc569bd5a6ee943d29fd7a8b8 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Sat, 4 Apr 2026 23:02:03 +0200 Subject: [PATCH 31/40] use soc-sensor in case of missing power sensor and also correct stock groups Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/storage.py | 30 +++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index bc18ba66dc..bc9669b772 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -131,7 +131,6 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 "state-of-charge": {"sensor": 2}, "storage-efficiency": 0.99, }, - ] """ @@ -1080,6 +1079,12 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 ) # --- apply shared stock groups + # Store original stock_delta values for use in _build_soc_schedule + original_stock_deltas = [ + device_constraints[d]["stock delta"].copy() + for d in range(len(device_constraints)) + ] + if hasattr(self, "stock_groups") and self.stock_groups: for stock_id, devices in self.stock_groups.items(): @@ -1088,20 +1093,25 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 d0 = devices[0] + # Combine all stock_deltas on the primary device + # This ensures the optimizer sees a single shared stock combined_delta = sum( device_constraints[d]["stock delta"] for d in devices ) - device_constraints[d0]["stock delta"] = combined_delta - # secondary devices keep their delta but must not have SOC constraints + # Secondary devices: zero out stock_delta (it's now in primary) but keep power contribution for d in devices[1:]: + # Zero out stock_delta since it's now in primary device's combined_delta device_constraints[d]["stock delta"] = 0 # disable stock bounds for secondary devices device_constraints[d]["equals"] = np.nan device_constraints[d]["min"] = np.nan device_constraints[d]["max"] = np.nan + + # Store original stock_deltas for use in _build_soc_schedule + self.original_stock_deltas = original_stock_deltas return ( sensors, start, @@ -1219,9 +1229,21 @@ def deserialize_flex_config(self): self.flex_model ) for d, sensor_flex_model in enumerate(self.flex_model): + soc_sensor_id = ( + sensor_flex_model["sensor_flex_model"] + .get("state-of-charge", {}) + .get("sensor", None) + ) + soc_sensor = None + if soc_sensor_id is not None: + soc_sensor = Sensor.query.filter_by(id=soc_sensor_id).first() self.flex_model[d] = StorageFlexModelSchema( start=self.start, - sensor=sensor_flex_model.get("sensor"), + sensor=( + sensor_flex_model.get("sensor") + if sensor_flex_model.get("sensor") is not None + else soc_sensor + ), default_soc_unit=sensor_flex_model["sensor_flex_model"].get( "soc-unit" ), From d8cab123f1576657eb207767eb16c030cf64cacb Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Sun, 5 Apr 2026 02:29:23 +0200 Subject: [PATCH 32/40] fix: create stock model for a model which has itself stock --- flexmeasures/data/models/planning/storage.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index bc9669b772..0c9db4556f 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -136,7 +136,20 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 # device model: entry in the flex-model list where the sensor key is the power sensor of the device (e.g. a feeder) device_models.append(fm) - if fm.get("state_of_charge") is None: + + # If this device has state-of-charge parameters (soc-at-start, soc-min, etc.), + # also create a stock model entry so those parameters are properly captured + soc_sensor = fm.get("state_of_charge") + if soc_sensor is not None: + soc_id = soc_sensor.id if isinstance(soc_sensor, Sensor) else soc_sensor + # Check if there are SOC parameters in this device entry + has_soc_params = any( + param in fm + for param in ["soc_at_start", "soc_min", "soc_max", "soc_targets"] + ) + if has_soc_params: + stock_models[soc_id] = fm + elif fm.get("state_of_charge") is None: stock_models[missing_soc_sensor_i] = fm missing_soc_sensor_i += 1 From ba7b4337b2f9e2dcdb9cfd15217b6fbbbd11c79b Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Sun, 5 Apr 2026 02:30:38 +0200 Subject: [PATCH 33/40] update the assert statements Signed-off-by: Ahmad-Wahid --- .../models/planning/tests/test_commitments.py | 73 ++++++++----------- 1 file changed, 30 insertions(+), 43 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 3de09bf568..24e9315a2c 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -615,10 +615,10 @@ def test_mixed_gas_and_electricity_assets(app, db): costs_data = commitment_costs[0]["data"] - # Battery: 60kWh Δ (20→80) / 0.95 eff × 100 EUR/MWh + discharge loss ≈ 4.32 EUR + # Battery: 60kWh Δ (20→80) / 0.95 eff × 100 EUR/MWh = 6.32 EUR (charge) + discharge loss ≈ 4.32 EUR assert costs_data["electricity energy 0"] == pytest.approx(4.32, rel=1e-2), ( - f"Electricity energy cost (battery charging phase ~3h at 20kW with 95% efficiency " - f"+ discharge at end): 60kWh/0.95 × (100 EUR/MWh) = 4.32 EUR, " + f"Battery electricity cost (charges 60kWh with 95% efficiency + discharge): " + f"60kWh/0.95 × (100 EUR/MWh) = 4.32 EUR, " f"got {costs_data['electricity energy 0']}" ) @@ -629,52 +629,39 @@ def test_mixed_gas_and_electricity_assets(app, db): f"got {costs_data['gas energy 1']}" ) - # Battery charges early (3h @20kW): tiny slope cost = 3h × 20kW × (24/1e6) = 2.30e-6 EUR - assert costs_data["prefer charging device 0 sooner"] == pytest.approx( - 2.30e-6, rel=1e-2 - ), ( - f"Charging preference (battery charges early at low-slope cost): " - f"accumulates tiny slope penalty over charging period = 2.30e-6 EUR, " - f"got {costs_data['prefer charging device 0 sooner']}" + # Total energy cost: battery (4.32) + boiler (1.20) = 5.52 EUR + total_energy_cost = sum( + v + for k, v in costs_data.items() + if k.startswith("electricity energy") or k.startswith("gas energy") ) - - # Battery idle periods with 0.5× multiplier = 0.5 × 2.30e-6 = 1.15e-6 EUR (prioritizes early charge) - assert costs_data["prefer curtailing device 0 later"] == pytest.approx( - 1.15e-6, rel=1e-2 - ), ( - f"Curtailing preference (battery idle periods with 0.5× multiplier): " - f"= 0.5 × charging preference = 1.15e-6 EUR (weaker to prioritize early charging), " - f"got {costs_data['prefer curtailing device 0 later']}" + assert total_energy_cost == pytest.approx(5.52, rel=1e-2), ( + f"Total energy cost (battery 4.32 + boiler 1.20): " + f"= 5.52 EUR, got {total_energy_cost}" ) - # Boiler: constant 1kW × 24h × tiny_slope = 24h × 1kW × (24/1e6) = 1.20e-6 EUR (no flexibility) - assert costs_data["prefer charging device 1 sooner"] == pytest.approx( - 1.20e-6, rel=1e-2 - ), ( - f"Charging preference (boiler 1kW constant load, 24h duration): " - f"1 kW × 24h × tiny_slope = 1.20e-6 EUR (degenerate: no flexibility), " - f"got {costs_data['prefer charging device 1 sooner']}" - ) + # Battery prefers to charge as early as possible (3h @20kW, 1h@>0kW, then 0kW until the last slot with full discharge) + assert all(battery_data[:3] == 20) + assert battery_data[3] > 0 + assert all(battery_data[4:-1] == 0) + assert battery_data[-1] == -20 - # Boiler curtailing with 0.5× multiplier = 0.5 × 1.20e-6 = 6.00e-7 EUR (no flexibility) - assert costs_data["prefer curtailing device 1 later"] == pytest.approx( - 6.00e-7, rel=1e-2 - ), ( - f"Curtailing preference (boiler with 0.5× multiplier, no flexibility): " - f"= 0.5 × charging preference = 6.00e-7 EUR, " - f"got {costs_data['prefer curtailing device 1 later']}" - ) + # Boiler constant consumption throughout (1 kW for all 24 hours) + assert all(boiler_data == 1.0) + + # ---- PREFERENCE COSTS: Battery only + # Battery has preference cost since it can optimize charging/discharging timing. + # Boiler has NO preference cost since it has a constant 1kW consumption (fully constrained). + battery_total_pref = costs_data.get("prefer a full storage 0 sooner", 0) + boiler_total_pref = costs_data.get("prefer a full storage 1 sooner", 0) - # Verify charging cost ~2× curtailing cost (due to 0.5× multiplier) assert ( - costs_data["prefer charging device 0 sooner"] - > costs_data["prefer curtailing device 0 later"] - ), ( - f"Battery charging preference (2.30e-6) should cost ~2× more than curtailing " - f"(1.15e-6) due to 0.5× multiplier on curtailing slopes. " - f"This ensures optimizer prioritizes filling battery early over idling. " - f"Ratio: {costs_data['prefer charging device 0 sooner'] / costs_data['prefer curtailing device 0 later']:.1f}×" - ) + battery_total_pref > 0 + ), "Battery should have a preference cost since it optimizes charging/discharging timing." + + assert ( + boiler_total_pref == 0 + ), "Boiler should have NO preference cost since its consumption is fully constrained to 1kW constant." def test_two_devices_shared_stock(app, db): From a2295020eba02773561d5ca65d47cee24233820a Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Sun, 5 Apr 2026 02:40:42 +0200 Subject: [PATCH 34/40] remove stock-id field Signed-off-by: Ahmad-Wahid --- .../data/schemas/scheduling/storage.py | 21 ------------------- flexmeasures/ui/static/openapi-specs.json | 11 +--------- 2 files changed, 1 insertion(+), 31 deletions(-) diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index fb2ba801f5..9378955da8 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -226,22 +226,11 @@ class StorageFlexModelSchema(Schema): metadata=metadata.SOC_USAGE.to_dict(), ) commodity = fields.Str( - required=False, data_key="commodity", load_default="electricity", validate=OneOf(["electricity", "gas"]), metadata=dict(description="Commodity label for this device/asset."), ) - stock_id = fields.Str( - data_key="stock-id", - required=False, - load_default=None, - validate=validate.Length(min=1), - metadata=dict( - description="Identifier of a shared storage (stock) that this device charges/discharges. " - "Devices with the same stock-id share one SOC state." - ), - ) def __init__( self, @@ -525,16 +514,6 @@ class DBStorageFlexModelSchema(Schema): validate=OneOf(["electricity", "gas"]), metadata=dict(description="Commodity label for this device/asset."), ) - stock_id = fields.Str( - data_key="stock-id", - required=False, - load_default=None, - validate=validate.Length(min=1), - metadata=dict( - description="Identifier of a shared storage (stock) that this device charges/discharges. " - "Devices with the same stock-id share one SOC state." - ), - ) mapped_schema_keys: dict diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 6bca850a8e..54efa403e4 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -7,7 +7,7 @@ }, "termsOfService": null, "title": "FlexMeasures", - "version": "0.32.0" + "version": "0.31.0" }, "externalDocs": { "description": "FlexMeasures runs on the open source FlexMeasures technology. Read the docs here.", @@ -5512,15 +5512,6 @@ "gas" ], "description": "Commodity label for this device/asset." - }, - "stock-id": { - "type": [ - "string", - "null" - ], - "default": null, - "minLength": 1, - "description": "Identifier of a shared storage (stock) that this device charges/discharges. Devices with the same stock-id share one SOC state." } }, "additionalProperties": false From 819004478a7e3d1a8ce4c28068ed11a9f58b3711 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 7 Apr 2026 14:17:54 +0200 Subject: [PATCH 35/40] fix: correct the stock groups Signed-off-by: Ahmad-Wahid --- .../models/planning/linear_optimization.py | 5 + flexmeasures/data/models/planning/storage.py | 35 ++- .../models/planning/tests/test_commitments.py | 266 ++++++++++++++++++ .../data/schemas/scheduling/__init__.py | 14 +- 4 files changed, 309 insertions(+), 11 deletions(-) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index c6bafed8a5..36f3395b34 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -108,6 +108,11 @@ def device_scheduler( # noqa C901 for g, devices in stock_groups.items(): for d in devices: device_to_group[d] = g + # For devices not in any stock group (e.g., inflexible devices), + # map them to themselves so they're treated as individual groups + for d in range(len(device_constraints)): + if d not in device_to_group: + device_to_group[d] = d else: for d in range(len(device_constraints)): device_to_group[d] = d diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 0c9db4556f..220f0a4ccd 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -134,12 +134,20 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 ] """ + # Check if this is a stock-only model (no power sensor) + # Stock-only entries have SOC parameters but no power sensor + soc_sensor = fm.get("state_of_charge") + if fm.get("sensor") is None and soc_sensor is not None: + # This is a stock-only entry, add to stock_models only + soc_id = soc_sensor.id if isinstance(soc_sensor, Sensor) else soc_sensor + stock_models[soc_id] = fm + continue + # device model: entry in the flex-model list where the sensor key is the power sensor of the device (e.g. a feeder) device_models.append(fm) # If this device has state-of-charge parameters (soc-at-start, soc-min, etc.), # also create a stock model entry so those parameters are properly captured - soc_sensor = fm.get("state_of_charge") if soc_sensor is not None: soc_id = soc_sensor.id if isinstance(soc_sensor, Sensor) else soc_sensor # Check if there are SOC parameters in this device entry @@ -155,6 +163,13 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 flex_model = device_models self.stock_models = stock_models + self._device_models = ( + device_models # Store filtered model for later use in _build_soc_schedule + ) + + # Rebuild stock_groups using only device_models (which have sensors) + # This ensures the mapping aligns with the device indices + self.stock_groups = self._build_stock_groups(device_models) # List the asset(s) and sensor(s) being scheduled if self.asset is not None: @@ -1698,14 +1713,22 @@ def compute( # noqa: C901 if sensor is not None } - flex_model = self.flex_model.copy() + # Use the filtered device_models (stored during _prepare) not self.flex_model + # because stock_groups was rebuilt with device indices, not original indices + flex_model_for_soc = getattr(self, "_device_models", None) + if flex_model_for_soc is None: + # Fallback: reconstruct if not available (shouldn't happen in normal flow) + flex_model_for_soc = ( + self.flex_model.copy() + if isinstance(self.flex_model, dict) + else [fm for fm in self.flex_model if fm.get("sensor") is not None] + ) - if not isinstance(self.flex_model, list): - flex_model["sensor"] = sensors[0] - flex_model = [flex_model] + if not isinstance(flex_model_for_soc, list): + flex_model_for_soc = [flex_model_for_soc] soc_schedule = self._build_soc_schedule( - flex_model=flex_model, + flex_model=flex_model_for_soc, ems_schedule=ems_schedule, soc_at_start=soc_at_start, device_constraints=device_constraints, diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index ceb44eb57d..210cc52df3 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -16,6 +16,7 @@ from flexmeasures.data.models.time_series import Sensor from flexmeasures.data.models.planning.linear_optimization import device_scheduler from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType +from flexmeasures.data.utils import save_to_db def test_multi_feed_device_scheduler_shared_buffer(): @@ -868,3 +869,268 @@ def test_two_devices_shared_stock(app, db): "which should dominate since it performs most charging: " "~682.8 kWh at 0.99 efficiency * 100 EUR/MWh ≈ 17.07 EUR." ) + + +def test_simulation_copy(app, db): + # ---- asset types and assets + gas_boiler_type = get_or_create_model(GenericAssetType, name="gas-boiler") + tank_type = get_or_create_model(GenericAssetType, name="gas-tank") + site_type = get_or_create_model(GenericAssetType, name="site") + + site = GenericAsset( + name="Test Site", + generic_asset_type=site_type, + ) + building = GenericAsset( + name="Building", generic_asset_type=site_type, parent_asset_id=site.id + ) + pv = GenericAsset( + name="PV", + generic_asset_type=get_or_create_model(GenericAssetType, name="pv"), + parent_asset_id=site.id, + ) + + gas_boiler = GenericAsset( + name="Gas Boiler", generic_asset_type=gas_boiler_type, parent_asset_id=site.id + ) + + gas_tank = GenericAsset( + name="Gas Tank", generic_asset_type=tank_type, parent_asset_id=site.id + ) + battery = GenericAsset( + name="Battery", + generic_asset_type=get_or_create_model(GenericAssetType, name="battery"), + parent_asset_id=site.id, + ) + + db.session.add_all([gas_boiler, gas_tank, building, battery, pv, site]) + db.session.commit() + + # ---- sensors + start = pd.Timestamp("2026-04-07T00:00:00+01:00") + end = pd.Timestamp( + "2026-04-09T06:00:00+01:00" + ) # Extended to allow discharge target on April 8 + belief_time = pd.Timestamp( + "2026-04-05T00:00:00+01:00" + ) # 2 days before start for generous planning horizon + power_resolution = pd.Timedelta("15m") + energy_resolution = pd.Timedelta(0) + + building_raw_power = Sensor( + name="building raw power", + unit="kW", + event_resolution=power_resolution, + generic_asset=building, + ) + + pv_power = Sensor( + name="PV power", + unit="kW", + event_resolution=power_resolution, + generic_asset=pv, + ) + + pv_raw_power = Sensor( + name="PV raw power", + unit="kW", + event_resolution=power_resolution, + generic_asset=pv, + ) + + battery_power = Sensor( + name="battery power", + unit="kW", + event_resolution=power_resolution, + generic_asset=battery, + ) + + battery_soc = Sensor( + name="battery state-of-charge", + unit="kWh", + event_resolution=energy_resolution, # instantaneous + generic_asset=battery, + ) + + boiler_power = Sensor( + name="boiler power", + unit="kW", + event_resolution=power_resolution, + generic_asset=gas_boiler, + ) + + tank_power = Sensor( + name="tank power", + unit="kW", + event_resolution=power_resolution, + generic_asset=gas_tank, + ) + + tank_soc = Sensor( + name="tank state-of-charge", + unit="kWh", + event_resolution=energy_resolution, # instantaneous + generic_asset=gas_tank, + ) + + tank_soc_usage = Sensor( + name="tank soc usage", + unit="kW", + event_resolution=power_resolution, + generic_asset=gas_tank, + ) + + db.session.add_all( + [ + boiler_power, + tank_soc, + tank_power, + tank_soc_usage, + building_raw_power, + battery_power, + pv_power, + pv_raw_power, + battery_soc, + ] + ) + db.session.commit() + import timely_beliefs as tb + from flexmeasures import Source + + # add dummy data to building raw power to ensure site-level constraints are respected + building_data = pd.Series( + 100.0, + index=pd.date_range(start, end, freq=power_resolution, name="event_start"), + name="event_value", + ).reset_index() + + bdf = tb.BeliefsDataFrame( + building_data, + belief_horizon=-pd.Timedelta(seconds=1) * np.array(range(len(building_data))), + sensor=building_raw_power, + source=get_or_create_model(Source, name="Simulation"), + ) + save_to_db(bdf, bulk_save_objects=False, save_changed_beliefs_only=False) + + bdf = tb.BeliefsDataFrame( + building_data, + belief_horizon=-pd.Timedelta(seconds=1) * np.array(range(len(building_data))), + sensor=tank_soc_usage, + source=get_or_create_model(Source, name="Simulation"), + ) + + save_to_db(bdf, bulk_save_objects=False, save_changed_beliefs_only=False) + + # add dummy data to PV power to ensure site-level constraints are respected + # Create realistic PV data with solar curve pattern + pv_index = pd.date_range(start, end, freq=power_resolution, name="event_start") + # Solar generation typically peaks around noon and follows a bell curve + # Hours: 0-8 (night), 8-10 (morning ramp), 10-14 (peak 5-20kW), 14-16 (evening ramp), 16-24 (night) + hours = pv_index.hour + pv_index.minute / 60.0 + + pv_values = [] + for hour in hours: + if hour < 8 or hour >= 18: # Night time + pv_values.append(0.0) + elif 8 <= hour < 10: # Morning ramp + pv_values.append((hour - 8) * 5.0) # 0 to 10 kW + elif 10 <= hour < 11: # Morning continued + pv_values.append(10.0 + (hour - 10) * 10.0) # 10 to 20 kW + elif 11 <= hour < 12: # Peak approach + pv_values.append(20.0 + (hour - 11) * 5.0) # 20 to 25 kW + elif 12 <= hour < 14: # Peak sustained + pv_values.append(23.0) # ~23 kW peak + elif 14 <= hour < 15: # Afternoon decline + pv_values.append(23.0 - (hour - 14) * 10.0) # 23 to 13 kW + elif 15 <= hour < 16: # Afternoon continued + pv_values.append(13.0 - (hour - 15) * 5.0) # 13 to 8 kW + elif 16 <= hour < 18: # Evening ramp down + pv_values.append((18 - hour) * 4.0) # 8 to 0 kW + + pv_data = pd.DataFrame({"event_start": pv_index, "event_value": pv_values}) + + pv_bdf = tb.BeliefsDataFrame( + pv_data, + belief_horizon=-pd.Timedelta(seconds=1) * np.array(range(len(pv_data))), + sensor=pv_raw_power, + source=get_or_create_model(Source, name="Simulation"), + ) + save_to_db(pv_bdf, bulk_save_objects=False, save_changed_beliefs_only=False) + + # ---- flex-model with time-varying power capacity + # Device 0: boiler with time-varying power capacity (30 kW throughout the entire period) + # Storage container: tank with shared state-of-charge + flex_model = [ + # { + # "sensor": pv_power.id, + # "consumption-capacity": "0 kW", + # "production-capacity": {"sensor": pv_raw_power.id}, + # "power-capacity": "1 GW", + # }, + # { + # "sensor": battery_power.id, + # "soc-min": 0.0, + # "soc-max": 100.0, + # "soc-at-start": 20.0, + # "power-capacity": "20 kW", + # "roundtrip-efficiency": 0.9, + # "soc-targets": [{"datetime": "2026-04-07T20:00:00+01:00", "value": 80.0}], + # "state-of-charge": {"sensor": battery_soc.id}, + # "commodity": "electricity", + # + # }, + { + "sensor": boiler_power.id, + "state-of-charge": {"sensor": tank_soc.id}, + "production-capacity": "100 kW", + "power-capacity": "100 kW", + "charging-efficiency": 0.5, + "commodity": "gas", + "discharging-efficiency": 0.5, + }, + { + # "sensor": tank_power.id, + "soc-min": 200.0, + "soc-max": 1000.0, + "soc-at-start": 200.0, + "power-capacity": "100 kW", + # "soc-targets": [ + # {"datetime": "2026-04-07T20:00:00+01:00", "value": 700.0}, + # # {"datetime": "2026-04-08T18:00:00+01:00", "value": 400.0}, # Discharge target - tank discharges after charging + # ], + "commodity": "gas", + "state-of-charge": {"sensor": tank_soc.id}, + "discharging-efficiency": 1, + "charging-efficiency": 1, + }, + ] + + flex_context = { + "consumption-price": "100 EUR/MWh", + "production-price": "100 EUR/MWh", + "gas-price": "50 EUR/MWh", + "site-power-capacity": "4700 kW", + "site-consumption-capacity": "4000 kW", + "site-production-capacity": "0 kW", + "site-consumption-breach-price": "100000 EUR/kW", + "site-production-breach-price": "100000 EUR/kW", + "relax-constraints": True, + "inflexible-device-sensors": [building_raw_power.id], + } + + scheduler = StorageScheduler( + asset_or_sensor=site, + start=start, + end=end, + resolution=power_resolution, + belief_time=belief_time, + flex_model=flex_model, + flex_context=flex_context, + return_multiple=True, + ) + + pd.set_option("display.max_rows", None) + schedules = scheduler.compute(skip_validation=True) + + # ---- verify outputs + print(schedules) diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 1da124e4e0..7f1c747a3c 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -904,13 +904,17 @@ def ensure_sensor_or_asset(self, data, **kwargs): and data["sensor"].asset != data["asset"] ): raise ValidationError("Sensor does not belong to asset.") + # if ( + # "state-of-charge" in data["sensor_flex_model"] + # and "asset" in data + # and data["sensor_flex_model"]["state-of-charge"].asset != data["asset"] + # ): + # raise ValidationError("Sensor does not belong to asset.") if ( - "state-of-charge" in data["sensor_flex_model"] - and "asset" in data - and data["sensor_flex_model"]["state-of-charge"].asset != data["asset"] + "sensor" not in data + and "state-of-charge" not in data["sensor_flex_model"] + and "asset" not in data ): - raise ValidationError("Sensor does not belong to asset.") - if "sensor" not in data and "state-of-charge" not in data["sensor_flex_model"] and "asset" not in data: raise ValidationError("Specify either a sensor or an asset.") @pre_load From 177154c3e5765069f468b140967b32791fccdb24 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 9 Apr 2026 13:47:02 +0200 Subject: [PATCH 36/40] refactor: remove unneccessary test function Signed-off-by: Ahmad-Wahid --- .../models/planning/tests/test_commitments.py | 266 ------------------ 1 file changed, 266 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 210cc52df3..ceb44eb57d 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -16,7 +16,6 @@ from flexmeasures.data.models.time_series import Sensor from flexmeasures.data.models.planning.linear_optimization import device_scheduler from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType -from flexmeasures.data.utils import save_to_db def test_multi_feed_device_scheduler_shared_buffer(): @@ -869,268 +868,3 @@ def test_two_devices_shared_stock(app, db): "which should dominate since it performs most charging: " "~682.8 kWh at 0.99 efficiency * 100 EUR/MWh ≈ 17.07 EUR." ) - - -def test_simulation_copy(app, db): - # ---- asset types and assets - gas_boiler_type = get_or_create_model(GenericAssetType, name="gas-boiler") - tank_type = get_or_create_model(GenericAssetType, name="gas-tank") - site_type = get_or_create_model(GenericAssetType, name="site") - - site = GenericAsset( - name="Test Site", - generic_asset_type=site_type, - ) - building = GenericAsset( - name="Building", generic_asset_type=site_type, parent_asset_id=site.id - ) - pv = GenericAsset( - name="PV", - generic_asset_type=get_or_create_model(GenericAssetType, name="pv"), - parent_asset_id=site.id, - ) - - gas_boiler = GenericAsset( - name="Gas Boiler", generic_asset_type=gas_boiler_type, parent_asset_id=site.id - ) - - gas_tank = GenericAsset( - name="Gas Tank", generic_asset_type=tank_type, parent_asset_id=site.id - ) - battery = GenericAsset( - name="Battery", - generic_asset_type=get_or_create_model(GenericAssetType, name="battery"), - parent_asset_id=site.id, - ) - - db.session.add_all([gas_boiler, gas_tank, building, battery, pv, site]) - db.session.commit() - - # ---- sensors - start = pd.Timestamp("2026-04-07T00:00:00+01:00") - end = pd.Timestamp( - "2026-04-09T06:00:00+01:00" - ) # Extended to allow discharge target on April 8 - belief_time = pd.Timestamp( - "2026-04-05T00:00:00+01:00" - ) # 2 days before start for generous planning horizon - power_resolution = pd.Timedelta("15m") - energy_resolution = pd.Timedelta(0) - - building_raw_power = Sensor( - name="building raw power", - unit="kW", - event_resolution=power_resolution, - generic_asset=building, - ) - - pv_power = Sensor( - name="PV power", - unit="kW", - event_resolution=power_resolution, - generic_asset=pv, - ) - - pv_raw_power = Sensor( - name="PV raw power", - unit="kW", - event_resolution=power_resolution, - generic_asset=pv, - ) - - battery_power = Sensor( - name="battery power", - unit="kW", - event_resolution=power_resolution, - generic_asset=battery, - ) - - battery_soc = Sensor( - name="battery state-of-charge", - unit="kWh", - event_resolution=energy_resolution, # instantaneous - generic_asset=battery, - ) - - boiler_power = Sensor( - name="boiler power", - unit="kW", - event_resolution=power_resolution, - generic_asset=gas_boiler, - ) - - tank_power = Sensor( - name="tank power", - unit="kW", - event_resolution=power_resolution, - generic_asset=gas_tank, - ) - - tank_soc = Sensor( - name="tank state-of-charge", - unit="kWh", - event_resolution=energy_resolution, # instantaneous - generic_asset=gas_tank, - ) - - tank_soc_usage = Sensor( - name="tank soc usage", - unit="kW", - event_resolution=power_resolution, - generic_asset=gas_tank, - ) - - db.session.add_all( - [ - boiler_power, - tank_soc, - tank_power, - tank_soc_usage, - building_raw_power, - battery_power, - pv_power, - pv_raw_power, - battery_soc, - ] - ) - db.session.commit() - import timely_beliefs as tb - from flexmeasures import Source - - # add dummy data to building raw power to ensure site-level constraints are respected - building_data = pd.Series( - 100.0, - index=pd.date_range(start, end, freq=power_resolution, name="event_start"), - name="event_value", - ).reset_index() - - bdf = tb.BeliefsDataFrame( - building_data, - belief_horizon=-pd.Timedelta(seconds=1) * np.array(range(len(building_data))), - sensor=building_raw_power, - source=get_or_create_model(Source, name="Simulation"), - ) - save_to_db(bdf, bulk_save_objects=False, save_changed_beliefs_only=False) - - bdf = tb.BeliefsDataFrame( - building_data, - belief_horizon=-pd.Timedelta(seconds=1) * np.array(range(len(building_data))), - sensor=tank_soc_usage, - source=get_or_create_model(Source, name="Simulation"), - ) - - save_to_db(bdf, bulk_save_objects=False, save_changed_beliefs_only=False) - - # add dummy data to PV power to ensure site-level constraints are respected - # Create realistic PV data with solar curve pattern - pv_index = pd.date_range(start, end, freq=power_resolution, name="event_start") - # Solar generation typically peaks around noon and follows a bell curve - # Hours: 0-8 (night), 8-10 (morning ramp), 10-14 (peak 5-20kW), 14-16 (evening ramp), 16-24 (night) - hours = pv_index.hour + pv_index.minute / 60.0 - - pv_values = [] - for hour in hours: - if hour < 8 or hour >= 18: # Night time - pv_values.append(0.0) - elif 8 <= hour < 10: # Morning ramp - pv_values.append((hour - 8) * 5.0) # 0 to 10 kW - elif 10 <= hour < 11: # Morning continued - pv_values.append(10.0 + (hour - 10) * 10.0) # 10 to 20 kW - elif 11 <= hour < 12: # Peak approach - pv_values.append(20.0 + (hour - 11) * 5.0) # 20 to 25 kW - elif 12 <= hour < 14: # Peak sustained - pv_values.append(23.0) # ~23 kW peak - elif 14 <= hour < 15: # Afternoon decline - pv_values.append(23.0 - (hour - 14) * 10.0) # 23 to 13 kW - elif 15 <= hour < 16: # Afternoon continued - pv_values.append(13.0 - (hour - 15) * 5.0) # 13 to 8 kW - elif 16 <= hour < 18: # Evening ramp down - pv_values.append((18 - hour) * 4.0) # 8 to 0 kW - - pv_data = pd.DataFrame({"event_start": pv_index, "event_value": pv_values}) - - pv_bdf = tb.BeliefsDataFrame( - pv_data, - belief_horizon=-pd.Timedelta(seconds=1) * np.array(range(len(pv_data))), - sensor=pv_raw_power, - source=get_or_create_model(Source, name="Simulation"), - ) - save_to_db(pv_bdf, bulk_save_objects=False, save_changed_beliefs_only=False) - - # ---- flex-model with time-varying power capacity - # Device 0: boiler with time-varying power capacity (30 kW throughout the entire period) - # Storage container: tank with shared state-of-charge - flex_model = [ - # { - # "sensor": pv_power.id, - # "consumption-capacity": "0 kW", - # "production-capacity": {"sensor": pv_raw_power.id}, - # "power-capacity": "1 GW", - # }, - # { - # "sensor": battery_power.id, - # "soc-min": 0.0, - # "soc-max": 100.0, - # "soc-at-start": 20.0, - # "power-capacity": "20 kW", - # "roundtrip-efficiency": 0.9, - # "soc-targets": [{"datetime": "2026-04-07T20:00:00+01:00", "value": 80.0}], - # "state-of-charge": {"sensor": battery_soc.id}, - # "commodity": "electricity", - # - # }, - { - "sensor": boiler_power.id, - "state-of-charge": {"sensor": tank_soc.id}, - "production-capacity": "100 kW", - "power-capacity": "100 kW", - "charging-efficiency": 0.5, - "commodity": "gas", - "discharging-efficiency": 0.5, - }, - { - # "sensor": tank_power.id, - "soc-min": 200.0, - "soc-max": 1000.0, - "soc-at-start": 200.0, - "power-capacity": "100 kW", - # "soc-targets": [ - # {"datetime": "2026-04-07T20:00:00+01:00", "value": 700.0}, - # # {"datetime": "2026-04-08T18:00:00+01:00", "value": 400.0}, # Discharge target - tank discharges after charging - # ], - "commodity": "gas", - "state-of-charge": {"sensor": tank_soc.id}, - "discharging-efficiency": 1, - "charging-efficiency": 1, - }, - ] - - flex_context = { - "consumption-price": "100 EUR/MWh", - "production-price": "100 EUR/MWh", - "gas-price": "50 EUR/MWh", - "site-power-capacity": "4700 kW", - "site-consumption-capacity": "4000 kW", - "site-production-capacity": "0 kW", - "site-consumption-breach-price": "100000 EUR/kW", - "site-production-breach-price": "100000 EUR/kW", - "relax-constraints": True, - "inflexible-device-sensors": [building_raw_power.id], - } - - scheduler = StorageScheduler( - asset_or_sensor=site, - start=start, - end=end, - resolution=power_resolution, - belief_time=belief_time, - flex_model=flex_model, - flex_context=flex_context, - return_multiple=True, - ) - - pd.set_option("display.max_rows", None) - schedules = scheduler.compute(skip_validation=True) - - # ---- verify outputs - print(schedules) From a110f0ec7dd0904801266de94a442940f857cffa Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 9 Apr 2026 14:11:05 +0200 Subject: [PATCH 37/40] fix: shared soc-gain, soc-usage, soc-minima and soc-maxima Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 220f0a4ccd..65b4201cc0 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -199,6 +199,10 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 soc_targets = [None] * num_flexible_devices soc_min = [None] * num_flexible_devices soc_max = [None] * num_flexible_devices + soc_minima = [None] * num_flexible_devices + soc_maxima = [None] * num_flexible_devices + soc_gain = [None] * num_flexible_devices + soc_usage = [None] * num_flexible_devices # Assign SOC constraints from stock model to the first device in each group for stock_id, devices in self.stock_groups.items(): @@ -214,9 +218,11 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 soc_targets[d0] = stock_model.get("soc_targets") soc_min[d0] = stock_model.get("soc_min") soc_max[d0] = stock_model.get("soc_max") + soc_minima[d0] = stock_model.get("soc_minima") + soc_maxima[d0] = stock_model.get("soc_maxima") + soc_gain[d0] = stock_model.get("soc_gain") + soc_usage[d0] = stock_model.get("soc_usage") - soc_minima = [flex_model_d.get("soc_minima") for flex_model_d in flex_model] - soc_maxima = [flex_model_d.get("soc_maxima") for flex_model_d in flex_model] storage_efficiency = [ flex_model_d.get("storage_efficiency") for flex_model_d in flex_model ] @@ -226,8 +232,6 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 prefer_curtailing_later = [ flex_model_d.get("prefer_curtailing_later") for flex_model_d in flex_model ] - soc_gain = [flex_model_d.get("soc_gain") for flex_model_d in flex_model] - soc_usage = [flex_model_d.get("soc_usage") for flex_model_d in flex_model] consumption_capacity = [ flex_model_d.get("consumption_capacity") for flex_model_d in flex_model ] From c7679ef77f6c4610e8d6c16ec903473bd4b7c6ee Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 9 Apr 2026 14:16:43 +0200 Subject: [PATCH 38/40] fix: shared StockCommitment for preferring a full SoC Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 65b4201cc0..df723efd72 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -203,6 +203,8 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 soc_maxima = [None] * num_flexible_devices soc_gain = [None] * num_flexible_devices soc_usage = [None] * num_flexible_devices + prefer_charging_sooner = [None] * num_flexible_devices + prefer_curtailing_later = [None] * num_flexible_devices # Assign SOC constraints from stock model to the first device in each group for stock_id, devices in self.stock_groups.items(): @@ -222,16 +224,12 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 soc_maxima[d0] = stock_model.get("soc_maxima") soc_gain[d0] = stock_model.get("soc_gain") soc_usage[d0] = stock_model.get("soc_usage") + prefer_charging_sooner[d0] = stock_model.get("prefer_charging_sooner") + prefer_curtailing_later[d0] = stock_model.get("prefer_curtailing_later") storage_efficiency = [ flex_model_d.get("storage_efficiency") for flex_model_d in flex_model ] - prefer_charging_sooner = [ - flex_model_d.get("prefer_charging_sooner") for flex_model_d in flex_model - ] - prefer_curtailing_later = [ - flex_model_d.get("prefer_curtailing_later") for flex_model_d in flex_model - ] consumption_capacity = [ flex_model_d.get("consumption_capacity") for flex_model_d in flex_model ] From 53d27c7a71e8f6c74fb5171e86291a3f0a043434 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 9 Apr 2026 14:17:23 +0200 Subject: [PATCH 39/40] dev: todo Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index df723efd72..cc998efe33 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -227,6 +227,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 prefer_charging_sooner[d0] = stock_model.get("prefer_charging_sooner") prefer_curtailing_later[d0] = stock_model.get("prefer_curtailing_later") + # todo: move storage-efficiency into a shared parameter for the first device belonging to a shared storage storage_efficiency = [ flex_model_d.get("storage_efficiency") for flex_model_d in flex_model ] From 69b5e272e3bc97c5f60ffafe28a552ef4dc08e9f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 9 Apr 2026 14:26:00 +0200 Subject: [PATCH 40/40] dev: add "test" test case Signed-off-by: F.N. Claessen --- .../models/planning/tests/test_commitments.py | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index ceb44eb57d..37f5b156d3 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -16,6 +16,7 @@ from flexmeasures.data.models.time_series import Sensor from flexmeasures.data.models.planning.linear_optimization import device_scheduler from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType +from flexmeasures.data.utils import save_to_db def test_multi_feed_device_scheduler_shared_buffer(): @@ -868,3 +869,218 @@ def test_two_devices_shared_stock(app, db): "which should dominate since it performs most charging: " "~682.8 kWh at 0.99 efficiency * 100 EUR/MWh ≈ 17.07 EUR." ) + + +def test_simulation_copy_new(app, db): + # ---- asset types and assets + gas_boiler_type = get_or_create_model(GenericAssetType, name="gas-boiler") + buffer_type = get_or_create_model(GenericAssetType, name="heat-buffer") + site_type = get_or_create_model(GenericAssetType, name="site") + + site = GenericAsset( + name="Test Site", + generic_asset_type=site_type, + ) + building = GenericAsset( + name="Building", generic_asset_type=site_type, parent_asset_id=site.id + ) + + gas_boiler = GenericAsset( + name="Gas Boiler", generic_asset_type=gas_boiler_type, parent_asset_id=site.id + ) + heat_buffer = GenericAsset( + name="Heat Buffer", generic_asset_type=buffer_type, parent_asset_id=site.id + ) + electric_heater = GenericAsset( + name="Electric Heater", + generic_asset_type=get_or_create_model( + GenericAssetType, name="electric-heater" + ), + parent_asset_id=site.id, + ) + + db.session.add_all([gas_boiler, heat_buffer, building, electric_heater, site]) + db.session.commit() + + # ---- sensors + start = pd.Timestamp("2026-04-07T00:00:00+01:00") + end = pd.Timestamp( + "2026-04-09T06:00:00+01:00" + ) # Extended to allow discharge target on April 8 + belief_time = pd.Timestamp( + "2026-04-05T00:00:00+01:00" + ) # 2 days before start for generous planning horizon + power_resolution = pd.Timedelta("15m") + energy_resolution = pd.Timedelta(0) + + building_raw_power = Sensor( + name="building raw power", + unit="kW", + event_resolution=power_resolution, + generic_asset=building, + ) + + boiler_power = Sensor( + name="boiler power", + unit="kW", + event_resolution=power_resolution, + generic_asset=gas_boiler, + ) + + tank_power = Sensor( + name="heat buffer power", + unit="kW", + event_resolution=power_resolution, + generic_asset=heat_buffer, + ) + + buffer_soc = Sensor( + name="buffer state of charge", + unit="kWh", + event_resolution=energy_resolution, # instantaneous + generic_asset=heat_buffer, + ) + + buffer_soc_usage = Sensor( + name="buffer soc usage", + unit="kW", + event_resolution=power_resolution, + generic_asset=heat_buffer, + ) + + heater_power = Sensor( + name="heater power", + unit="kW", + event_resolution=power_resolution, + generic_asset=electric_heater, + ) + soc_targets = Sensor( + name="buffer soc targets", + unit="kWh", + event_resolution=energy_resolution, # instantaneous + generic_asset=heat_buffer, + ) + + db.session.add_all( + [ + boiler_power, + buffer_soc, + tank_power, + buffer_soc_usage, + building_raw_power, + heater_power, + soc_targets, + ] + ) + db.session.commit() + import timely_beliefs as tb + from flexmeasures import Source + + # add dummy data to building raw power to ensure site-level constraints are respected + building_data = pd.Series( + 100.0, + index=pd.date_range(start, end, freq=power_resolution, name="event_start"), + name="event_value", + ).reset_index() + + soc_usage = building_data.copy() + + bdf = tb.BeliefsDataFrame( + building_data, + belief_horizon=-pd.Timedelta(seconds=1) * np.array(range(len(building_data))), + sensor=building_raw_power, + source=get_or_create_model(Source, name="Simulation"), + ) + save_to_db(bdf, bulk_save_objects=False, save_changed_beliefs_only=False) + + soc_usage["event_value"] = soc_usage["event_value"] * 1.49 + bdf = tb.BeliefsDataFrame( + soc_usage, + belief_time=belief_time, + sensor=buffer_soc_usage, + source=get_or_create_model(Source, name="Simulation"), + ) + + save_to_db(bdf, bulk_save_objects=False, save_changed_beliefs_only=False) + + flex_model = [ + # { + # "sensor": pv_power.id, + # "consumption-capacity": "0 kW", + # "production-capacity": {"sensor": pv_raw_power.id}, + # "power-capacity": "1 GW", + # }, + # { + # "sensor": battery_power.id, + # "soc-min": 0.0, + # "soc-max": 100.0, + # "soc-at-start": 20.0, + # "power-capacity": "20 kW", + # "roundtrip-efficiency": 0.9, + # "soc-targets": [{"datetime": "2026-04-07T20:00:00+01:00", "value": 80.0}], + # "state-of-charge": {"sensor": battery_soc.id}, + # "commodity": "electricity", + # + # }, + { + "sensor": heater_power.id, + "state-of-charge": {"sensor": buffer_soc.id}, + "power-capacity": "100 kW", + "charging-efficiency": 0.9, + "commodity": "electricity", + "production-capacity": "0 kW", + # "storage-efficiency": 0.9, # todo: workaround does not work yet + }, + { + "sensor": boiler_power.id, + "state-of-charge": {"sensor": buffer_soc.id}, + "power-capacity": "100 kW", + "charging-efficiency": 0.9, + "commodity": "gas", + "production-capacity": "0 kW", + # "storage-efficiency": 0.9, # todo: workaround does not work yet + }, + { + # "sensor": tank_power.id, + "soc-min": 200.0, + "soc-max": 1000.0, + "soc-at-start": 200.0, + # "soc-targets": [ + # {"datetime": "2026-04-07T20:00:00+01:00", "value": 700.0}, + # ], + "state-of-charge": {"sensor": buffer_soc.id}, + # "soc-usage": [{"sensor": buffer_soc_usage.id}], + "storage-efficiency": 0.9, # todo: does not work yet + # todo: consider assigning this to the heat commodity, maybe we can derive some useful (costs?) KPI from it + }, + ] + + flex_context = { + "consumption-price": "100 EUR/MWh", + "production-price": "100 EUR/MWh", + "gas-price": "150 EUR/MWh", + "site-power-capacity": "4700 kW", + "site-consumption-capacity": "4000 kW", + "site-production-capacity": "100 kW", + "site-consumption-breach-price": "100000 EUR/kW", + "site-production-breach-price": "100000 EUR/kW", + "relax-constraints": True, + "inflexible-device-sensors": [building_raw_power.id], + } + + scheduler = StorageScheduler( + asset_or_sensor=site, + start=start, + end=end, + resolution=power_resolution, + belief_time=belief_time, + flex_model=flex_model, + flex_context=flex_context, + return_multiple=True, + ) + + pd.set_option("display.max_rows", None) + schedules = scheduler.compute(skip_validation=True) + + # ---- verify outputs + print(schedules)