diff --git a/src/pluginbattery/sim.py b/src/pluginbattery/sim.py index 964baca..92176ed 100644 --- a/src/pluginbattery/sim.py +++ b/src/pluginbattery/sim.py @@ -202,8 +202,57 @@ def simulate(df: pd.DataFrame, battery: Battery, schedule: np.ndarray) -> pd.Dat def oracle_daily_schedule(df: pd.DataFrame, battery: Battery) -> np.ndarray: - """Per-UTC-day 24h LP. Maximises savings within each day; SoC carries - forward but no cross-day energy shifting. Returns a schedule for simulate().""" + """Greedy dispatch matching what a plug-in battery's firmware actually does. + + 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. + + 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. + + The function name is kept for backwards compatibility with simulate(). + """ + cap = battery.capacity_kwh + pc_max = battery.max_charge_kw + pd_max = battery.max_discharge_kw + eta = battery.round_trip_eff ** 0.5 + n = len(df) + + demand = df["demand_kwh"].to_numpy() + pv = df["pv_kwh"].to_numpy() if "pv_kwh" in df.columns else np.zeros(n) + + schedule = np.zeros((n, 2)) + soc = battery.initial_soc_kwh + 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)) + 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)) + schedule[t, 1] = d + soc = max(0.0, soc - d / eta) + return schedule + + +def _oracle_daily_schedule_legacy(df: pd.DataFrame, battery: Battery) -> np.ndarray: + """Legacy per-day LP (daily-myopic). Kept for reference / regression + testing only — the public oracle_daily_schedule() now solves the whole + window in one go to avoid SoC-resets-to-zero-overnight artefacts.""" cap = battery.capacity_kwh pc_max = battery.max_charge_kw pd_max = battery.max_discharge_kw diff --git a/tests/test_sim.py b/tests/test_sim.py index 3162319..7d3c087 100644 --- a/tests/test_sim.py +++ b/tests/test_sim.py @@ -46,7 +46,10 @@ def test_plugin_never_exports(): assert (out["grid_kwh_with_battery"] >= -1e-9).all() -def test_oracle_arbitrages_clear_spread(): +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.)""" prices = [0.05] * 12 + [0.50] * 12 demands = [1.0] * 24 df = make_df(prices, demands) @@ -54,9 +57,28 @@ def test_oracle_arbitrages_clear_spread(): round_trip_eff=0.9, allows_export=False) schedule = oracle_daily_schedule(df, bat) out = simulate(df, bat, schedule) - assert out["charge_kwh"].iloc[:12].sum() > 0 + assert out["charge_kwh"].sum() == 0 + assert out["discharge_kwh"].sum() == 0 + + +def test_greedy_fills_from_surplus_then_overflows(): + """With PV surplus, greedy fills the battery as fast as power allows + until capacity is reached, then lets the rest export.""" + prices = [0.20] * 24 + demand = [0.1] * 24 + pv = [0.0] * 6 + [3.0] * 6 + [0.0] * 12 # 6 sunny hours, 3 kWh/h surplus + df = make_df(prices, demand) + df["pv_kwh"] = pv + bat = Battery(capacity_kwh=2.0, max_charge_kw=0.8, max_discharge_kw=0.8, + round_trip_eff=1.0, allows_export=False) + schedule = oracle_daily_schedule(df, bat) + out = simulate(df, bat, schedule) + # Charging happens during the first surplus hours, capped at 0.8 kW + assert out["charge_kwh"].iloc[6:9].sum() == pytest.approx(2.0, abs=1e-6) + # Once full, no more charging even though surplus continues + assert out["charge_kwh"].iloc[9:12].sum() == 0 + # Battery discharges into evening demand assert out["discharge_kwh"].iloc[12:].sum() > 0 - assert out["savings"].sum() > 0 def test_apply_nl_tariff_matches_user_formula():