Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/FlexMeasures/flexmeasures/issues/2416>`_]

Infrastructure / Support
-------------------------
Expand Down
16 changes: 16 additions & 0 deletions flexmeasures/data/models/planning/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
103 changes: 101 additions & 2 deletions flexmeasures/data/models/planning/tests/test_commitments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
7 changes: 7 additions & 0 deletions flexmeasures/data/models/planning/tests/test_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions flexmeasures/data/services/scheduling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions flexmeasures/data/tests/test_scheduling_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading