refactor: drop PV synthesis, model raw P1 net signal directly

The simulator used to reconstruct "gross household demand" by adding
back a synthesized PV trace (irradiance × kWp peak-match) to the P1 net
meter, then re-subtract a different synthesized PV per scenario. That
reconstruction was leaky — Michiel's horizontal pyranometer is at a
different location and orientation than dad's SE-facing array, so the
synthesis can't reproduce dad's actual production curve. Result: 511
hours of negative "gross demand" and phantom export inflation up to
~6 kW peak in scenarios where pv_kwp ≠ 3.

New shape: simulator works on a single signed signal, raw_demand_kw
(the P1 reading as recorded). No solar synthesis. Whatever the meter
shows is the input.

Concretely:
  - sim.py: drop synthesize_pv, reconstruct_gross_demand,
    schedule_with_planning_pv, no_foresight_schedule, groundhog_schedule,
    _oracle_daily_schedule_legacy. Rename column convention demand_kwh →
    raw_demand_kw. Plug-in discharge cap becomes max(0, raw_demand_kw).
  - web.py: drop pv_kwp/pv_yield/strategy form params. Demand slider
    now applies as an *additive* baseline shift (not multiplicative —
    multiplying scaled the export bursts too, which is wrong since dad's
    PV stays the same regardless of household consumption). Default
    demand_kwh = 2325 (dad's actual full-year net per his quote;
    extrapolated 8-month window comes out to ~1515, partial coverage).
    Saturation metric now measures (surplus ≥ pc_max), not (charge ≥
    pc_max) — the latter conflated arbitrage top-off with power-bottleneck.
  - templates/index.html: drop PV input, drop strategy radios, drop
    irradiance chart. Modal charts collapsed from 4 to 3: price, net
    meter (toggles between with/without battery), SoC.
  - app.js: mirror the above, drop pv_kwp/strategy plumbing.
  - tests: rebase fixtures on raw_demand_kw, drop synthesize_pv test.
  - scripts: drop --pv-kwp/--pv-yield flags throughout, switch column
    references to raw_demand_kw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michiel Berger 2026-05-08 21:32:59 +02:00
parent e2fb72a0ab
commit fc90e65271
12 changed files with 296 additions and 476 deletions

View file

@ -46,8 +46,8 @@ def main() -> None:
# ─── Build the same df the web app sees ───
base = apply_nl_tariff(load_hourly("data/raw"))
df = base.copy()
annual = base["demand_kwh"].sum() * 8766 / len(base)
df["demand_kwh"] = base["demand_kwh"] * (args.demand_kwh / annual)
annual = base["raw_demand_kw"].sum() * 8766 / len(base)
df["raw_demand_kw"] = base["raw_demand_kw"] * (args.demand_kwh / annual)
scale = args.retail / base["eur_per_kwh"].mean()
df["eur_per_kwh"] = base["eur_per_kwh"] * scale
df["epex_eur_per_kwh"] = base["epex_eur_per_kwh"] * scale
@ -62,10 +62,10 @@ def main() -> None:
out = out.assign(
import_kwh=np.maximum(0.0, g),
export_kwh=np.maximum(0.0, -g),
no_bat_grid=out["demand_kwh"], # equals the original net since pv=0
cost_no_battery_eur=np.where(out["demand_kwh"] > 0,
out["demand_kwh"] * out["eur_per_kwh"],
out["demand_kwh"] * out["export_eur_per_kwh"]),
no_bat_grid=out["raw_demand_kw"], # equals the original net since pv=0
cost_no_battery_eur=np.where(out["raw_demand_kw"] > 0,
out["raw_demand_kw"] * out["eur_per_kwh"],
out["raw_demand_kw"] * out["export_eur_per_kwh"]),
cost_with_battery_eur=np.where(g > 0, g * out["eur_per_kwh"],
g * out["export_eur_per_kwh"]),
)
@ -89,7 +89,7 @@ def main() -> None:
print(f" {'hr':>2s} {'price':>5s} {'no-bat meter':>15s} "
f"{'charge':>7s} {'disch':>7s} {'SoC':>5s} {'with-bat meter':>16s} {'sav':>6s}")
for ts, r in day.iterrows():
net_no = r["demand_kwh"]
net_no = r["raw_demand_kw"]
mark = "↓ exp" if net_no < -0.01 else "↑ imp" if net_no > 0.01 else "·"
grid = r["grid_kwh_with_battery"]
grid_mark = "↓ exp" if grid < -0.01 else "↑ imp" if grid > 0.01 else "·"
@ -97,8 +97,8 @@ def main() -> None:
f"{net_no:+7.2f} {mark:5s} "
f"{r['charge_kwh']:>6.2f} {r['discharge_kwh']:>6.2f} "
f"{r['soc_kwh']:>4.2f} {grid:+8.2f} {grid_mark:5s}{r['savings_eur']:+5.2f}")
d_imp_no = max(0.0, day['demand_kwh'].clip(lower=0).sum())
d_exp_no = -day['demand_kwh'].clip(upper=0).sum()
d_imp_no = max(0.0, day['raw_demand_kw'].clip(lower=0).sum())
d_exp_no = -day['raw_demand_kw'].clip(upper=0).sum()
d_imp_yes = day['import_kwh'].sum()
d_exp_yes = day['export_kwh'].sum()
d_sav = day['savings_eur'].sum()
@ -113,8 +113,8 @@ def main() -> None:
week_savings = win["savings_eur"].sum()
week_charge = win["charge_kwh"].sum()
week_disch = win["discharge_kwh"].sum()
week_surplus = (-win["demand_kwh"].clip(upper=0)).sum()
week_imports = win["demand_kwh"].clip(lower=0).sum()
week_surplus = (-win["raw_demand_kw"].clip(upper=0)).sum()
week_imports = win["raw_demand_kw"].clip(lower=0).sum()
annual_savings = out["savings_eur"].sum()
annualised = week_savings * 365.25 / args.days