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

@ -2,10 +2,11 @@
"""Run every battery in the catalog through both calculators and rank by payback.
Default scenario matches the dynamic-mode store-test:
4000 kWh/yr demand, avg retail 0.25, no PV, no saldering, 3% inflation.
4000 kWh/yr net demand, avg retail 0.25, no saldering, 3% inflation.
Override with CLI flags to test a different scenario (e.g., dad's situation
with --pv-kwp 3.0). Output: data/processed/battery_leaderboard.csv.
The simulator works on the raw P1 net-meter trace whatever PV is on the
roof is already netted in. To model a different PV setup, swap the input
data, not a CLI flag. Output: data/processed/battery_leaderboard.csv.
"""
from __future__ import annotations
@ -19,7 +20,6 @@ from pluginbattery.sim import (
load_hourly,
oracle_daily_schedule,
simulate,
synthesize_pv,
)
from pluginbattery.store_calc import (
Scenario,
@ -36,8 +36,6 @@ def main() -> None:
p.add_argument("--retail", type=float, default=0.25)
p.add_argument("--demand", type=float, default=4000.0)
p.add_argument("--eta", type=float, default=0.88)
p.add_argument("--pv-kwp", type=float, default=0.0)
p.add_argument("--pv-yield", type=float, default=875.0)
p.add_argument("--saldering", action="store_true")
p.add_argument("--inflation", type=float, default=0.03)
p.add_argument("--top", type=int, default=20, help="How many rows to print")
@ -46,15 +44,14 @@ def main() -> None:
# ─── Load and prep the simulation data once ────────────────────────
base = load_hourly("data/raw"); base = apply_nl_tariff(base)
df = base.copy()
annual_demand = base["demand_kwh"].sum() * (8766.0 / len(base))
df["demand_kwh"] = base["demand_kwh"] * (args.demand / annual_demand)
annual_net_demand = base["raw_demand_kw"].sum() * (8766.0 / len(base))
df["raw_demand_kw"] = base["raw_demand_kw"] * (args.demand / annual_net_demand)
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
if args.pv_kwp > 0:
df = synthesize_pv(df, kwp=args.pv_kwp,
target_kwh_per_kwp_per_year=args.pv_yield)
df["export_eur_per_kwh"] = df["eur_per_kwh"] if args.saldering else 0.0
# Existing-PV export visible at the meter (any hour where raw_demand_kw < 0).
has_existing_pv = bool((df["raw_demand_kw"] < 0).any())
avg_epex = float(df["epex_eur_per_kwh"].mean())
store_params = StoreParams(avg_epex_eur_per_kwh=avg_epex)
@ -80,8 +77,9 @@ def main() -> None:
# Cache LP results by (capacity, power) — many batteries share identical specs.
lp_cache: dict[tuple[float, float], float] = {}
print(f"Scenario: demand {args.demand:.0f} kWh/yr, avg retail €{args.retail}, "
f"PV {args.pv_kwp} kWp, saldering={args.saldering}, inflation {args.inflation*100:.1f}%/yr")
print(f"Scenario: net demand {args.demand:.0f} kWh/yr, avg retail €{args.retail}, "
f"existing-PV exports={'yes' if has_existing_pv else 'no'}, "
f"saldering={args.saldering}, inflation {args.inflation*100:.1f}%/yr")
print(f"Running {len(rows_in)} batteries (caching by (capacity, power))...")
print()
@ -104,7 +102,7 @@ def main() -> None:
scn = Scenario(
capacity_kwh=r["capacity_kwh"], max_charge_kw=r["power_kw"],
battery_cost_eur=r["price_eur"], avg_retail_eur_per_kwh=args.retail,
has_pv=args.pv_kwp > 0, has_saldering=args.saldering, dynamic_rate=True,
has_pv=has_existing_pv, has_saldering=args.saldering, dynamic_rate=True,
)
sq = store_quote(scn, store_params, inflation=args.inflation)