-
Notifications
You must be signed in to change notification settings - Fork 48
Description
When scheduling multiple EV charging sessions that share the same power sensor, the StorageScheduler currently overwrites earlier schedules instead of combining them. As a result, only the last session is effectively scheduled, even though multiple sessions are defined in the flex-model.
This happens when the flex-model contains multiple storage entries referencing the same sensor (e.g. multiple EVSE sessions on a single charge point), which is a valid and common modeling pattern.
Problematic behavior
The issue originates from this code in StorageScheduler.compute:
storage_schedule = {
sensor: ems_schedule[d]
for d, sensor in enumerate(sensors)
if sensor is not None
}If the same sensor appears multiple times, earlier schedules are silently overwritten.
Expected behavior
If multiple flex-model entries reference the same sensor, the scheduler should:
- Compute each session independently
- Aggregate (sum) their schedules
- Return one combined schedule per sensor
Fix
The issue is resolved by accumulating schedules instead of overwriting them:
for d, sensor in enumerate(sensors):
if sensor is not None and sensor not in storage_schedule:
storage_schedule[sensor] = ems_schedule[d]
elif sensor is not None and sensor in storage_schedule:
storage_schedule[sensor] += ems_schedule[d]This correctly supports:
- Single session
- Overlapping sessions
- Non-overlapping sessions
Reproduction test case
The following test demonstrates the issue and validates the fix.
import pandas as pd
import pytest
import timely_beliefs as tb
from flexmeasures import Sensor, Source
from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType
from flexmeasures.data.services.scheduling import StorageScheduler
from flexmeasures.data.services.utils import get_or_create_model
from flexmeasures.data.utils import save_to_db
pd.set_option("display.max_rows", None)
def load_pv_production(
*,
pv_power_sensor: Sensor,
start: pd.Timestamp,
end: pd.Timestamp,
resolution: pd.Timedelta,
db,
):
"""
Create deterministic PV production data and store it on the PV power sensor.
Convention: production = negative consumption.
"""
source = get_or_create_model(Source, name="PV forecast")
index = pd.date_range(start=start, end=end, freq=resolution, inclusive="left")
# Simple deterministic PV profile (kW)
values = []
for ts in index:
hour = ts.hour + ts.minute / 60
if 6 <= hour <= 18:
# bell-ish shape
values.append(-max(0, 25 * (1 - abs(hour - 12) / 6)))
else:
values.append(0.0)
bdf = tb.BeliefsDataFrame(
pd.Series(values, index=index, name="event_value"),
belief_horizon=pd.Timedelta(0),
sensor=pv_power_sensor,
source=source,
)
save_to_db(bdf, bulk_save_objects=True, save_changed_beliefs_only=True)
db.session.commit()
def build_flex_model(case, pv_power_sensor, evse_power_sensor):
base_pv = {
"sensor": pv_power_sensor.id,
"consumption-capacity": "0 kW",
"production-capacity": {"sensor": pv_power_sensor.id},
"power-capacity": "1 GW",
}
if case == "single_session":
return [
base_pv,
{
"sensor": evse_power_sensor.id,
"soc-at-start": 20.0,
"soc-min": 0.0,
"soc-max": 100.0,
"soc-targets": [
{"datetime": "2023-01-01T11:45:00+01:00", "value": 100.0}
],
"soc-minima": [
{"datetime": "2023-01-01T11:45:00+01:00", "value": 97.0}
],
"soc-maxima": [
{"datetime": "2023-01-01T04:15:00+01:00", "value": 25.5}
],
"power-capacity": [
{
"start": "2023-01-01T00:00:00+01:00",
"end": "2023-01-01T04:15:00+01:00",
"value": "0 kW",
},
{
"start": "2023-01-01T04:15:00+01:00",
"end": "2023-01-01T11:45:00+01:00",
"value": "22 kW",
},
{
"start": "2023-01-01T11:45:00+01:00",
"end": "2023-01-02T00:00:00+01:00",
"value": "0 kW",
},
],
"charging-efficiency": 0.9486832980505138,
"production-capacity": "0 kW",
},
]
if case == "overlap_sessions":
return [
base_pv,
# Session 1
{
"sensor": evse_power_sensor.id,
"soc-at-start": 20.0,
"soc-min": 0.0,
"soc-max": 100.0,
"soc-targets": [
{"datetime": "2023-01-01T11:45:00+01:00", "value": 100.0}
],
"soc-minima": [
{"datetime": "2023-01-01T11:45:00+01:00", "value": 97.0}
],
"soc-maxima": [
{"datetime": "2023-01-01T04:15:00+01:00", "value": 25.5}
],
"power-capacity": [
{
"start": "2023-01-01T00:00:00+01:00",
"end": "2023-01-01T04:15:00+01:00",
"value": "0 kW",
},
{
"start": "2023-01-01T04:15:00+01:00",
"end": "2023-01-01T10:15:00+01:00",
"value": "22 kW",
},
{
"start": "2023-01-01T10:15:00+01:00",
"end": "2023-01-01T11:45:00+01:00",
"value": "11 kW",
},
{
"start": "2023-01-01T11:45:00+01:00",
"end": "2023-01-02T00:00:00+01:00",
"value": "0 kW",
},
],
"charging-efficiency": 0.9486832980505138,
"production-capacity": "0 kW",
},
# Session 2
{
"sensor": evse_power_sensor.id,
"soc-at-start": 30.0,
"soc-min": 0.0,
"soc-max": 100.0,
"soc-targets": [
{"datetime": "2023-01-01T18:15:00+01:00", "value": 100.0}
],
"soc-minima": [
{"datetime": "2023-01-01T18:15:00+01:00", "value": 97.0}
],
"soc-maxima": [
{"datetime": "2023-01-01T10:15:00+01:00", "value": 35.0}
],
"power-capacity": [
{
"start": "2023-01-01T00:00:00+01:00",
"end": "2023-01-01T10:15:00+01:00",
"value": "0 kW",
},
{
"start": "2023-01-01T10:15:00+01:00",
"end": "2023-01-01T11:45:00+01:00",
"value": "11 kW",
},
{
"start": "2023-01-01T11:45:00+01:00",
"end": "2023-01-01T18:15:00+01:00",
"value": "22 kW",
},
{
"start": "2023-01-01T18:15:00+01:00",
"end": "2023-01-02T00:00:00+01:00",
"value": "0 kW",
},
],
"charging-efficiency": 0.9486832980505138,
"production-capacity": "0 kW",
},
]
if case == "non_overlap_sessions":
return [
base_pv,
{
"sensor": evse_power_sensor.id,
"soc-at-start": 20.0,
"soc-min": 0.0,
"soc-max": 100.0,
"soc-targets": [
{"datetime": "2023-01-01T11:45:00+01:00", "value": 100.0}
],
"soc-minima": [
{"datetime": "2023-01-01T11:45:00+01:00", "value": 97.0}
],
"soc-maxima": [
{"datetime": "2023-01-01T04:15:00+01:00", "value": 25.5}
],
"power-capacity": [
{
"start": "2023-01-01T00:00:00+01:00",
"end": "2023-01-01T04:15:00+01:00",
"value": "0 kW",
},
{
"start": "2023-01-01T04:15:00+01:00",
"end": "2023-01-01T11:45:00+01:00",
"value": "22 kW",
},
{
"start": "2023-01-01T11:45:00+01:00",
"end": "2023-01-02T00:00:00+01:00",
"value": "0 kW",
},
],
"charging-efficiency": 0.9486832980505138,
"production-capacity": "0 kW",
},
{
"sensor": evse_power_sensor.id,
"soc-at-start": 30.0,
"soc-min": 0.0,
"soc-max": 100.0,
"soc-targets": [
{"datetime": "2023-01-01T18:15:00+01:00", "value": 100.0}
],
"soc-minima": [
{"datetime": "2023-01-01T18:15:00+01:00", "value": 97.0}
],
"soc-maxima": [
{"datetime": "2023-01-01T12:15:00+01:00", "value": 35.0}
],
"power-capacity": [
{
"start": "2023-01-01T00:00:00+01:00",
"end": "2023-01-01T12:15:00+01:00",
"value": "0 kW",
},
{
"start": "2023-01-01T12:15:00+01:00",
"end": "2023-01-01T18:15:00+01:00",
"value": "22 kW",
},
{
"start": "2023-01-01T18:15:00+01:00",
"end": "2023-01-02T00:00:00+01:00",
"value": "0 kW",
},
],
"charging-efficiency": 0.9486832980505138,
"production-capacity": "0 kW",
},
]
raise ValueError(case)
@pytest.mark.parametrize(
"case",
["single_session", "overlap_sessions", "non_overlap_sessions"],
)
def test_multi_asset_schedule(app, db, setup_reporters, simulation_datalake, case):
pv_asset_type = get_or_create_model(GenericAssetType, name="pv")
evse_asset_type = get_or_create_model(GenericAssetType, name="evse")
start = pd.Timestamp("2023-01-01T00:00:00+01:00")
end = pd.Timestamp("2023-01-02T00:00:00+01:00")
resolution = pd.Timedelta("15min")
pv = GenericAsset(name="PV", generic_asset_type=pv_asset_type)
evse = GenericAsset(
name="EVSE",
generic_asset_type=evse_asset_type,
attributes={"energy-capacity": "100 kWh"},
)
db.session.add_all([pv, evse])
db.session.commit()
pv_power_sensor = Sensor(
name="pv power",
unit="kW",
event_resolution=resolution,
generic_asset=pv,
attributes={"consumption_is_positive": False},
)
evse_power_sensor = Sensor(
name="evse power",
unit="kW",
event_resolution=resolution,
generic_asset=evse,
)
db.session.add_all([pv_power_sensor, evse_power_sensor])
db.session.commit()
# ---- load PV data (no files)
load_pv_production(
pv_power_sensor=pv_power_sensor,
start=start,
end=end,
resolution=resolution,
db=db,
)
flex_model = build_flex_model(case, pv_power_sensor, evse_power_sensor)
scheduler = StorageScheduler(
asset_or_sensor=evse,
start=start,
end=end,
resolution=resolution,
belief_time=start,
flex_model=flex_model,
flex_context={
"consumption-price": "50 EUR/MWh",
"production-price": "50 EUR/MWh",
},
return_multiple=True,
)
schedules = scheduler.compute(skip_validation=True)
breakpoint()
assert isinstance(schedules, list)
sensors = {s["sensor"] for s in schedules}
assert pv_power_sensor in sensors
assert evse_power_sensor in sensors