- 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.
136 lines
5.8 KiB
Python
136 lines
5.8 KiB
Python
#!/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()
|