diff --git a/src/pluginbattery/sim.py b/src/pluginbattery/sim.py index 92176ed..b8e3471 100644 --- a/src/pluginbattery/sim.py +++ b/src/pluginbattery/sim.py @@ -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: - """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: - - If the meter would export (demand − pv < 0): charge the battery as - fast as power, surplus, and remaining capacity allow. - - If the meter would import (demand − pv > 0): discharge the battery - to cover that import, capped by power and stored energy. Plug-in - units cannot push past the meter, so discharge ≤ net demand. + This matches what a modern plug-in battery with smart-charging firmware + (Tibber controllers, Marstek 'Smart Charging' mode, EcoFlow with dynamic + tariff integration) actually does in practice — and it always produces + visually intuitive schedules without LP indifference artefacts. - No optimization, no future visibility. This is the real-world behaviour - of every plug-in battery on the catalog (Marstek, Zendure, EcoFlow, - HomeWizard etc. all default to "self-consumption mode" which is exactly - 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. + Pass 1 (greedy self-consumption): + For each hour in order, charge from any meter export, discharge to + cover any meter import. Exactly the "default mode" behaviour. - 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 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() 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)) - 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): - net = demand[t] - pv[t] - if net < -1e-9: # exporting hour - surplus = -net - headroom = (cap - soc) / eta if eta > 0 else 0.0 - c = max(0.0, min(pc_max, surplus, headroom)) + if net[t] < -1e-9: # exporting + surplus = -net[t] + room = (cap - soc[t]) / eta if eta > 0 else 0.0 + c = max(0.0, min(pc_max, surplus, room)) schedule[t, 0] = c - soc = min(cap, soc + c * eta) - elif net > 1e-9: # importing hour - avail = soc * eta - limit = pd_max if battery.allows_export else net - d = max(0.0, min(pd_max, avail, limit)) + soc[t + 1] = min(cap, soc[t] + c * eta) + elif net[t] > 1e-9: # importing + avail = soc[t] * eta + cap_d = pd_max if battery.allows_export else net[t] + d = max(0.0, min(pd_max, avail, cap_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 diff --git a/tests/test_sim.py b/tests/test_sim.py index cc475e8..3a81dd1 100644 --- a/tests/test_sim.py +++ b/tests/test_sim.py @@ -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():