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()

126
scripts/capacity_sweep.py Normal file
View file

@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Sweep battery capacity at fixed power and report savings.
Same scenario as the dynamic-rate calibration test:
- 4000 kWh/yr demand
- Dynamic rate, avg retail 0.25
- No PV, no saldering
- 0.8 kW charge/discharge power (plug-in)
Reports both the honest LP year-1 savings and the cracked store-calculator
quote, plus the marginal savings per added kWh of capacity.
"""
from __future__ import annotations
import argparse
import csv
import math
from pathlib import Path
import numpy as np
from pluginbattery.sim import (
Battery,
apply_nl_tariff,
load_hourly,
oracle_daily_schedule,
simulate,
)
from pluginbattery.store_calc import (
Scenario,
StoreParams,
quote as store_quote,
)
def main() -> None:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--power", type=float, default=0.8, help="Battery in/out power kW")
p.add_argument("--retail", type=float, default=0.25, help="Avg retail EUR/kWh")
p.add_argument("--demand", type=float, default=4000.0, help="Annual demand kWh/yr")
p.add_argument("--eta", type=float, default=0.88, help="LP round-trip efficiency")
p.add_argument("--cap-min", type=float, default=0.5, help="Min capacity kWh")
p.add_argument("--cap-max", type=float, default=15.0, help="Max capacity kWh")
p.add_argument("--cap-step", type=float, default=0.5, help="Capacity step kWh")
p.add_argument("--out", default="data/processed/capacity_sweep.csv")
args = p.parse_args()
base = load_hourly("data/raw"); base = apply_nl_tariff(base)
# Scale to the test scenario.
annual_demand = base["demand_kwh"].sum() * (8766.0 / len(base))
df = base.copy()
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
df["export_eur_per_kwh"] = 0.0 # no saldering
avg_epex = float(df["epex_eur_per_kwh"].mean())
store_params = StoreParams(avg_epex_eur_per_kwh=avg_epex)
capacities = np.arange(args.cap_min, args.cap_max + args.cap_step / 2, args.cap_step)
rows = []
print(f"Sweeping {len(capacities)} capacities at {args.power} kW power...")
print()
print(f"{'cap (kWh)':>9s} {'LP €/yr':>9s} {'store €/yr':>11s} "
f"{'LP Δ/kWh':>10s} {'store Δ/kWh':>12s} {'overstate':>10s}")
print("-" * 75)
prev_lp, prev_store = 0.0, 0.0
for cap in capacities:
bat = Battery(
capacity_kwh=float(cap), 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 = float(out["savings"].sum())
s = Scenario(
capacity_kwh=float(cap), max_charge_kw=args.power,
battery_cost_eur=0.0, # not used here
avg_retail_eur_per_kwh=args.retail,
has_pv=False, has_saldering=False, dynamic_rate=True,
)
sc = store_quote(s, store_params)["year1_total"]
marg_lp = (lp - prev_lp) / args.cap_step
marg_store = (sc - prev_store) / args.cap_step
ratio = sc / lp if lp > 0 else float("inf")
prev_lp, prev_store = lp, sc
rows.append({
"capacity_kwh": round(float(cap), 3),
"power_kw": args.power,
"lp_year1_eur": round(lp, 2),
"store_year1_eur": round(sc, 2),
"marginal_lp_eur_per_kwh": round(marg_lp, 2),
"marginal_store_eur_per_kwh": round(marg_store, 2),
"overstatement_ratio": round(ratio, 2),
})
print(f"{cap:>9.2f}{lp:>7.2f}{sc:>9.2f} "
f"{marg_lp:>8.2f}{marg_store:>10.2f} {ratio:>8.2f}×")
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[0].keys())
writer.writeheader()
writer.writerows(rows)
print(f"\n{out_path}")
# Identify the diminishing-returns elbow on the LP curve.
lp_marg = [r["marginal_lp_eur_per_kwh"] for r in rows]
threshold = lp_marg[0] * 0.5 # half the first-kWh marginal value
elbow_idx = next((i for i, m in enumerate(lp_marg) if m < threshold), len(lp_marg))
if 0 < elbow_idx < len(rows):
elbow = rows[elbow_idx]
print(f"\nLP marginal value drops below 50% of first-kWh value at "
f"capacity ≈ {elbow['capacity_kwh']} kWh "
f"(€{elbow['marginal_lp_eur_per_kwh']:.2f}/kWh added).")
if __name__ == "__main__":
main()

View 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()

View file

@ -0,0 +1,136 @@
#!/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()

141
scripts/export_from_influx.py Executable file
View file

@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Export hourly prices, P1 power, and solar irradiance from data-vm InfluxDB.
SSHs into data-vm and queries localhost:8086 there. The InfluxDB v2 token is
read from sync/json2influx.py on data-vm no secrets stored locally or in git.
Outputs three CSVs in data/raw/ with columns:
timestamp (UTC ISO-8601), <value>
Usage:
python scripts/export_from_influx.py
python scripts/export_from_influx.py --start 2024-09-01T00:00:00Z --end 2025-09-01T00:00:00Z
"""
from __future__ import annotations
import argparse
import csv
import io
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
REMOTE = "data-vm"
INFLUX_URL = "http://localhost:8086"
INFLUX_DB = "default"
# Default window: cleanest 365-day overlap of prices + P1 + solar.
# 2024-09→2025-09 has a 19-day P1 outage in March 2025; the year before is clean.
DEFAULT_START = "2023-09-01T00:00:00Z"
DEFAULT_END = "2024-09-01T00:00:00Z"
QUERIES = {
# Raw day-ahead EPEX price, mean per hour, in EUR/kWh.
# Taxes and supplier markup are layered on downstream — not here.
"prices_hourly.csv": (
"eur_per_kwh",
"SELECT mean(\"epex-price\")/1000 AS eur_per_kwh "
"FROM \"measurements\" WHERE \"market\" = 'nl' "
"AND time >= '{start}' AND time < '{end}' "
"GROUP BY time(1h) fill(none)",
),
# Net power draw at the meter, mean over the hour, in watts.
# Sign convention to be confirmed (likely positive = import).
"p1_hourly.csv": (
"power_w",
"SELECT mean(\"power-current\") AS power_w "
"FROM \"measurements\" "
"WHERE time >= '{start}' AND time < '{end}' "
"GROUP BY time(1h) fill(none)",
),
# Horizontal solar irradiance, mean over the hour, W/m^2.
"solar_hourly.csv": (
"irradiance_w_m2",
"SELECT mean(\"ws1-solarradiation\") AS irradiance_w_m2 "
"FROM \"measurements\" "
"WHERE time >= '{start}' AND time < '{end}' "
"GROUP BY time(1h) fill(none)",
),
}
def fetch_csv(query: str) -> str:
"""Run InfluxQL on data-vm and return CSV body."""
# Escape internal double quotes so they survive the bash double-quoted arg.
q_escaped = query.replace("\\", "\\\\").replace('"', r"\"")
remote_script = f"""set -euo pipefail
TOKEN=$(grep -oP "token='[^']+'" /home/michielb/sync/json2influx.py | head -1 | cut -d"'" -f2)
HTTP_STATUS=$(curl -sS -o /tmp/influx_resp.txt -w "%{{http_code}}" -G "{INFLUX_URL}/query" \\
--data-urlencode "db={INFLUX_DB}" \\
--data-urlencode "q={q_escaped}" \\
-H "Authorization: Token $TOKEN" \\
-H "Accept: application/csv")
if [ "$HTTP_STATUS" != "200" ]; then
echo "HTTP $HTTP_STATUS:" >&2
cat /tmp/influx_resp.txt >&2
exit 1
fi
cat /tmp/influx_resp.txt
"""
result = subprocess.run(
["ssh", REMOTE, "bash -s"],
input=remote_script,
capture_output=True,
text=True,
)
if result.returncode != 0:
sys.stderr.write(f"--- remote stderr ---\n{result.stderr}\n")
sys.stderr.write(f"--- remote stdout ---\n{result.stdout}\n")
raise SystemExit(f"remote command failed (exit {result.returncode})")
return result.stdout
def write_clean(raw_csv: str, value_col: str, out_path: Path) -> int:
"""Strip InfluxDB's name/tags columns, convert ns epoch → ISO-8601 UTC."""
reader = csv.DictReader(io.StringIO(raw_csv))
rows_out = 0
with out_path.open("w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["timestamp", value_col])
for row in reader:
ts_ns = int(row["time"])
iso = datetime.fromtimestamp(ts_ns / 1e9, tz=timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
writer.writerow([iso, row[value_col]])
rows_out += 1
return rows_out
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--start", default=DEFAULT_START)
parser.add_argument("--end", default=DEFAULT_END)
parser.add_argument("--out", default="data/raw")
args = parser.parse_args()
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
expected_hours = int(
(
datetime.strptime(args.end, "%Y-%m-%dT%H:%M:%SZ")
- datetime.strptime(args.start, "%Y-%m-%dT%H:%M:%SZ")
).total_seconds()
// 3600
)
print(f"Window: {args.start}{args.end} ({expected_hours} hours)")
for filename, (value_col, query_template) in QUERIES.items():
query = query_template.format(start=args.start, end=args.end)
print(f"Fetching {filename} ...", flush=True)
raw = fetch_csv(query)
rows = write_clean(raw, value_col, out_dir / filename)
coverage = rows / expected_hours * 100 if expected_hours else 0
print(f" {rows} rows ({coverage:.1f}% coverage) -> {out_dir / filename}")
return 0
if __name__ == "__main__":
sys.exit(main())

66
scripts/run_oracle.py Normal file
View file

@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Run the daily 24h-foresight oracle over the full window and write per-hour output."""
from __future__ import annotations
import argparse
from pathlib import Path
from pluginbattery.sim import (
ECOFLOW_STREAM_AC,
Battery,
apply_nl_tariff,
load_hourly,
oracle_daily_schedule,
simulate,
)
# EcoFlow Stream AC retail price (incl. VAT) for payback math.
ECOFLOW_STREAM_AC_PRICE_EUR = 699.0
def run(battery: Battery, price_eur: float, data_dir: Path, out_path: Path) -> None:
df = load_hourly(data_dir)
df = apply_nl_tariff(df) # eur_per_kwh now = consumer price (incl. VAT + tax)
print(f"Loaded {len(df)} hours: {df.index[0]}{df.index[-1]}")
print(f"Battery: cap={battery.capacity_kwh} kWh, "
f"P_in={battery.max_charge_kw} kW, P_out={battery.max_discharge_kw} kW, "
f"η_rt={battery.round_trip_eff:.2f}, allows_export={battery.allows_export}")
print(f"Price (incl. VAT) : €{price_eur:.0f}")
print(f"Tariff: consumer = EPEX × 1.21 + 0.136 EUR/kWh")
print("Solving daily 24h LPs...")
schedule = oracle_daily_schedule(df, battery)
print("Walking SoC...")
out = simulate(df, battery, schedule)
out_path.parent.mkdir(parents=True, exist_ok=True)
out.to_csv(out_path)
cost_no = out["cost_no_battery"].sum()
cost_yes = out["cost_with_battery"].sum()
savings = out["savings"].sum()
cycles = out["discharge_kwh"].sum() / battery.capacity_kwh
payback_years = price_eur / savings if savings > 0 else float("inf")
print()
print(f"Year cost without battery : €{cost_no:8.2f}")
print(f"Year cost with battery : €{cost_yes:8.2f}")
print(f"Annual savings (consumer) : €{savings:8.2f}")
print(f"Equivalent full cycles : {cycles:7.1f}")
print(f"Hours simulated : {len(out)}")
print(f"Simple payback : {payback_years:6.2f} years")
print(f"Output -> {out_path}")
def main() -> None:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--data", default="data/raw")
p.add_argument("--out", default="data/processed/oracle_daily.csv")
p.add_argument("--price", type=float, default=ECOFLOW_STREAM_AC_PRICE_EUR)
args = p.parse_args()
run(ECOFLOW_STREAM_AC, args.price, Path(args.data), Path(args.out))
if __name__ == "__main__":
main()

View file

@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Pull thuisbatterijgids.net's full battery catalogue into a CSV.
The site exposes a JSON REST endpoint at /wp-json/tbg/v1/batteries no
browser, no scraping, just paginated JSON. Output goes to data/raw/.
"""
from __future__ import annotations
import argparse
import csv
import json
import urllib.request
from pathlib import Path
API = "https://thuisbatterijgids.net/wp-json/tbg/v1/batteries"
HEADERS = {"User-Agent": "Mozilla/5.0 (research; pluginbattery)"}
FIELDS = [
"brand", "title", "capacity_kwh", "power_w",
"price_eur", "price_per_kwh", "installation", "review_score",
"recommended", "url", "id",
]
def fetch_page(page: int, per_page: int = 100) -> dict:
url = f"{API}?per_page={per_page}&page={page}"
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--out", default="data/raw/thuisbatterijgids_catalog.csv",
help="CSV output path (a parallel .json with all fields is also written)")
args = ap.parse_args()
all_batteries = []
page = 1
while True:
data = fetch_page(page)
all_batteries.extend(data["batteries"])
print(f" page {page}/{data['total_pages']}: +{len(data['batteries'])} batteries "
f"(running total {len(all_batteries)})")
if page >= data["total_pages"]:
break
page += 1
csv_path = Path(args.out)
csv_path.parent.mkdir(parents=True, exist_ok=True)
with csv_path.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
writer.writeheader()
for b in all_batteries:
writer.writerow({
"brand": b.get("brand", ""),
"title": b.get("title", ""),
"capacity_kwh": b.get("capacity"),
"power_w": b.get("power_continuous"),
"price_eur": b.get("price"),
"price_per_kwh": b.get("price_per_kwh"),
"installation": b.get("installation"),
"review_score": b.get("review_score"),
"recommended": b.get("recommended"),
"url": b.get("url", ""),
"id": b.get("id"),
})
json_path = csv_path.with_suffix(".json")
with json_path.open("w") as f:
json.dump(all_batteries, f, indent=2, ensure_ascii=False)
print(f"\nWrote {len(all_batteries)} batteries:")
print(f" CSV → {csv_path}")
print(f" JSON → {json_path}")
if __name__ == "__main__":
main()