Initial import: home-battery ROI simulator + cracked thuisbatterijgids calc

- Hourly data exporter (InfluxDB → CSV) for prices, P1, irradiance.
- LP-based 24h-foresight oracle dispatch with SoC-consistent state engine.
- Reverse-engineered thuisbatterijgids.nl formula (matches their quotes
  to within €0.50 across three battery configs).
- Catalog scraper for the 52 batteries on thuisbatterijgids.net via their
  /wp-json REST endpoint.
- Web app (Flask) that ranks every catalog battery by honest payback and
  contrasts with the store's quote, deployable via the included Procfile.
This commit is contained in:
Michiel Berger 2026-04-30 13:46:27 +02:00
commit 60e0706736
29 changed files with 30358 additions and 0 deletions

View file

@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""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.
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.
"""
from __future__ import annotations
import argparse
import csv
from pathlib import Path
from pluginbattery.sim import (
Battery,
apply_nl_tariff,
load_hourly,
oracle_daily_schedule,
simulate,
synthesize_pv,
)
from pluginbattery.store_calc import (
Scenario,
StoreParams,
payback_years,
quote as store_quote,
)
def main() -> None:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--catalog", default="data/raw/thuisbatterijgids_catalog.csv")
p.add_argument("--out", default="data/processed/battery_leaderboard.csv")
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")
args = p.parse_args()
# ─── 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)
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
avg_epex = float(df["epex_eur_per_kwh"].mean())
store_params = StoreParams(avg_epex_eur_per_kwh=avg_epex)
# ─── Load catalog ─────────────────────────────────────────────────
rows_in = []
with open(args.catalog) as f:
for r in csv.DictReader(f):
try:
cap = float(r["capacity_kwh"])
pw = float(r["power_w"]) / 1000.0 # → kW
price = float(r["price_eur"])
except (TypeError, ValueError):
continue
if cap <= 0 or pw <= 0 or price <= 0:
continue
rows_in.append({
"title": r["title"], "brand": r["brand"],
"capacity_kwh": cap, "power_kw": pw, "price_eur": price,
"url": r["url"],
})
# 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"Running {len(rows_in)} batteries (caching by (capacity, power))...")
print()
rows_out = []
for i, r in enumerate(rows_in, 1):
key = (round(r["capacity_kwh"], 3), round(r["power_kw"], 3))
if key in lp_cache:
lp_year1 = lp_cache[key]
else:
bat = Battery(
capacity_kwh=r["capacity_kwh"], max_charge_kw=r["power_kw"],
max_discharge_kw=r["power_kw"], round_trip_eff=args.eta,
allows_export=False,
)
out = simulate(df, bat, oracle_daily_schedule(df, bat))
lp_year1 = float(out["savings"].sum())
lp_cache[key] = lp_year1
lp_payback = payback_years(lp_year1, r["price_eur"], args.inflation)
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,
)
sq = store_quote(scn, store_params, inflation=args.inflation)
rows_out.append({
"title": r["title"], "brand": r["brand"],
"capacity_kwh": r["capacity_kwh"], "power_kw": r["power_kw"],
"price_eur": r["price_eur"],
"lp_year1_eur": round(lp_year1, 2),
"lp_payback_yr": round(lp_payback, 2),
"store_year1_eur": round(sq["year1_total"], 2),
"store_payback_yr": round(sq["payback_years"], 2),
"overstatement": round(sq["year1_total"] / lp_year1, 2) if lp_year1 > 0 else None,
"url": r["url"],
})
# Sort by honest payback (ascending = best first).
rows_out.sort(key=lambda r: r["lp_payback_yr"])
out_path = Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=rows_out[0].keys())
writer.writeheader()
writer.writerows(rows_out)
# Pretty-print top N.
print(f"{'rank':>4s} {'battery':40s} {'cap':>5s} {'kW':>4s} "
f"{'price':>7s} {'LP €/yr':>9s} {'LP yr':>7s} {'Store €/yr':>11s} {'Store yr':>9s} {'×over':>6s}")
print("-" * 120)
for i, r in enumerate(rows_out[:args.top], 1):
title = (r["title"][:38] + "") if len(r["title"]) > 39 else r["title"]
over = f"{r['overstatement']:.2f}" if r["overstatement"] else ""
print(f"{i:>4d} {title:40s} {r['capacity_kwh']:>5.2f} {r['power_kw']:>4.1f} "
f"{r['price_eur']:>5.0f}{r['lp_year1_eur']:>7.2f} {r['lp_payback_yr']:>5.2f} "
f"{r['store_year1_eur']:>9.2f} {r['store_payback_yr']:>7.2f} {over:>5s}×")
print(f"\nFull leaderboard ({len(rows_out)} batteries) → {out_path}")
if __name__ == "__main__":
main()