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

@ -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():