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:
commit
60e0706736
29 changed files with 30358 additions and 0 deletions
215
scripts/compare_batteries.py
Normal file
215
scripts/compare_batteries.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
#!/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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue