From 8c4ec5ef94e2f762540ed36b135d4aeab545e2c7 Mon Sep 17 00:00:00 2001 From: Taran Mamidala Date: Mon, 31 Aug 2026 06:07:22 -0400 Subject: [PATCH] feat(planning): export commodity_costs in StorageScheduler outputs and persist in job meta (#2416) Signed-off-by: Taran Mamidala --- documentation/changelog.rst | 1 + flexmeasures/data/models/planning/storage.py | 16 +++ .../models/planning/tests/test_commitments.py | 103 +++++++++++++++++- .../models/planning/tests/test_storage.py | 7 ++ flexmeasures/data/services/scheduling.py | 4 + .../data/tests/test_scheduling_jobs.py | 3 + 6 files changed, 132 insertions(+), 2 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 6368bf9e0a..a5f7519d3c 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -10,6 +10,7 @@ v1.1.0 | September XX, 2026 New features ------------- +* StorageScheduler now exports a ``commodity_costs`` breakdown alongside ``commitment_costs`` for multi-commodity or non-default commodity schedules and persists it in ``rq_job.meta["scheduler_info"]["commodity_costs"]`` [see `issue #2416 `_] Infrastructure / Support ------------------------- diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 43e1c11d69..3c2af995e4 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -3643,6 +3643,21 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: "unit": self.flex_context["shared_currency_unit"], }, ] + commodity_costs = ( + [ + { + "name": "commodity_costs", + "data": model.commodity_costs, + "unit": self.flex_context["shared_currency_unit"], + }, + ] + if getattr(model, "commodity_costs", None) + and ( + len(model.commodity_costs) > 1 + or set(model.commodity_costs.keys()) != {"electricity"} + ) + else [] + ) soc_schedules = [ { "name": "state_of_charge", @@ -3686,6 +3701,7 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: return self._deduplicate_outputs( storage_schedules + commitment_costs + + commodity_costs + soc_schedules + consumption_production_schedules + scheduling_result diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 7094a43ca7..35c95b0c2a 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -794,8 +794,8 @@ def test_mixed_gas_and_electricity_assets(app, db): assert isinstance(schedules, list) assert ( - len(schedules) == 4 - ) # 2 storage schedules + 1 commitment costs + 1 scheduling_result + len(schedules) == 5 + ) # 2 storage schedules + 1 commitment costs + 1 commodity costs + 1 scheduling_result # Extract schedules by type storage_schedules = [ @@ -804,9 +804,13 @@ def test_mixed_gas_and_electricity_assets(app, db): commitment_costs = [ entry for entry in schedules if entry.get("name") == "commitment_costs" ] + commodity_costs = [ + entry for entry in schedules if entry.get("name") == "commodity_costs" + ] assert len(storage_schedules) == 2 assert len(commitment_costs) == 1 + assert len(commodity_costs) == 1 # Get battery schedule battery_schedule = next( @@ -859,6 +863,11 @@ def test_mixed_gas_and_electricity_assets(app, db): f"= 5.52 EUR, got {total_energy_cost}" ) + # Commodity costs breakdown + commodity_costs_data = commodity_costs[0]["data"] + assert commodity_costs_data["electricity"] == pytest.approx(4.32, rel=1e-2) + assert commodity_costs_data["gas"] == pytest.approx(1.20, rel=1e-2) + # 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 @@ -3300,3 +3309,93 @@ def test_commitments_in_commodity_contexts_are_converted(app): nested_spec = scheduler.flex_context["commodity_contexts"][0]["commitments"][0] assert "baseline" in nested_spec assert nested_spec["commodity"] == "electricity" + + +def test_commodity_costs_output_filtering(): + """Verify that StorageScheduler only exports commodity_costs when there are multiple + commodities or a non-default commodity (skipping single-commodity default electricity). + """ + scheduler = object.__new__(StorageScheduler) + scheduler.flex_context = {"shared_currency_unit": "EUR"} + + class MockModel: + def __init__(self, commodity_costs): + self.commodity_costs = commodity_costs + + # Empty or None -> no commodity_costs + for model in [MockModel({}), MockModel(None), object()]: + costs = ( + [ + { + "name": "commodity_costs", + "data": getattr(model, "commodity_costs", None), + "unit": scheduler.flex_context["shared_currency_unit"], + } + ] + if getattr(model, "commodity_costs", None) + and ( + len(model.commodity_costs) > 1 + or set(model.commodity_costs.keys()) != {"electricity"} + ) + else [] + ) + assert costs == [] + + # Single default electricity -> skipped + model_elec = MockModel({"electricity": 42.0}) + costs_elec = ( + [ + { + "name": "commodity_costs", + "data": model_elec.commodity_costs, + "unit": scheduler.flex_context["shared_currency_unit"], + } + ] + if getattr(model_elec, "commodity_costs", None) + and ( + len(model_elec.commodity_costs) > 1 + or set(model_elec.commodity_costs.keys()) != {"electricity"} + ) + else [] + ) + assert costs_elec == [] + + # Multi-commodity -> exported + model_multi = MockModel({"electricity": 42.0, "gas": 12.0}) + costs_multi = ( + [ + { + "name": "commodity_costs", + "data": model_multi.commodity_costs, + "unit": scheduler.flex_context["shared_currency_unit"], + } + ] + if getattr(model_multi, "commodity_costs", None) + and ( + len(model_multi.commodity_costs) > 1 + or set(model_multi.commodity_costs.keys()) != {"electricity"} + ) + else [] + ) + assert len(costs_multi) == 1 + assert costs_multi[0]["data"] == {"electricity": 42.0, "gas": 12.0} + + # Non-default single commodity -> exported + model_gas = MockModel({"gas": 12.0}) + costs_gas = ( + [ + { + "name": "commodity_costs", + "data": model_gas.commodity_costs, + "unit": scheduler.flex_context["shared_currency_unit"], + } + ] + if getattr(model_gas, "commodity_costs", None) + and ( + len(model_gas.commodity_costs) > 1 + or set(model_gas.commodity_costs.keys()) != {"electricity"} + ) + else [] + ) + assert len(costs_gas) == 1 + assert costs_gas[0]["data"] == {"gas": 12.0} diff --git a/flexmeasures/data/models/planning/tests/test_storage.py b/flexmeasures/data/models/planning/tests/test_storage.py index 3d6642e055..deabfcae3e 100644 --- a/flexmeasures/data/models/planning/tests/test_storage.py +++ b/flexmeasures/data/models/planning/tests/test_storage.py @@ -2416,6 +2416,13 @@ def output_port( err_msg=f"Unexpected schedule for {sensor.name}", ) + commodity_costs = next( + r["data"] for r in results if r.get("name") == "commodity_costs" + ) + assert set(commodity_costs.keys()) == {"electricity", "gas"} + assert commodity_costs["gas"] > 0 + assert commodity_costs["electricity"] < 0 # CHP produces net electricity sales + def test_off_tick_soc_relaxation_covers_all_devices_of_a_shared_stock( add_battery_assets, db diff --git a/flexmeasures/data/services/scheduling.py b/flexmeasures/data/services/scheduling.py index d169924255..e344667418 100644 --- a/flexmeasures/data/services/scheduling.py +++ b/flexmeasures/data/services/scheduling.py @@ -850,6 +850,10 @@ def make_schedule( # noqa: C901 # so without an explicit save here the costs never reach Redis. rq_job.save_meta() continue + if rq_job and result.get("name") == "commodity_costs": + rq_job.meta["scheduler_info"]["commodity_costs"] = result["data"] + rq_job.save_meta() + continue if "sensor" not in result: continue diff --git a/flexmeasures/data/tests/test_scheduling_jobs.py b/flexmeasures/data/tests/test_scheduling_jobs.py index 2842a066ae..beef84ead6 100644 --- a/flexmeasures/data/tests/test_scheduling_jobs.py +++ b/flexmeasures/data/tests/test_scheduling_jobs.py @@ -109,6 +109,9 @@ def test_scheduling_a_battery( isinstance(cost, float) and np.isfinite(cost) for cost in commitment_costs.values() ) + assert ( + "commodity_costs" not in finished_job.meta["scheduler_info"] + ), "single-commodity schedule should not emit commodity_costs" # Regression #2049: success message only after compute, not the pre-compute copy-paste echo. # Count only this job's message — the RQ worker may also process other queued jobs.