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:
parent
6f8238376b
commit
9e77b35ff4
2 changed files with 121 additions and 33 deletions
|
|
@ -202,24 +202,31 @@ def simulate(df: pd.DataFrame, battery: Battery, schedule: np.ndarray) -> pd.Dat
|
||||||
|
|
||||||
|
|
||||||
def oracle_daily_schedule(df: pd.DataFrame, battery: Battery) -> np.ndarray:
|
def oracle_daily_schedule(df: pd.DataFrame, battery: Battery) -> np.ndarray:
|
||||||
"""Greedy dispatch matching what a plug-in battery's firmware actually does.
|
"""Two-pass dispatch: greedy self-consumption + daily grid arbitrage.
|
||||||
|
|
||||||
For each hour, in order:
|
This matches what a modern plug-in battery with smart-charging firmware
|
||||||
- If the meter would export (demand − pv < 0): charge the battery as
|
(Tibber controllers, Marstek 'Smart Charging' mode, EcoFlow with dynamic
|
||||||
fast as power, surplus, and remaining capacity allow.
|
tariff integration) actually does in practice — and it always produces
|
||||||
- If the meter would import (demand − pv > 0): discharge the battery
|
visually intuitive schedules without LP indifference artefacts.
|
||||||
to cover that import, capped by power and stored energy. Plug-in
|
|
||||||
units cannot push past the meter, so discharge ≤ net demand.
|
|
||||||
|
|
||||||
No optimization, no future visibility. This is the real-world behaviour
|
Pass 1 (greedy self-consumption):
|
||||||
of every plug-in battery on the catalog (Marstek, Zendure, EcoFlow,
|
For each hour in order, charge from any meter export, discharge to
|
||||||
HomeWizard etc. all default to "self-consumption mode" which is exactly
|
cover any meter import. Exactly the "default mode" behaviour.
|
||||||
this rule). Earlier versions used a 24h-foresight LP, which is provably
|
|
||||||
optimal but produced visually unintuitive schedules — the LP would shift
|
|
||||||
charging to arbitrary surplus hours when the cost was the same. Real
|
|
||||||
batteries don't do that, so the model now matches reality.
|
|
||||||
|
|
||||||
The function name is kept for backwards compatibility with simulate().
|
Pass 2 (per-UTC-day arbitrage on residual capacity):
|
||||||
|
For each day, repeatedly find the most profitable
|
||||||
|
cheap-hour → expensive-hour pair, considering:
|
||||||
|
- Both charge and discharge already have residual room (power
|
||||||
|
and SoC headroom).
|
||||||
|
- Discharge fits within demand at the expensive hour (plug-in
|
||||||
|
cannot push to grid; allows_export=True relaxes this).
|
||||||
|
- Profit per shifted kWh = η · price[discharge] − price[charge] / η
|
||||||
|
must be positive.
|
||||||
|
Execute the most profitable, update SoC trajectory, repeat. Stops
|
||||||
|
when no profitable cycle remains for that day.
|
||||||
|
|
||||||
|
Pass 1 is order-of-day dependent (real firmware acts moment-by-moment),
|
||||||
|
so it always runs first. Pass 2 sees what's left.
|
||||||
"""
|
"""
|
||||||
cap = battery.capacity_kwh
|
cap = battery.capacity_kwh
|
||||||
pc_max = battery.max_charge_kw
|
pc_max = battery.max_charge_kw
|
||||||
|
|
@ -229,23 +236,100 @@ def oracle_daily_schedule(df: pd.DataFrame, battery: Battery) -> np.ndarray:
|
||||||
|
|
||||||
demand = df["demand_kwh"].to_numpy()
|
demand = df["demand_kwh"].to_numpy()
|
||||||
pv = df["pv_kwh"].to_numpy() if "pv_kwh" in df.columns else np.zeros(n)
|
pv = df["pv_kwh"].to_numpy() if "pv_kwh" in df.columns else np.zeros(n)
|
||||||
|
net = demand - pv
|
||||||
|
prices = df["eur_per_kwh"].to_numpy()
|
||||||
|
|
||||||
schedule = np.zeros((n, 2))
|
schedule = np.zeros((n, 2))
|
||||||
soc = battery.initial_soc_kwh
|
soc = np.zeros(n + 1)
|
||||||
|
soc[0] = battery.initial_soc_kwh
|
||||||
|
|
||||||
|
# ── Pass 1: greedy self-consumption ─────────────────────────────────
|
||||||
for t in range(n):
|
for t in range(n):
|
||||||
net = demand[t] - pv[t]
|
if net[t] < -1e-9: # exporting
|
||||||
if net < -1e-9: # exporting hour
|
surplus = -net[t]
|
||||||
surplus = -net
|
room = (cap - soc[t]) / eta if eta > 0 else 0.0
|
||||||
headroom = (cap - soc) / eta if eta > 0 else 0.0
|
c = max(0.0, min(pc_max, surplus, room))
|
||||||
c = max(0.0, min(pc_max, surplus, headroom))
|
|
||||||
schedule[t, 0] = c
|
schedule[t, 0] = c
|
||||||
soc = min(cap, soc + c * eta)
|
soc[t + 1] = min(cap, soc[t] + c * eta)
|
||||||
elif net > 1e-9: # importing hour
|
elif net[t] > 1e-9: # importing
|
||||||
avail = soc * eta
|
avail = soc[t] * eta
|
||||||
limit = pd_max if battery.allows_export else net
|
cap_d = pd_max if battery.allows_export else net[t]
|
||||||
d = max(0.0, min(pd_max, avail, limit))
|
d = max(0.0, min(pd_max, avail, cap_d))
|
||||||
schedule[t, 1] = d
|
schedule[t, 1] = d
|
||||||
soc = max(0.0, soc - d / eta)
|
soc[t + 1] = max(0.0, soc[t] - d / eta)
|
||||||
|
else:
|
||||||
|
soc[t + 1] = soc[t]
|
||||||
|
|
||||||
|
# ── Pass 2: per-day arbitrage on residual capacity ──────────────────
|
||||||
|
if eta <= 0:
|
||||||
|
return schedule
|
||||||
|
inv_eta = 1.0 / eta
|
||||||
|
|
||||||
|
# Index hours by UTC date (timeline is short enough that this is cheap).
|
||||||
|
by_date: dict = {}
|
||||||
|
for i, ts in enumerate(df.index):
|
||||||
|
by_date.setdefault(ts.date(), []).append(i)
|
||||||
|
|
||||||
|
for day_idx in by_date.values():
|
||||||
|
day_idx = np.array(day_idx)
|
||||||
|
last = day_idx[-1]
|
||||||
|
for _ in range(24): # safety bound
|
||||||
|
best = None
|
||||||
|
for ci in day_idx:
|
||||||
|
room_c = pc_max - schedule[ci, 0]
|
||||||
|
if room_c < 1e-9:
|
||||||
|
continue
|
||||||
|
# SoC headroom across (ci, end-of-day]: how much can we add
|
||||||
|
# to soc[ci+1..last+1] without exceeding cap?
|
||||||
|
tail = soc[ci + 1 : last + 2]
|
||||||
|
hr = (cap - tail.max()) / eta if tail.size else 0.0
|
||||||
|
if hr < 1e-9:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for di in day_idx[day_idx > ci]:
|
||||||
|
room_d = pd_max - schedule[di, 1]
|
||||||
|
if room_d < 1e-9:
|
||||||
|
continue
|
||||||
|
if not battery.allows_export:
|
||||||
|
# Discharge cannot exceed demand at di.
|
||||||
|
room_d = min(room_d, net[di] - schedule[di, 1])
|
||||||
|
if room_d < 1e-9:
|
||||||
|
continue
|
||||||
|
# SoC must remain ≥ 0 across (ci, di]; new schedule has
|
||||||
|
# +c at ci shifting trajectory up, then -d at di pulling
|
||||||
|
# it down. Trajectory between ci+1..di stays elevated by
|
||||||
|
# +x·η; minimum allowed kWh limited by min SoC in that
|
||||||
|
# range plus future-discharge availability.
|
||||||
|
profit = eta * prices[di] - prices[ci] * inv_eta
|
||||||
|
if profit <= 1e-6:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Max charge in AC kWh: limited by power, capacity headroom,
|
||||||
|
# discharge-side capacity.
|
||||||
|
max_kwh = min(room_c, hr, room_d * inv_eta)
|
||||||
|
if max_kwh < 1e-6:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if best is None or profit > best[2]:
|
||||||
|
best = (ci, di, profit, max_kwh)
|
||||||
|
if best is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
ci, di, _, kwh = best
|
||||||
|
schedule[ci, 0] += kwh
|
||||||
|
schedule[di, 1] += kwh * eta
|
||||||
|
# Trajectory: SoC between ci+1 and di stays elevated by kwh*eta.
|
||||||
|
# SoC at di+1 onwards returns to original (+kwh*eta) - (kwh*eta) = unchanged).
|
||||||
|
for t in range(ci, di):
|
||||||
|
soc[t + 1] += kwh * eta
|
||||||
|
# Beyond di, soc[di+1] = previous - d/eta = previous - kwh
|
||||||
|
# We added kwh*eta at soc[di], then removed kwh*eta on the
|
||||||
|
# discharge so soc[di+1] should already match — but we updated
|
||||||
|
# the loop with soc trajectory; need to ensure di+1 onward stays
|
||||||
|
# consistent.
|
||||||
|
# Easier: walk soc again from di onward to verify consistency.
|
||||||
|
# Since both sides cancel, soc[di+1..end] should be unchanged.
|
||||||
|
|
||||||
return schedule
|
return schedule
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,10 +47,10 @@ def test_plugin_never_exports():
|
||||||
assert (out["grid_kwh_with_battery"] >= -1e-9).all()
|
assert (out["grid_kwh_with_battery"] >= -1e-9).all()
|
||||||
|
|
||||||
|
|
||||||
def test_greedy_does_not_grid_arbitrage_without_pv():
|
def test_grid_arbitrage_kicks_in_on_no_sun_days():
|
||||||
"""Greedy dispatch is self-consumption only — it won't import cheap and
|
"""Without PV but with a daily price spread, the dispatcher should
|
||||||
discharge expensive without a surplus source. (LP would; greedy by
|
charge during the cheapest hours and discharge during the most
|
||||||
design doesn't, matching real plug-in firmware.)"""
|
expensive — same dynamic-tariff behaviour Tibber-style controllers do."""
|
||||||
prices = [0.05] * 12 + [0.50] * 12
|
prices = [0.05] * 12 + [0.50] * 12
|
||||||
demands = [1.0] * 24
|
demands = [1.0] * 24
|
||||||
df = make_df(prices, demands)
|
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)
|
round_trip_eff=0.9, allows_export=False)
|
||||||
schedule = oracle_daily_schedule(df, bat)
|
schedule = oracle_daily_schedule(df, bat)
|
||||||
out = simulate(df, bat, schedule)
|
out = simulate(df, bat, schedule)
|
||||||
assert out["charge_kwh"].sum() == 0
|
# Charging happened during the cheap morning hours (0..11)
|
||||||
assert out["discharge_kwh"].sum() == 0
|
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():
|
def test_greedy_fills_from_surplus_then_overflows():
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue