pluginbattery/scripts/compare_with_store.py
Michiel Berger fc90e65271 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>
2026-05-08 21:32:59 +02:00

129 lines
5.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Run a scenario through both our LP and the cracked store calculator.
This lets us see, for any battery / PV / saldering / rate combination, what
thuisbatterijgids.nl claims you'll save vs what the actual physics permit.
Examples:
# Reproduce the dynamic-mode tests:
python scripts/compare_with_store.py --capacity 1.92 --power 0.8 --cost 700 --retail 0.25
python scripts/compare_with_store.py --capacity 5.12 --power 0.8 --cost 1339 --retail 0.25
python scripts/compare_with_store.py --capacity 5.12 --power 2.5 --cost 1339 --retail 0.25
# Fixed retail, no saldering:
python scripts/compare_with_store.py --fixed --retail 0.28
"""
from __future__ import annotations
import argparse
from pluginbattery.sim import (
Battery,
apply_nl_tariff,
load_hourly,
oracle_daily_schedule,
simulate,
)
from pluginbattery.store_calc import (
Scenario,
StoreParams,
payback_years,
quote as store_quote,
)
def main() -> None:
p = argparse.ArgumentParser(description=__doc__)
# Battery
p.add_argument("--capacity", type=float, default=1.92, help="Battery capacity in kWh")
p.add_argument("--power", type=float, default=0.8, help="Battery in/out power in kW")
p.add_argument("--cost", type=float, default=700, help="Battery price (incl. VAT) in EUR")
p.add_argument("--eta", type=float, default=0.88, help="LP round-trip efficiency")
# Tariff
p.add_argument("--retail", type=float, default=0.25, help="Average retail EUR/kWh")
p.add_argument("--demand", type=float, default=4000.0, help="Annual demand kWh/yr")
p.add_argument("--fixed", action="store_true",
help="Fixed-rate mode (no time-of-day variation; kills arbitrage)")
p.add_argument("--saldering", action="store_true",
help="Full saldering on (export = retail). Default: no saldering.")
# Inflation
p.add_argument("--inflation", type=float, default=0.03)
args = p.parse_args()
# ─── Honest LP ─────────────────────────────────────────────────────
base = load_hourly("data/raw"); base = apply_nl_tariff(base)
df = base.copy()
# Demand scaling — scale the entire signed signal so existing-PV exports
# scale with the household.
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)
# Retail price.
if args.fixed:
df["eur_per_kwh"] = args.retail
epex_in_use = base["epex_eur_per_kwh"] # raw EPEX kept
else:
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
epex_in_use = df["epex_eur_per_kwh"]
# Saldering.
if args.saldering:
df["export_eur_per_kwh"] = df["eur_per_kwh"]
else:
df["export_eur_per_kwh"] = 0.0 # post-saldering default in this script
has_existing_pv = bool((df["raw_demand_kw"] < 0).any())
bat = Battery(
capacity_kwh=args.capacity,
max_charge_kw=args.power,
max_discharge_kw=args.power,
round_trip_eff=args.eta,
allows_export=False,
)
out = simulate(df, bat, oracle_daily_schedule(df, bat))
lp_year1 = float(out["savings"].sum())
lp_payback = payback_years(lp_year1, args.cost, args.inflation)
# ─── Store calculator (cracked) ────────────────────────────────────
avg_epex_used = float(epex_in_use.mean())
store_params = StoreParams(avg_epex_eur_per_kwh=avg_epex_used)
scenario = Scenario(
capacity_kwh=args.capacity,
max_charge_kw=args.power,
battery_cost_eur=args.cost,
avg_retail_eur_per_kwh=args.retail,
has_pv=has_existing_pv,
has_saldering=args.saldering,
dynamic_rate=not args.fixed,
)
sq = store_quote(scenario, store_params, inflation=args.inflation)
# ─── Report ─────────────────────────────────────────────────────────
print(f"Scenario:")
print(f" Battery : {args.capacity} kWh / {args.power} kW / €{args.cost:.0f}")
print(f" Tariff : avg retail €{args.retail}, "
f"{'FIXED' if args.fixed else 'DYNAMIC'} rate, "
f"{'WITH' if args.saldering else 'NO'} saldering")
print(f" PV : {'existing PV in meter signal' if has_existing_pv else 'no measurable existing PV'}")
print(f" Net demand : {args.demand:.0f} kWh/yr")
print(f" Inflation : {args.inflation*100:.1f}%/yr")
print()
print(f"{'':22s} {'year-1 €':>10s} {'payback':>9s}")
print(f" {'Honest LP':20s}{lp_year1:>8.2f} {lp_payback:>5.2f} yr")
print(f" {'Store calculator':20s}{sq['year1_total']:>8.2f} {sq['payback_years']:>5.2f} yr")
if sq["dynamic_arbitrage"] > 0 or sq["pv_self_consumption"] > 0:
print(f" ↳ arbitrage €{sq['dynamic_arbitrage']:>8.2f}")
print(f" ↳ self-consume €{sq['pv_self_consumption']:>8.2f}")
if sq["year1_total"] > 0:
ratio = sq["year1_total"] / max(lp_year1, 1e-9)
print()
print(f" Store overstates savings by {ratio:.2f}× "
f"(€{sq['year1_total'] - lp_year1:+.2f}/yr)")
if __name__ == "__main__":
main()