Hybrid dispatcher: greedy self-consumption + per-day grid arbitrage

Single algorithm with two passes:

Pass 1 — greedy self-consumption (real-firmware default):
    For each hour, charge any surplus, discharge into any demand.
    This nails sunny days: battery fills from morning surplus, exports
    only after capacity is reached, drains during evening peak.

Pass 2 — daily price-spread arbitrage on the residual capacity:
    For each UTC day, repeatedly find the most profitable
    cheap-charge → expensive-discharge pair (positive after round-trip
    efficiency), execute it, recompute SoC trajectory, repeat until no
    profitable cycle remains. Discharge must fit within hourly demand
    for plug-in batteries (no grid push). Sees only that day's prices,
    matching what a Tibber/Frank-style smart-charging controller does
    with day-ahead price visibility.

Effect:
- Sunny weeks (June 17-19): unchanged — greedy fill + evening discharge.
- No-sun weeks (Feb 7+): now generates arbitrage savings instead of €0.
- Mixed weeks: greedy handles surplus, arbitrage handles the rest.

Updated: test_grid_arbitrage_kicks_in_on_no_sun_days replaces the prior
"greedy doesn't arbitrage" test; now verifies the dispatcher charges
cheap hours and discharges expensive ones when no PV is available.
This commit is contained in:
Michiel Berger 2026-05-01 10:36:07 +02:00
parent 6f8238376b
commit 9e77b35ff4
2 changed files with 121 additions and 33 deletions

View file

@ -47,10 +47,10 @@ def test_plugin_never_exports():
assert (out["grid_kwh_with_battery"] >= -1e-9).all()
def test_greedy_does_not_grid_arbitrage_without_pv():
"""Greedy dispatch is self-consumption only — it won't import cheap and
discharge expensive without a surplus source. (LP would; greedy by
design doesn't, matching real plug-in firmware.)"""
def test_grid_arbitrage_kicks_in_on_no_sun_days():
"""Without PV but with a daily price spread, the dispatcher should
charge during the cheapest hours and discharge during the most
expensive same dynamic-tariff behaviour Tibber-style controllers do."""
prices = [0.05] * 12 + [0.50] * 12
demands = [1.0] * 24
df = make_df(prices, demands)
@ -58,8 +58,12 @@ def test_greedy_does_not_grid_arbitrage_without_pv():
round_trip_eff=0.9, allows_export=False)
schedule = oracle_daily_schedule(df, bat)
out = simulate(df, bat, schedule)
assert out["charge_kwh"].sum() == 0
assert out["discharge_kwh"].sum() == 0
# Charging happened during the cheap morning hours (0..11)
assert out["charge_kwh"].iloc[:12].sum() > 0
# Discharging happened during the expensive afternoon (12..23)
assert out["discharge_kwh"].iloc[12:].sum() > 0
# Arbitrage produced some saving
assert out["savings"].sum() > 0
def test_greedy_fills_from_surplus_then_overflows():