Replace LP with greedy self-consumption — match real battery firmware

The LP was producing visually unintuitive schedules: when several surplus
hours had identical cost benefit (true with constant export_rate), the
solver picked an arbitrary subset, which the user couldn't read as 'this
is the battery doing its job'. Worse, the daily-LP variant drained to 0
every midnight because it placed zero value on next-day SoC.

Replaced oracle_daily_schedule() with a one-pass greedy dispatcher that
matches what every plug-in battery in the catalog (Marstek, Zendure,
EcoFlow, HomeWizard) actually does in 'self-consumption mode':

  for each hour:
      if exporting at meter: charge as fast as power+capacity allows
      if importing at meter: discharge to cover net demand

Trade-offs:
- Greedy doesn't do grid arbitrage (charge cheap → discharge expensive
  without a surplus source). The LP would; real plug-in firmware
  doesn't, so greedy is more honest about what dad's battery would do.
- Charts now show 'fill in the morning, overflow at midday' which is
  what users expect to see.
- Updated tests: dropped LP-arbitrage assertion, added a greedy-fills-
  from-surplus-then-overflows test that locks the new behaviour in.
This commit is contained in:
Michiel Berger 2026-05-01 10:20:23 +02:00
parent ab4f9712b5
commit b4d895224b
2 changed files with 76 additions and 5 deletions

View file

@ -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: def oracle_daily_schedule(df: pd.DataFrame, battery: Battery) -> np.ndarray:
"""Per-UTC-day 24h LP. Maximises savings within each day; SoC carries """Greedy dispatch matching what a plug-in battery's firmware actually does.
forward but no cross-day energy shifting. Returns a schedule for simulate()."""
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 cap = battery.capacity_kwh
pc_max = battery.max_charge_kw pc_max = battery.max_charge_kw
pd_max = battery.max_discharge_kw pd_max = battery.max_discharge_kw

View file

@ -46,7 +46,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_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 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)
@ -54,9 +57,28 @@ def test_oracle_arbitrages_clear_spread():
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"].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["discharge_kwh"].iloc[12:].sum() > 0
assert out["savings"].sum() > 0
def test_apply_nl_tariff_matches_user_formula(): def test_apply_nl_tariff_matches_user_formula():