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
147
scripts/battery_leaderboard.py
Normal file
147
scripts/battery_leaderboard.py
Normal 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
126
scripts/capacity_sweep.py
Normal 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()
|
||||
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()
|
||||
136
scripts/compare_with_store.py
Normal file
136
scripts/compare_with_store.py
Normal 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
141
scripts/export_from_influx.py
Executable 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
66
scripts/run_oracle.py
Normal 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()
|
||||
79
scripts/scrape_batteries.py
Normal file
79
scripts/scrape_batteries.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue