refactor: drop PV synthesis, model raw P1 net signal directly
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>
This commit is contained in:
parent
e2fb72a0ab
commit
fc90e65271
12 changed files with 296 additions and 476 deletions
|
|
@ -2,10 +2,11 @@
|
|||
"""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.
|
||||
4000 kWh/yr net demand, avg retail €0.25, 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.
|
||||
The simulator works on the raw P1 net-meter trace — whatever PV is on the
|
||||
roof is already netted in. To model a different PV setup, swap the input
|
||||
data, not a CLI flag. Output: data/processed/battery_leaderboard.csv.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -19,7 +20,6 @@ from pluginbattery.sim import (
|
|||
load_hourly,
|
||||
oracle_daily_schedule,
|
||||
simulate,
|
||||
synthesize_pv,
|
||||
)
|
||||
from pluginbattery.store_calc import (
|
||||
Scenario,
|
||||
|
|
@ -36,8 +36,6 @@ def main() -> None:
|
|||
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")
|
||||
|
|
@ -46,15 +44,14 @@ def main() -> None:
|
|||
# ─── 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)
|
||||
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)
|
||||
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
|
||||
# Existing-PV export visible at the meter (any hour where raw_demand_kw < 0).
|
||||
has_existing_pv = bool((df["raw_demand_kw"] < 0).any())
|
||||
|
||||
avg_epex = float(df["epex_eur_per_kwh"].mean())
|
||||
store_params = StoreParams(avg_epex_eur_per_kwh=avg_epex)
|
||||
|
|
@ -80,8 +77,9 @@ def main() -> None:
|
|||
# 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"Scenario: net demand {args.demand:.0f} kWh/yr, avg retail €{args.retail}, "
|
||||
f"existing-PV exports={'yes' if has_existing_pv else 'no'}, "
|
||||
f"saldering={args.saldering}, inflation {args.inflation*100:.1f}%/yr")
|
||||
print(f"Running {len(rows_in)} batteries (caching by (capacity, power))...")
|
||||
print()
|
||||
|
||||
|
|
@ -104,7 +102,7 @@ def main() -> None:
|
|||
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,
|
||||
has_pv=has_existing_pv, has_saldering=args.saldering, dynamic_rate=True,
|
||||
)
|
||||
sq = store_quote(scn, store_params, inflation=args.inflation)
|
||||
|
||||
|
|
|
|||
|
|
@ -48,9 +48,9 @@ def main() -> None:
|
|||
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))
|
||||
annual_demand = base["raw_demand_kw"].sum() * (8766.0 / len(base))
|
||||
df = base.copy()
|
||||
df["demand_kwh"] = base["demand_kwh"] * (args.demand / annual_demand)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Run the 24h-foresight oracle for several battery configs and print a comparison.
|
||||
"""Run the price-arbitrage 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).
|
||||
The simulator works on the raw P1 net-meter trace — whatever PV is on the
|
||||
roof is already netted in. Existing-PV exports show up as negative
|
||||
``raw_demand_kw`` and are absorbed by the battery before crossing the meter.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -18,7 +19,6 @@ from pluginbattery.sim import (
|
|||
load_hourly,
|
||||
oracle_daily_schedule,
|
||||
simulate,
|
||||
synthesize_pv,
|
||||
)
|
||||
|
||||
CONFIGS = [
|
||||
|
|
@ -79,14 +79,6 @@ def payback_years(year1_savings: float, cost: float, inflation: float = 0.0) ->
|
|||
|
||||
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). "
|
||||
|
|
@ -121,11 +113,12 @@ def main() -> None:
|
|||
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"
|
||||
# Existing PV (if any) is already in raw_demand_kw via the meter signal.
|
||||
annual_export = float(-df["raw_demand_kw"].clip(upper=0).sum() * 8766.0 / len(df))
|
||||
if annual_export > 1.0:
|
||||
pv_note = f"{annual_export:.0f} kWh/yr exported at meter (existing PV)"
|
||||
else:
|
||||
pv_note = "no measurable existing PV in the data"
|
||||
|
||||
if args.export_rate is not None:
|
||||
df["export_eur_per_kwh"] = float(args.export_rate)
|
||||
|
|
@ -146,10 +139,10 @@ def main() -> None:
|
|||
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")
|
||||
print(f"Meter : {pv_note}\n")
|
||||
|
||||
# Headline figures with no battery, just for context.
|
||||
g_no = df["demand_kwh"] - df.get("pv_kwh", 0)
|
||||
g_no = df["raw_demand_kw"]
|
||||
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 +
|
||||
|
|
@ -187,7 +180,6 @@ def main() -> None:
|
|||
"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),
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ Examples:
|
|||
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
|
||||
# Fixed retail, no saldering:
|
||||
python scripts/compare_with_store.py --fixed --retail 0.28
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -23,7 +23,6 @@ from pluginbattery.sim import (
|
|||
load_hourly,
|
||||
oracle_daily_schedule,
|
||||
simulate,
|
||||
synthesize_pv,
|
||||
)
|
||||
from pluginbattery.store_calc import (
|
||||
Scenario,
|
||||
|
|
@ -47,10 +46,6 @@ def main() -> None:
|
|||
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()
|
||||
|
|
@ -59,9 +54,10 @@ def main() -> None:
|
|||
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)
|
||||
# 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:
|
||||
|
|
@ -73,16 +69,14 @@ def main() -> None:
|
|||
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
|
||||
|
||||
has_existing_pv = bool((df["raw_demand_kw"] < 0).any())
|
||||
|
||||
bat = Battery(
|
||||
capacity_kwh=args.capacity,
|
||||
max_charge_kw=args.power,
|
||||
|
|
@ -102,7 +96,7 @@ def main() -> None:
|
|||
max_charge_kw=args.power,
|
||||
battery_cost_eur=args.cost,
|
||||
avg_retail_eur_per_kwh=args.retail,
|
||||
has_pv=args.pv_kwp > 0,
|
||||
has_pv=has_existing_pv,
|
||||
has_saldering=args.saldering,
|
||||
dynamic_rate=not args.fixed,
|
||||
)
|
||||
|
|
@ -114,9 +108,8 @@ def main() -> None:
|
|||
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" 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}")
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ meter readings, every 15 min, 2025 calendar year, Europe/Amsterdam local time).
|
|||
|
||||
Output: data/raw/p1_hourly.csv (timestamp UTC, power_w mean over hour). Net
|
||||
of PV exports — `power_w` can be negative when his solar exceeded household
|
||||
demand. The simulator interprets `demand_kwh = power_w / 1000`, and a plug-in
|
||||
battery's discharge cap `max(0, demand - pv)` correctly handles negative
|
||||
demand by forbidding discharge during surplus hours.
|
||||
demand. The simulator interprets `raw_demand_kw = power_w / 1000`, and a
|
||||
plug-in battery's discharge cap `max(0, raw_demand_kw)` correctly forbids
|
||||
discharge during net-export hours (the meter is already flowing the wrong way).
|
||||
|
||||
The simulator's window is 2023-09-01 → 2024-09-01 UTC. Dad's data is the
|
||||
2025 calendar year. We align by (month, day, hour) so seasonal patterns
|
||||
|
|
|
|||
|
|
@ -46,8 +46,8 @@ def main() -> None:
|
|||
# ─── Build the same df the web app sees ───
|
||||
base = apply_nl_tariff(load_hourly("data/raw"))
|
||||
df = base.copy()
|
||||
annual = base["demand_kwh"].sum() * 8766 / len(base)
|
||||
df["demand_kwh"] = base["demand_kwh"] * (args.demand_kwh / annual)
|
||||
annual = base["raw_demand_kw"].sum() * 8766 / len(base)
|
||||
df["raw_demand_kw"] = base["raw_demand_kw"] * (args.demand_kwh / annual)
|
||||
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
|
||||
|
|
@ -62,10 +62,10 @@ def main() -> None:
|
|||
out = out.assign(
|
||||
import_kwh=np.maximum(0.0, g),
|
||||
export_kwh=np.maximum(0.0, -g),
|
||||
no_bat_grid=out["demand_kwh"], # equals the original net since pv=0
|
||||
cost_no_battery_eur=np.where(out["demand_kwh"] > 0,
|
||||
out["demand_kwh"] * out["eur_per_kwh"],
|
||||
out["demand_kwh"] * out["export_eur_per_kwh"]),
|
||||
no_bat_grid=out["raw_demand_kw"], # equals the original net since pv=0
|
||||
cost_no_battery_eur=np.where(out["raw_demand_kw"] > 0,
|
||||
out["raw_demand_kw"] * out["eur_per_kwh"],
|
||||
out["raw_demand_kw"] * out["export_eur_per_kwh"]),
|
||||
cost_with_battery_eur=np.where(g > 0, g * out["eur_per_kwh"],
|
||||
g * out["export_eur_per_kwh"]),
|
||||
)
|
||||
|
|
@ -89,7 +89,7 @@ def main() -> None:
|
|||
print(f" {'hr':>2s} {'price':>5s} {'no-bat meter':>15s} "
|
||||
f"{'charge':>7s} {'disch':>7s} {'SoC':>5s} {'with-bat meter':>16s} {'sav':>6s}")
|
||||
for ts, r in day.iterrows():
|
||||
net_no = r["demand_kwh"]
|
||||
net_no = r["raw_demand_kw"]
|
||||
mark = "↓ exp" if net_no < -0.01 else "↑ imp" if net_no > 0.01 else "·"
|
||||
grid = r["grid_kwh_with_battery"]
|
||||
grid_mark = "↓ exp" if grid < -0.01 else "↑ imp" if grid > 0.01 else "·"
|
||||
|
|
@ -97,8 +97,8 @@ def main() -> None:
|
|||
f"{net_no:+7.2f} {mark:5s} "
|
||||
f"{r['charge_kwh']:>6.2f} {r['discharge_kwh']:>6.2f} "
|
||||
f"{r['soc_kwh']:>4.2f} {grid:+8.2f} {grid_mark:5s} €{r['savings_eur']:+5.2f}")
|
||||
d_imp_no = max(0.0, day['demand_kwh'].clip(lower=0).sum())
|
||||
d_exp_no = -day['demand_kwh'].clip(upper=0).sum()
|
||||
d_imp_no = max(0.0, day['raw_demand_kw'].clip(lower=0).sum())
|
||||
d_exp_no = -day['raw_demand_kw'].clip(upper=0).sum()
|
||||
d_imp_yes = day['import_kwh'].sum()
|
||||
d_exp_yes = day['export_kwh'].sum()
|
||||
d_sav = day['savings_eur'].sum()
|
||||
|
|
@ -113,8 +113,8 @@ def main() -> None:
|
|||
week_savings = win["savings_eur"].sum()
|
||||
week_charge = win["charge_kwh"].sum()
|
||||
week_disch = win["discharge_kwh"].sum()
|
||||
week_surplus = (-win["demand_kwh"].clip(upper=0)).sum()
|
||||
week_imports = win["demand_kwh"].clip(lower=0).sum()
|
||||
week_surplus = (-win["raw_demand_kw"].clip(upper=0)).sum()
|
||||
week_imports = win["raw_demand_kw"].clip(lower=0).sum()
|
||||
|
||||
annual_savings = out["savings_eur"].sum()
|
||||
annualised = week_savings * 365.25 / args.days
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue