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
24 changes: 24 additions & 0 deletions flexmeasures/data/models/planning/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,30 @@ def building(db, setup_accounts, setup_markets) -> GenericAsset:
return building


@pytest.fixture(scope="module")
def charge_point(db, setup_accounts) -> GenericAsset:
"""
Set up a charge point. A charge point can have one or more connectors,
modeled as separate power sensors on this asset.
"""
charge_point_type = db.session.execute(
select(GenericAssetType).filter_by(name="charge point")
).scalar_one_or_none()
if not charge_point_type:
charge_point_type = GenericAssetType(name="charge point")
db.session.add(charge_point_type)
charge_point = GenericAsset(
name="planning charge point",
generic_asset_type=charge_point_type,
owner=setup_accounts["Prosumer"],
flex_context={
"site-power-capacity": "1 MVA",
},
)
db.session.add(charge_point)
return charge_point


@pytest.fixture(scope="module")
def flexible_devices(db, building) -> dict[str, Sensor]:
"""
Expand Down
185 changes: 185 additions & 0 deletions flexmeasures/data/models/planning/tests/test_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -2087,3 +2087,188 @@ def test_off_tick_soc_relaxation_covers_all_devices_of_a_shared_stock(
assert constraints_2.loc[start, "min"] == pytest.approx(
4
), "the on-tick device's soc-minima should remain a hard constraint"


def _evse_power_sensor(db, asset, resolution, name):
power_sensor = Sensor(
name=name,
generic_asset=asset,
event_resolution=resolution,
unit="kW",
)
db.session.add(power_sensor)
db.session.commit()
return power_sensor


def _storage_schedule_for_sensor(results, power_sensor):
storage_for_sensor = [
r
for r in results
if r.get("name") == "storage_schedule" and r.get("sensor") is power_sensor
]
assert (
len(storage_for_sensor) == 1
), "expected exactly one combined storage_schedule for this sensor"
return storage_for_sensor[0]["data"]


def test_multiple_sessions_same_sensor_accumulate_schedules(db, charge_point):
"""Two flex-model sessions on one power sensor must sum schedules (not overwrite).

