pluginbattery/scripts/compare_with_store.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

136 lines
5.8 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 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
# PV scenario, fixed retail, no saldering:
python scripts/compare_with_store.py --pv-kwp 3.0 --fixed --retail 0.28
"""
from __future__ import annotations
import argparse
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__)
# 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.")
# Solar
p.add_argument("--pv-kwp", type=float, default=0.0, help="PV size in kWp; 0 = no PV")
p.add_argument("--pv-yield", type=float, default=875.0,
help="Annual PV yield in kWh/kWp (store uses 875 in their footer)")
# 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.
annual_demand = base["demand_kwh"].sum() * (8766.0 / len(base))
df["demand_kwh"] = base["demand_kwh"] * (args.demand / annual_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"]
# PV.
if args.pv_kwp > 0:
df = synthesize_pv(df, kwp=args.pv_kwp, target_kwh_per_kwp_per_year=args.pv_yield)
# 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
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=args.pv_kwp > 0,
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 : {args.pv_kwp} kWp"
f"{' @ ' + str(args.pv_yield) + ' kWh/kWp/yr' if args.pv_kwp else ''}")
print(f" 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()