The simulator used to reconstruct "gross household demand" by adding
back a synthesized PV trace (irradiance × kWp peak-match) to the P1 net
meter, then re-subtract a different synthesized PV per scenario. That
reconstruction was leaky — Michiel's horizontal pyranometer is at a
different location and orientation than dad's SE-facing array, so the
synthesis can't reproduce dad's actual production curve. Result: 511
hours of negative "gross demand" and phantom export inflation up to
~6 kW peak in scenarios where pv_kwp ≠ 3.
New shape: simulator works on a single signed signal, raw_demand_kw
(the P1 reading as recorded). No solar synthesis. Whatever the meter
shows is the input.
Concretely:
- sim.py: drop synthesize_pv, reconstruct_gross_demand,
schedule_with_planning_pv, no_foresight_schedule, groundhog_schedule,
_oracle_daily_schedule_legacy. Rename column convention demand_kwh →
raw_demand_kw. Plug-in discharge cap becomes max(0, raw_demand_kw).
- web.py: drop pv_kwp/pv_yield/strategy form params. Demand slider
now applies as an *additive* baseline shift (not multiplicative —
multiplying scaled the export bursts too, which is wrong since dad's
PV stays the same regardless of household consumption). Default
demand_kwh = 2325 (dad's actual full-year net per his quote;
extrapolated 8-month window comes out to ~1515, partial coverage).
Saturation metric now measures (surplus ≥ pc_max), not (charge ≥
pc_max) — the latter conflated arbitrage top-off with power-bottleneck.
- templates/index.html: drop PV input, drop strategy radios, drop
irradiance chart. Modal charts collapsed from 4 to 3: price, net
meter (toggles between with/without battery), SoC.
- app.js: mirror the above, drop pv_kwp/strategy plumbing.
- tests: rebase fixtures on raw_demand_kw, drop synthesize_pv test.
- scripts: drop --pv-kwp/--pv-yield flags throughout, switch column
references to raw_demand_kw.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
129 lines
5.5 KiB
Python
129 lines
5.5 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
|
||
|
||
# Fixed retail, no saldering:
|
||
python scripts/compare_with_store.py --fixed --retail 0.28
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
|
||
from pluginbattery.sim import (
|
||
Battery,
|
||
apply_nl_tariff,
|
||
load_hourly,
|
||
oracle_daily_schedule,
|
||
simulate,
|
||
)
|
||
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.")
|
||
# 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 — scale the entire signed signal so existing-PV exports
|
||
# scale with the household.
|
||
annual_net_demand = base["raw_demand_kw"].sum() * (8766.0 / len(base))
|
||
df["raw_demand_kw"] = base["raw_demand_kw"] * (args.demand / annual_net_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"]
|
||
|
||
# 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
|
||
|
||
has_existing_pv = bool((df["raw_demand_kw"] < 0).any())
|
||
|
||
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=has_existing_pv,
|
||
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 : {'existing PV in meter signal' if has_existing_pv else 'no measurable existing PV'}")
|
||
print(f" Net 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()
|