Regression for https://github.com/FlexMeasures/flexmeasures/issues/1947
(core fix in #1948). Multiple independent storage entries may share a power
sensor (e.g. two EV sessions plugged into the same charge point connector,
one after the other). Their device schedules must be accumulated into a
single sensor schedule.

Setup: two non-overlapping sessions, each needing 2 kWh at 1 kW over a 2-hour
window. Overwrite behaviour would keep only the second session (~2 kWh total,
zeros in the first half); correct accumulation yields ~4 kWh with power in
both halves of the horizon.
"""
resolution = timedelta(hours=1)
start = pd.Timestamp("2020-01-01T00:00:00", tz="Europe/Amsterdam")
end = start + 4 * resolution
mid = start + 2 * resolution

power_sensor = _evse_power_sensor(
db, charge_point, resolution, name="connector power non-overlap"
)

index = initialize_index(start=start, end=end, resolution=resolution)
# Pre-allocate capacity per session (required when sessions would otherwise
# compete for the same charger capacity; here they are non-overlapping).
session1_capacity = pd.Series([1.0, 1.0, 0.0, 0.0], index=index)
session2_capacity = pd.Series([0.0, 0.0, 1.0, 1.0], index=index)

flex_model = [
{
"sensor": power_sensor.id,
"soc-at-start": "0 kWh",
"soc-min": "0 kWh",
"soc-max": "100 kWh",
"soc-minima": [{"datetime": mid.isoformat(), "value": "2 kWh"}],
"power-capacity": "1 kW",
"consumption-capacity": series_to_ts_specs(session1_capacity, unit="kW"),
"production-capacity": "0 kW",
},
{
"sensor": power_sensor.id,
"soc-at-start": "0 kWh",
"soc-min": "0 kWh",
"soc-max": "100 kWh",
"soc-minima": [{"datetime": end.isoformat(), "value": "2 kWh"}],
"power-capacity": "1 kW",
"consumption-capacity": series_to_ts_specs(session2_capacity, unit="kW"),
"production-capacity": "0 kW",
},
]

scheduler: Scheduler = StorageScheduler(
asset_or_sensor=charge_point,
start=start,
end=end,
resolution=resolution,
flex_model=flex_model,
flex_context={
"consumption-price": "100 EUR/MWh",
"production-price": "0 EUR/MWh",
"site-power-capacity": "10 kW",
"soc-minima-breach-price": "6000 EUR/kWh",
},
return_multiple=True,
)
results = scheduler.compute()
schedule = _storage_schedule_for_sensor(results, power_sensor)

# Each session charges 2 kWh at 1 kW over its half window → [1, 1, 1, 1] kW.
np.testing.assert_allclose(schedule.values, [1.0, 1.0, 1.0, 1.0], atol=1e-3)
# Overwrite would leave only the second session (zeros first half, ~2 kWh total).
np.testing.assert_allclose(schedule.iloc[:2].values, [1.0, 1.0], atol=1e-3)
np.testing.assert_allclose(schedule.iloc[2:].values, [1.0, 1.0], atol=1e-3)
np.testing.assert_allclose(schedule.sum(), 4.0, atol=1e-3)


def test_multiple_sessions_different_connectors_share_charge_point_capacity(
db, charge_point
):
"""Overlapping sessions on different connectors still share the site cap.

Companion to ``test_multiple_sessions_same_sensor_accumulate_schedules``. Two
EV sessions cannot overlap on the *same* connector - a connector serves one
car at a time - so overlap is only physically meaningful across two
*different* connectors of the same charge point (#2344 review). Each
connector gets its own power sensor; the charge point's site-power-capacity
is the shared constraint the two connectors compete for while both sessions
are active.

Setup: two sessions, one per connector, each with its own 1 kW nominal
connector capacity, each needing 2 kWh over a 4-hour window. An hourly
soc-minima ramp (0.5 kWh/hour for both) forces them to draw power in the
same hours. The charge point's site-power-capacity is capped at 1 kW total
- exactly the combined minimum required each hour - so the shared cap pins
each connector to 0.5 kW per hour, half of its own nominal capacity.
"""
resolution = timedelta(hours=1)
start = pd.Timestamp("2020-01-01T00:00:00", tz="Europe/Amsterdam")
end = start + 4 * resolution

connector_a = _evse_power_sensor(
db, charge_point, resolution, name="connector A power"
)
connector_b = _evse_power_sensor(
db, charge_point, resolution, name="connector B power"
)

# Both sessions must show 0.5 kWh of cumulative progress every hour, which
# (combined with the 1 kW site cap) pins both to exactly 0.5 kW per hour.
ramp = [
{"datetime": (start + n * resolution).isoformat(), "value": f"{0.5 * n} kWh"}
for n in range(1, 5)
]

def session_flex_model(sensor):
return {
"sensor": sensor.id,
"soc-at-start": "0 kWh",
"soc-min": "0 kWh",
"soc-max": "100 kWh",
"soc-minima": ramp,
"power-capacity": "1 kW",
"consumption-capacity": "1 kW",
"production-capacity": "0 kW",
}

scheduler: Scheduler = StorageScheduler(
asset_or_sensor=charge_point,
start=start,
end=end,
resolution=resolution,
flex_model=[
session_flex_model(connector_a),
session_flex_model(connector_b),
],
flex_context={
"consumption-price": "100 EUR/MWh",
"production-price": "0 EUR/MWh",
"site-power-capacity": "1 kW",
"soc-minima-breach-price": "6000 EUR/kWh",
},
return_multiple=True,
)
results = scheduler.compute()
schedule_a = _storage_schedule_for_sensor(results, connector_a)
schedule_b = _storage_schedule_for_sensor(results, connector_b)

# Each connector is pinned to 0.5 kW/hour - half its own 1 kW nominal
# capacity - because the two connectors share the charge point's 1 kW cap.
np.testing.assert_allclose(schedule_a.values, [0.5, 0.5, 0.5, 0.5], atol=1e-3)
np.testing.assert_allclose(schedule_b.values, [0.5, 0.5, 0.5, 0.5], atol=1e-3)
# The combined draw never exceeds the charge point's site-power-capacity.
np.testing.assert_allclose(
schedule_a.values + schedule_b.values, [1.0, 1.0, 1.0, 1.0], atol=1e-3
)
# Each session still gets its full 2 kWh despite the shared cap.
np.testing.assert_allclose(schedule_a.sum(), 2.0, atol=1e-3)
np.testing.assert_allclose(schedule_b.sum(), 2.0, atol=1e-3)
Loading