pluginbattery/scripts/battery_leaderboard.py
Michiel Berger 60e0706736 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.
2026-04-30 13:46:27 +02:00

147 lines
6.2 KiB
Python
Raw 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 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()