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

@ -1,9 +1,10 @@
#!/usr/bin/env python3
"""Run the 24h-foresight oracle for several battery configs and print a comparison.
"""Run the price-arbitrage oracle for several battery configs and print a comparison.
Saves per-hour CSVs for each config plus a summary table. Tariff: NL consumer.
With --pv-kwp > 0, synthesizes PV output from horizontal irradiance and treats
saldering as full export credit at consumer price (NL pre-2027).
The simulator works on the raw P1 net-meter trace whatever PV is on the
roof is already netted in. Existing-PV exports show up as negative
``raw_demand_kw`` and are absorbed by the battery before crossing the meter.
"""
from __future__ import annotations
@ -18,7 +19,6 @@ from pluginbattery.sim import (
load_hourly,
oracle_daily_schedule,
simulate,
synthesize_pv,
)
CONFIGS = [
@ -79,14 +79,6 @@ def payback_years(year1_savings: float, cost: float, inflation: float = 0.0) ->
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--pv-kwp", type=float, default=0.0,
help="PV system size in kWp. 0 = no solar (default).",
)
parser.add_argument(
"--pv-target", type=float, default=900.0,
help="Calibrate PV to this annual yield in kWh/kWp/year. Default 900 = NL norm.",
)
parser.add_argument(
"--saldering", choices=["none", "full"], default="none",
help="'full' = export at consumer price (NL pre-2027). "
@ -121,11 +113,12 @@ def main() -> None:
elif args.price_mult != 1.0:
df["eur_per_kwh"] = df["eur_per_kwh"] * args.price_mult
pv_note = "no solar"
if args.pv_kwp > 0:
df = synthesize_pv(df, kwp=args.pv_kwp, target_kwh_per_kwp_per_year=args.pv_target)
pv_kwh_year = float(df["pv_kwh"].sum() * 8766.0 / len(df))
pv_note = f"{args.pv_kwp:.1f} kWp PV, calibrated to {pv_kwh_year:.0f} kWh/yr"
# Existing PV (if any) is already in raw_demand_kw via the meter signal.
annual_export = float(-df["raw_demand_kw"].clip(upper=0).sum() * 8766.0 / len(df))
if annual_export > 1.0:
pv_note = f"{annual_export:.0f} kWh/yr exported at meter (existing PV)"
else:
pv_note = "no measurable existing PV in the data"
if args.export_rate is not None:
df["export_eur_per_kwh"] = float(args.export_rate)
@ -146,10 +139,10 @@ def main() -> None:
print(f"Tariff: import = {price_note}; {sald_note}")
if args.price_inflation > 0:
print(f"Payback uses {args.price_inflation*100:.1f}%/yr energy-price inflation")
print(f"Solar : {pv_note}\n")
print(f"Meter : {pv_note}\n")
# Headline figures with no battery, just for context.
g_no = df["demand_kwh"] - df.get("pv_kwh", 0)
g_no = df["raw_demand_kw"]
imp_p = df["eur_per_kwh"]
exp_p = df.get("export_eur_per_kwh", df["eur_per_kwh"])
cost_no_battery_total = float((g_no.where(g_no > 0, 0) * imp_p +
@ -187,7 +180,6 @@ def main() -> None:
"max_charge_kw": battery.max_charge_kw,
"max_discharge_kw": battery.max_discharge_kw,
"price_eur": price,
"pv_kwp": args.pv_kwp,
"saldering": args.saldering,
"annual_savings_eur": round(savings, 2),
"kwh_shifted": round(kwh_shifted, 1),