pluginbattery/scripts/compare_batteries.py
Michiel Berger fc90e65271 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>
2026-05-08 21:32:59 +02:00

207 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""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.
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
import argparse
import csv
import re
from pathlib import Path
from pluginbattery.sim import (
Battery,
apply_nl_tariff,
load_hourly,
oracle_daily_schedule,
simulate,
)
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(
"--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
# 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)
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"Meter : {pv_note}\n")
# Headline figures with no battery, just for context.
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 +
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,
"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()