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

215 lines
7.5 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 the 24h-foresight oracle for several battery configs and print a comparison.
Saves per-hour CSVs for each config plus a summary table. Tariff: NL consumer.
With --pv-kwp > 0, synthesizes PV output from horizontal irradiance and treats
saldering as full export credit at consumer price (NL pre-2027).
"""
from __future__ import annotations
import argparse
import csv
import re
from pathlib import Path
from pluginbattery.sim import (
Battery,
apply_nl_tariff,
load_hourly,
oracle_daily_schedule,
simulate,
synthesize_pv,
)
CONFIGS = [
(
"EcoFlow Stream AC (1.92 kWh, 0.8 kW, plug-in)",
Battery(
capacity_kwh=1.92,
max_charge_kw=0.8,
max_discharge_kw=0.8,
round_trip_eff=0.90,
allows_export=False,
),
700.0,
),
(
"Marstek 5.12 kWh plug-in (800 W)",
Battery(
capacity_kwh=5.12,
max_charge_kw=0.8,
max_discharge_kw=0.8,
round_trip_eff=0.90,
allows_export=False,
),
1339.0,
),
(
"Marstek 5.12 kWh hardwired (2.5 kW)",
Battery(
capacity_kwh=5.12,
max_charge_kw=2.5,
max_discharge_kw=2.5,
round_trip_eff=0.90,
allows_export=False,
),
1339.0,
),
]
def slugify(s: str) -> str:
s = re.sub(r"[^a-zA-Z0-9]+", "_", s).strip("_").lower()
return s
def payback_years(year1_savings: float, cost: float, inflation: float = 0.0) -> float:
"""Solve Σ_{k=0}^{N-1} year1 × (1 + i)^k = cost for N.
With inflation = 0, reduces to cost / year1. With inflation > 0, the
closed form is N = log(1 + cost × i / year1) / log(1 + i).
"""
if year1_savings <= 0:
return float("inf")
if inflation == 0.0:
return cost / year1_savings
import math
return math.log(1 + cost * inflation / year1_savings) / math.log(1 + inflation)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--pv-kwp", type=float, default=0.0,
help="PV system size in kWp. 0 = no solar (default).",
)
parser.add_argument(
"--pv-target", type=float, default=900.0,
help="Calibrate PV to this annual yield in kWh/kWp/year. Default 900 = NL norm.",
)
parser.add_argument(
"--saldering", choices=["none", "full"], default="none",
help="'full' = export at consumer price (NL pre-2027). "
"'none' = export at raw EPEX (post-2027 default).",
)
parser.add_argument(
"--export-rate", type=float, default=None,
help="Fixed export EUR/kWh, e.g. 0 for 'no compensation' (sales-calculator style). "
"Overrides --saldering when set.",
)
parser.add_argument(
"--price-mult", type=float, default=1.0,
help="Scale consumer price by this factor (default 1.0). Use ~1.5 to project "
"from our 2023-24 backtest prices to current 2025 retail levels.",
)
parser.add_argument(
"--flat-retail", type=float, default=None,
help="Replace time-varying consumer price with a flat EUR/kWh value (kills arbitrage). "
"Mirrors the 'fixed rate' switch on online sales calculators.",
)
parser.add_argument(
"--price-inflation", type=float, default=0.0,
help="Annual energy-price inflation rate (e.g. 0.03 for 3%%). Affects payback only; "
"year-1 savings displayed are nominal.",
)
args = parser.parse_args()
df = load_hourly("data/raw")
df = apply_nl_tariff(df)
if args.flat_retail is not None:
df["eur_per_kwh"] = float(args.flat_retail)
elif args.price_mult != 1.0:
df["eur_per_kwh"] = df["eur_per_kwh"] * args.price_mult
pv_note = "no solar"
if args.pv_kwp > 0:
df = synthesize_pv(df, kwp=args.pv_kwp, target_kwh_per_kwp_per_year=args.pv_target)
pv_kwh_year = float(df["pv_kwh"].sum() * 8766.0 / len(df))
pv_note = f"{args.pv_kwp:.1f} kWp PV, calibrated to {pv_kwh_year:.0f} kWh/yr"
if args.export_rate is not None:
df["export_eur_per_kwh"] = float(args.export_rate)
sald_note = f"export = {args.export_rate:.3f} EUR/kWh (fixed)"
elif args.saldering == "none":
df["export_eur_per_kwh"] = df["epex_eur_per_kwh"]
sald_note = "no saldering (export = raw EPEX)"
else:
sald_note = "full saldering (export = consumer price)"
if args.flat_retail is not None:
price_note = f"FLAT €{args.flat_retail}/kWh (fixed-rate mode)"
elif args.price_mult != 1.0:
price_note = f"EPEX × 1.21 + 0.136 × {args.price_mult:.2f}"
else:
price_note = "EPEX × 1.21 + 0.136 (dynamic, as in raw data)"
print(f"Window: {df.index[0]}{df.index[-1]}, {len(df)} hours")
print(f"Tariff: import = {price_note}; {sald_note}")
if args.price_inflation > 0:
print(f"Payback uses {args.price_inflation*100:.1f}%/yr energy-price inflation")
print(f"Solar : {pv_note}\n")
# Headline figures with no battery, just for context.
g_no = df["demand_kwh"] - df.get("pv_kwh", 0)
imp_p = df["eur_per_kwh"]
exp_p = df.get("export_eur_per_kwh", df["eur_per_kwh"])
cost_no_battery_total = float((g_no.where(g_no > 0, 0) * imp_p +
g_no.where(g_no < 0, 0) * exp_p).sum())
import_kwh = float(g_no.where(g_no > 0, 0).sum())
export_kwh = float(-g_no.where(g_no < 0, 0).sum())
print(f" Without battery: {import_kwh:6.0f} kWh imported, {export_kwh:6.0f} kWh exported, "
f"net bill €{cost_no_battery_total:.2f}\n")
out_dir = Path("data/processed")
out_dir.mkdir(parents=True, exist_ok=True)
summary_rows = []
header = (
f"{'Battery':47s}{'€/yr saved':>12s}{'cycles':>9s}"
f"{'kWh shifted':>13s}{'payback':>10s}"
)
print(header)
print("-" * len(header))
for name, battery, price in CONFIGS:
schedule = oracle_daily_schedule(df, battery)
sim = simulate(df, battery, schedule)
savings = float(sim["savings"].sum())
kwh_shifted = float(sim["discharge_kwh"].sum())
cycles = kwh_shifted / battery.capacity_kwh
payback = payback_years(savings, price, args.price_inflation)
sim.to_csv(out_dir / f"hourly_{slugify(name)}.csv")
summary_rows.append(
{
"battery": name,
"capacity_kwh": battery.capacity_kwh,
"max_charge_kw": battery.max_charge_kw,
"max_discharge_kw": battery.max_discharge_kw,
"price_eur": price,
"pv_kwp": args.pv_kwp,
"saldering": args.saldering,
"annual_savings_eur": round(savings, 2),
"kwh_shifted": round(kwh_shifted, 1),
"equivalent_cycles": round(cycles, 1),
"payback_years": round(payback, 2),
"price_inflation": args.price_inflation,
}
)
print(
f"{name:47s}{savings:9.2f}{cycles:9.1f}{kwh_shifted:11.0f} kWh"
f"{payback:7.2f} yr"
)
summary_path = out_dir / "comparison.csv"
with summary_path.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=summary_rows[0].keys())
writer.writeheader()
writer.writerows(summary_rows)
print(f"\nSummary -> {summary_path}")
print(f"Per-hour outputs -> {out_dir}/hourly_*.csv")
if __name__ == "__main__":
main()