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>
126 lines
4.7 KiB
Python
126 lines
4.7 KiB
Python
#!/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["raw_demand_kw"].sum() * (8766.0 / len(base))
|
||
df = base.copy()
|
||
df["raw_demand_kw"] = base["raw_demand_kw"] * (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()
|