Compare commits

..

2 commits

Author SHA1 Message Date
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
Michiel Berger
e2fb72a0ab chore(catalog): add Zendure SolarFlow 2400AC 5.8 kWh plug-in set
5.8 kWh / 2.4 kW / €1759, sourced from solar-outlet.nl.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:32:39 +02:00
13 changed files with 311 additions and 476 deletions

View file

@ -13,5 +13,20 @@
"recommended": false, "recommended": false,
"installation": "plugin", "installation": "plugin",
"image_url": "" "image_url": ""
},
{
"id": -1,
"title": "Zendure SolarFlow 2400AC 5.8 kWh",
"url": "https://www.solar-outlet.nl/zendure-solarflow-2400ac-thuisbatterijset-58-kwh.html",
"brand": "Zendure",
"brand_slug": "zendure",
"capacity": 5.8,
"power_continuous": 2400,
"price": 1759,
"price_per_kwh": 303.3,
"review_score": null,
"recommended": false,
"installation": "plugin",
"image_url": ""
} }
] ]

View file

@ -2,10 +2,11 @@
"""Run every battery in the catalog through both calculators and rank by payback. """Run every battery in the catalog through both calculators and rank by payback.
Default scenario matches the dynamic-mode store-test: 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 The simulator works on the raw P1 net-meter trace whatever PV is on the
with --pv-kwp 3.0). Output: data/processed/battery_leaderboard.csv. 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 from __future__ import annotations
@ -19,7 +20,6 @@ from pluginbattery.sim import (
load_hourly, load_hourly,
oracle_daily_schedule, oracle_daily_schedule,
simulate, simulate,
synthesize_pv,
) )
from pluginbattery.store_calc import ( from pluginbattery.store_calc import (
Scenario, Scenario,
@ -36,8 +36,6 @@ def main() -> None:
p.add_argument("--retail", type=float, default=0.25) p.add_argument("--retail", type=float, default=0.25)
p.add_argument("--demand", type=float, default=4000.0) p.add_argument("--demand", type=float, default=4000.0)
p.add_argument("--eta", type=float, default=0.88) 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("--saldering", action="store_true")
p.add_argument("--inflation", type=float, default=0.03) p.add_argument("--inflation", type=float, default=0.03)
p.add_argument("--top", type=int, default=20, help="How many rows to print") 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 ──────────────────────── # ─── Load and prep the simulation data once ────────────────────────
base = load_hourly("data/raw"); base = apply_nl_tariff(base) base = load_hourly("data/raw"); base = apply_nl_tariff(base)
df = base.copy() df = base.copy()
annual_demand = base["demand_kwh"].sum() * (8766.0 / len(base)) annual_net_demand = base["raw_demand_kw"].sum() * (8766.0 / len(base))
df["demand_kwh"] = base["demand_kwh"] * (args.demand / annual_demand) df["raw_demand_kw"] = base["raw_demand_kw"] * (args.demand / annual_net_demand)
scale = args.retail / base["eur_per_kwh"].mean() scale = args.retail / base["eur_per_kwh"].mean()
df["eur_per_kwh"] = base["eur_per_kwh"] * scale df["eur_per_kwh"] = base["eur_per_kwh"] * scale
df["epex_eur_per_kwh"] = base["epex_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 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()) avg_epex = float(df["epex_eur_per_kwh"].mean())
store_params = StoreParams(avg_epex_eur_per_kwh=avg_epex) 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. # Cache LP results by (capacity, power) — many batteries share identical specs.
lp_cache: dict[tuple[float, float], float] = {} lp_cache: dict[tuple[float, float], float] = {}
print(f"Scenario: demand {args.demand:.0f} kWh/yr, avg retail €{args.retail}, " print(f"Scenario: net 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") 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(f"Running {len(rows_in)} batteries (caching by (capacity, power))...")
print() print()
@ -104,7 +102,7 @@ def main() -> None:
scn = Scenario( scn = Scenario(
capacity_kwh=r["capacity_kwh"], max_charge_kw=r["power_kw"], capacity_kwh=r["capacity_kwh"], max_charge_kw=r["power_kw"],
battery_cost_eur=r["price_eur"], avg_retail_eur_per_kwh=args.retail, 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) sq = store_quote(scn, store_params, inflation=args.inflation)

View file

@ -48,9 +48,9 @@ def main() -> None:
base = load_hourly("data/raw"); base = apply_nl_tariff(base) base = load_hourly("data/raw"); base = apply_nl_tariff(base)
# Scale to the test scenario. # 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 = 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() scale = args.retail / base["eur_per_kwh"].mean()
df["eur_per_kwh"] = base["eur_per_kwh"] * scale df["eur_per_kwh"] = base["eur_per_kwh"] * scale
df["epex_eur_per_kwh"] = base["epex_eur_per_kwh"] * scale df["epex_eur_per_kwh"] = base["epex_eur_per_kwh"] * scale

View file

@ -1,9 +1,10 @@
#!/usr/bin/env python3 #!/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. 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 The simulator works on the raw P1 net-meter trace whatever PV is on the
saldering as full export credit at consumer price (NL pre-2027). 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 from __future__ import annotations
@ -18,7 +19,6 @@ from pluginbattery.sim import (
load_hourly, load_hourly,
oracle_daily_schedule, oracle_daily_schedule,
simulate, simulate,
synthesize_pv,
) )
CONFIGS = [ CONFIGS = [
@ -79,14 +79,6 @@ def payback_years(year1_savings: float, cost: float, inflation: float = 0.0) ->
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description=__doc__) 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( parser.add_argument(
"--saldering", choices=["none", "full"], default="none", "--saldering", choices=["none", "full"], default="none",
help="'full' = export at consumer price (NL pre-2027). " help="'full' = export at consumer price (NL pre-2027). "
@ -121,11 +113,12 @@ def main() -> None:
elif args.price_mult != 1.0: elif args.price_mult != 1.0:
df["eur_per_kwh"] = df["eur_per_kwh"] * args.price_mult df["eur_per_kwh"] = df["eur_per_kwh"] * args.price_mult
pv_note = "no solar" # Existing PV (if any) is already in raw_demand_kw via the meter signal.
if args.pv_kwp > 0: annual_export = float(-df["raw_demand_kw"].clip(upper=0).sum() * 8766.0 / len(df))
df = synthesize_pv(df, kwp=args.pv_kwp, target_kwh_per_kwp_per_year=args.pv_target) if annual_export > 1.0:
pv_kwh_year = float(df["pv_kwh"].sum() * 8766.0 / len(df)) pv_note = f"{annual_export:.0f} kWh/yr exported at meter (existing PV)"
pv_note = f"{args.pv_kwp:.1f} kWp PV, calibrated to {pv_kwh_year:.0f} kWh/yr" else:
pv_note = "no measurable existing PV in the data"
if args.export_rate is not None: if args.export_rate is not None:
df["export_eur_per_kwh"] = float(args.export_rate) df["export_eur_per_kwh"] = float(args.export_rate)
@ -146,10 +139,10 @@ def main() -> None:
print(f"Tariff: import = {price_note}; {sald_note}") print(f"Tariff: import = {price_note}; {sald_note}")
if args.price_inflation > 0: if args.price_inflation > 0:
print(f"Payback uses {args.price_inflation*100:.1f}%/yr energy-price inflation") 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. # 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"] imp_p = df["eur_per_kwh"]
exp_p = df.get("export_eur_per_kwh", 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 + 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_charge_kw": battery.max_charge_kw,
"max_discharge_kw": battery.max_discharge_kw, "max_discharge_kw": battery.max_discharge_kw,
"price_eur": price, "price_eur": price,
"pv_kwp": args.pv_kwp,
"saldering": args.saldering, "saldering": args.saldering,
"annual_savings_eur": round(savings, 2), "annual_savings_eur": round(savings, 2),
"kwh_shifted": round(kwh_shifted, 1), "kwh_shifted": round(kwh_shifted, 1),

View file

@ -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 0.8 --cost 1339 --retail 0.25
python scripts/compare_with_store.py --capacity 5.12 --power 2.5 --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: # Fixed retail, no saldering:
python scripts/compare_with_store.py --pv-kwp 3.0 --fixed --retail 0.28 python scripts/compare_with_store.py --fixed --retail 0.28
""" """
from __future__ import annotations from __future__ import annotations
@ -23,7 +23,6 @@ from pluginbattery.sim import (
load_hourly, load_hourly,
oracle_daily_schedule, oracle_daily_schedule,
simulate, simulate,
synthesize_pv,
) )
from pluginbattery.store_calc import ( from pluginbattery.store_calc import (
Scenario, Scenario,
@ -47,10 +46,6 @@ def main() -> None:
help="Fixed-rate mode (no time-of-day variation; kills arbitrage)") help="Fixed-rate mode (no time-of-day variation; kills arbitrage)")
p.add_argument("--saldering", action="store_true", p.add_argument("--saldering", action="store_true",
help="Full saldering on (export = retail). Default: no saldering.") 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 # Inflation
p.add_argument("--inflation", type=float, default=0.03) p.add_argument("--inflation", type=float, default=0.03)
args = p.parse_args() args = p.parse_args()
@ -59,9 +54,10 @@ def main() -> None:
base = load_hourly("data/raw"); base = apply_nl_tariff(base) base = load_hourly("data/raw"); base = apply_nl_tariff(base)
df = base.copy() df = base.copy()
# Demand scaling. # Demand scaling — scale the entire signed signal so existing-PV exports
annual_demand = base["demand_kwh"].sum() * (8766.0 / len(base)) # scale with the household.
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)
# Retail price. # Retail price.
if args.fixed: if args.fixed:
@ -73,16 +69,14 @@ def main() -> None:
df["epex_eur_per_kwh"] = base["epex_eur_per_kwh"] * scale df["epex_eur_per_kwh"] = base["epex_eur_per_kwh"] * scale
epex_in_use = df["epex_eur_per_kwh"] 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. # Saldering.
if args.saldering: if args.saldering:
df["export_eur_per_kwh"] = df["eur_per_kwh"] df["export_eur_per_kwh"] = df["eur_per_kwh"]
else: else:
df["export_eur_per_kwh"] = 0.0 # post-saldering default in this script 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( bat = Battery(
capacity_kwh=args.capacity, capacity_kwh=args.capacity,
max_charge_kw=args.power, max_charge_kw=args.power,
@ -102,7 +96,7 @@ def main() -> None:
max_charge_kw=args.power, max_charge_kw=args.power,
battery_cost_eur=args.cost, battery_cost_eur=args.cost,
avg_retail_eur_per_kwh=args.retail, avg_retail_eur_per_kwh=args.retail,
has_pv=args.pv_kwp > 0, has_pv=has_existing_pv,
has_saldering=args.saldering, has_saldering=args.saldering,
dynamic_rate=not args.fixed, dynamic_rate=not args.fixed,
) )
@ -114,9 +108,8 @@ def main() -> None:
print(f" Tariff : avg retail €{args.retail}, " print(f" Tariff : avg retail €{args.retail}, "
f"{'FIXED' if args.fixed else 'DYNAMIC'} rate, " f"{'FIXED' if args.fixed else 'DYNAMIC'} rate, "
f"{'WITH' if args.saldering else 'NO'} saldering") f"{'WITH' if args.saldering else 'NO'} saldering")
print(f" PV : {args.pv_kwp} kWp" print(f" PV : {'existing PV in meter signal' if has_existing_pv else 'no measurable existing PV'}")
f"{' @ ' + str(args.pv_yield) + ' kWh/kWp/yr' if args.pv_kwp else ''}") print(f" Net demand : {args.demand:.0f} kWh/yr")
print(f" Demand : {args.demand:.0f} kWh/yr")
print(f" Inflation : {args.inflation*100:.1f}%/yr") print(f" Inflation : {args.inflation*100:.1f}%/yr")
print() print()
print(f"{'':22s} {'year-1 €':>10s} {'payback':>9s}") print(f"{'':22s} {'year-1 €':>10s} {'payback':>9s}")

View file

@ -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 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 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 demand. The simulator interprets `raw_demand_kw = power_w / 1000`, and a
battery's discharge cap `max(0, demand - pv)` correctly handles negative plug-in battery's discharge cap `max(0, raw_demand_kw)` correctly forbids
demand by forbidding discharge during surplus hours. 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 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 2025 calendar year. We align by (month, day, hour) so seasonal patterns

View file

@ -46,8 +46,8 @@ def main() -> None:
# ─── Build the same df the web app sees ─── # ─── Build the same df the web app sees ───
base = apply_nl_tariff(load_hourly("data/raw")) base = apply_nl_tariff(load_hourly("data/raw"))
df = base.copy() df = base.copy()
annual = base["demand_kwh"].sum() * 8766 / len(base) annual = base["raw_demand_kw"].sum() * 8766 / len(base)
df["demand_kwh"] = base["demand_kwh"] * (args.demand_kwh / annual) df["raw_demand_kw"] = base["raw_demand_kw"] * (args.demand_kwh / annual)
scale = args.retail / base["eur_per_kwh"].mean() scale = args.retail / base["eur_per_kwh"].mean()
df["eur_per_kwh"] = base["eur_per_kwh"] * scale df["eur_per_kwh"] = base["eur_per_kwh"] * scale
df["epex_eur_per_kwh"] = base["epex_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( out = out.assign(
import_kwh=np.maximum(0.0, g), import_kwh=np.maximum(0.0, g),
export_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 no_bat_grid=out["raw_demand_kw"], # equals the original net since pv=0
cost_no_battery_eur=np.where(out["demand_kwh"] > 0, cost_no_battery_eur=np.where(out["raw_demand_kw"] > 0,
out["demand_kwh"] * out["eur_per_kwh"], out["raw_demand_kw"] * out["eur_per_kwh"],
out["demand_kwh"] * out["export_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"], cost_with_battery_eur=np.where(g > 0, g * out["eur_per_kwh"],
g * out["export_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} " print(f" {'hr':>2s} {'price':>5s} {'no-bat meter':>15s} "
f"{'charge':>7s} {'disch':>7s} {'SoC':>5s} {'with-bat meter':>16s} {'sav':>6s}") f"{'charge':>7s} {'disch':>7s} {'SoC':>5s} {'with-bat meter':>16s} {'sav':>6s}")
for ts, r in day.iterrows(): 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 "·" mark = "↓ exp" if net_no < -0.01 else "↑ imp" if net_no > 0.01 else "·"
grid = r["grid_kwh_with_battery"] grid = r["grid_kwh_with_battery"]
grid_mark = "↓ exp" if grid < -0.01 else "↑ imp" if grid > 0.01 else "·" 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"{net_no:+7.2f} {mark:5s} "
f"{r['charge_kwh']:>6.2f} {r['discharge_kwh']:>6.2f} " 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}") 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_imp_no = max(0.0, day['raw_demand_kw'].clip(lower=0).sum())
d_exp_no = -day['demand_kwh'].clip(upper=0).sum() d_exp_no = -day['raw_demand_kw'].clip(upper=0).sum()
d_imp_yes = day['import_kwh'].sum() d_imp_yes = day['import_kwh'].sum()
d_exp_yes = day['export_kwh'].sum() d_exp_yes = day['export_kwh'].sum()
d_sav = day['savings_eur'].sum() d_sav = day['savings_eur'].sum()
@ -113,8 +113,8 @@ def main() -> None:
week_savings = win["savings_eur"].sum() week_savings = win["savings_eur"].sum()
week_charge = win["charge_kwh"].sum() week_charge = win["charge_kwh"].sum()
week_disch = win["discharge_kwh"].sum() week_disch = win["discharge_kwh"].sum()
week_surplus = (-win["demand_kwh"].clip(upper=0)).sum() week_surplus = (-win["raw_demand_kw"].clip(upper=0)).sum()
week_imports = win["demand_kwh"].clip(lower=0).sum() week_imports = win["raw_demand_kw"].clip(lower=0).sum()
annual_savings = out["savings_eur"].sum() annual_savings = out["savings_eur"].sum()
annualised = week_savings * 365.25 / args.days annualised = week_savings * 365.25 / args.days

View file

@ -1,13 +1,22 @@
"""Hourly home-battery simulator. """Hourly home-battery simulator.
The simulator operates on a single signed signal: ``raw_demand_kw``, the P1
net-meter reading. Positive = importing, negative = exporting (e.g. from
existing rooftop PV already netted at the meter). We do **not** model solar
as a separate signal whatever the meter shows is the input. To ask "what
if the PV were different" you need actual measurements of that situation;
synthesizing PV from a remote irradiance sensor was tried and didn't survive
contact with reality (different roof, different orientation, different cloud
cover).
Three pieces: Three pieces:
- load_hourly(): CSVs in -> single hourly DataFrame, missing-hour rows dropped. - load_hourly(): CSVs in -> single hourly DataFrame, missing-hour rows dropped.
- simulate(df, bat, sched): walks SoC hour by hour, clamps to physical limits, - simulate(df, bat, sched): walks SoC hour by hour, clamps to physical limits,
returns trajectory + grid flows + costs. returns trajectory + grid flows + costs.
- oracle_daily_schedule(): solves a 24h LP per UTC day for perfect-foresight - oracle_daily_schedule(): greedy self-consumption (Pass 1) + perfect-foresight
within-day arbitrage. No cross-day shifting. per-day price arbitrage (Pass 2).
Anything that decides what to do (oracle, rules, ML) returns a (n, 2) schedule of Anything that decides what to do returns a (n, 2) schedule of
[charge_kwh, discharge_kwh]; simulate() is the single source of truth for state. [charge_kwh, discharge_kwh]; simulate() is the single source of truth for state.
""" """
from __future__ import annotations from __future__ import annotations
@ -17,7 +26,6 @@ from pathlib import Path
import numpy as np import numpy as np
import pandas as pd import pandas as pd
from scipy.optimize import linprog
@dataclass(frozen=True) @dataclass(frozen=True)
@ -40,39 +48,6 @@ ECOFLOW_STREAM_AC = Battery(
) )
def synthesize_pv(
df: pd.DataFrame,
kwp: float,
target_kwh_per_kwp_per_year: float = 900.0,
irradiance_cap_w_m2: float = 1000.0,
) -> pd.DataFrame:
"""Synthesize hourly PV output (kWh) from horizontal irradiance.
Output shape follows the irradiance signal (so timing tracks real local
weather). The total is calibrated to `target_kwh_per_kwp_per_year`
900 kWh/kWp/year is the NL norm for a south-facing 35° roof. This
sidesteps the question "what's the right system factor?" and absorbs any
sensor bias automatically: if the irradiance reads 22% low, the implied
factor goes up to compensate.
Outlier handling: irradiance clipped to [0, irradiance_cap_w_m2].
"""
out = df.copy()
irr = out["irradiance_w_m2"].clip(lower=0.0, upper=irradiance_cap_w_m2)
# Annualised horizontal irradiance density for *this* dataset, kWh/m²/year.
hours = len(df)
annual_irradiance_kwh_m2 = (irr.sum() / 1000.0) * (8766.0 / hours)
if annual_irradiance_kwh_m2 <= 0:
raise ValueError("Irradiance data is all zero or negative.")
system_factor = target_kwh_per_kwp_per_year / annual_irradiance_kwh_m2
out["pv_kwh"] = irr / 1000.0 * kwp * system_factor
out.attrs["pv_system_factor"] = system_factor
out.attrs["pv_annual_kwh"] = float(out["pv_kwh"].sum() * 8766.0 / hours)
return out
def apply_nl_tariff( def apply_nl_tariff(
df: pd.DataFrame, df: pd.DataFrame,
vat: float = 0.21, vat: float = 0.21,
@ -95,17 +70,16 @@ def apply_nl_tariff(
def load_hourly(data_dir: Path | str = "data/raw") -> pd.DataFrame: def load_hourly(data_dir: Path | str = "data/raw") -> pd.DataFrame:
"""Read prices/P1/solar CSVs into one hourly DataFrame indexed by UTC timestamp. """Read prices + P1 CSVs into one hourly DataFrame indexed by UTC timestamp.
Drops every hour where any of the three signals is missing (inner join + Drops every hour where either signal is missing (inner join + dropna).
dropna). Adds a derived `demand_kwh` column = power_w / 1000 (since power_w Adds ``raw_demand_kw = power_w / 1000`` the signed P1 net meter
is the hour mean and 1 W * 1 h = 1 Wh). reading. Negative = exporting, positive = importing.
""" """
data_dir = Path(data_dir) data_dir = Path(data_dir)
files = { files = {
"eur_per_kwh": "prices_hourly.csv", "eur_per_kwh": "prices_hourly.csv",
"power_w": "p1_hourly.csv", "power_w": "p1_hourly.csv",
"irradiance_w_m2": "solar_hourly.csv",
} }
parts = [] parts = []
for col, fname in files.items(): for col, fname in files.items():
@ -117,7 +91,7 @@ def load_hourly(data_dir: Path | str = "data/raw") -> pd.DataFrame:
sub.index = sub.index.tz_convert("UTC") sub.index = sub.index.tz_convert("UTC")
parts.append(sub[[col]]) parts.append(sub[[col]])
df = pd.concat(parts, axis=1, join="inner").dropna().sort_index() df = pd.concat(parts, axis=1, join="inner").dropna().sort_index()
df["demand_kwh"] = df["power_w"] / 1000.0 df["raw_demand_kw"] = df["power_w"] / 1000.0
return df return df
@ -129,13 +103,13 @@ def simulate(df: pd.DataFrame, battery: Battery, schedule: np.ndarray) -> pd.Dat
authoritative state engine, the schedule is only a *request*. authoritative state engine, the schedule is only a *request*.
Column conventions read by this function: Column conventions read by this function:
- eur_per_kwh: import price (always required) - raw_demand_kw: signed meter reading (always required). +import, export.
- pv_kwh: optional, hourly PV production - eur_per_kwh: import price (always required).
- export_eur_per_kwh: optional, export credit price. Defaults to - export_eur_per_kwh: optional, export credit price. Defaults to
eur_per_kwh (full saldering) if missing. eur_per_kwh (full saldering) if missing.
Per hour: `g = demand pv + charge discharge`. Cost uses the import Per hour: ``g = raw_demand_kw + charge discharge``. Cost uses the
price when g > 0, the export price when g < 0. import price when g > 0, the export price when g < 0.
""" """
n = len(df) n = len(df)
if schedule.shape != (n, 2): if schedule.shape != (n, 2):
@ -151,8 +125,7 @@ def simulate(df: pd.DataFrame, battery: Battery, schedule: np.ndarray) -> pd.Dat
d_arr = np.empty(n) d_arr = np.empty(n)
soc_arr = np.empty(n) soc_arr = np.empty(n)
demand = df["demand_kwh"].to_numpy() raw = df["raw_demand_kw"].to_numpy()
pv = df["pv_kwh"].to_numpy() if "pv_kwh" in df.columns else np.zeros(n)
requested_c = schedule[:, 0] requested_c = schedule[:, 0]
requested_d = schedule[:, 1] requested_d = schedule[:, 1]
@ -166,8 +139,8 @@ def simulate(df: pd.DataFrame, battery: Battery, schedule: np.ndarray) -> pd.Dat
d = max(0.0, min(requested_d[t], pd_max, avail_kwh_grid)) d = max(0.0, min(requested_d[t], pd_max, avail_kwh_grid))
if not battery.allows_export: if not battery.allows_export:
# Plug-in: cannot push current backwards through the meter. # Plug-in: cannot push current backwards through the meter.
# Discharge can't exceed the *net* demand after solar. # Discharge can't take the net flow below zero.
d = min(d, max(0.0, demand[t] - pv[t])) d = min(d, max(0.0, raw[t]))
soc = soc + c * eta - d / eta soc = soc + c * eta - d / eta
# Numerical guard rail for floating point drift. # Numerical guard rail for floating point drift.
@ -178,8 +151,6 @@ def simulate(df: pd.DataFrame, battery: Battery, schedule: np.ndarray) -> pd.Dat
soc_arr[t] = soc soc_arr[t] = soc
out = df.copy() out = df.copy()
if "pv_kwh" not in out.columns:
out["pv_kwh"] = 0.0
out["charge_kwh"] = c_arr out["charge_kwh"] = c_arr
out["discharge_kwh"] = d_arr out["discharge_kwh"] = d_arr
out["soc_kwh"] = soc_arr out["soc_kwh"] = soc_arr
@ -190,7 +161,7 @@ def simulate(df: pd.DataFrame, battery: Battery, schedule: np.ndarray) -> pd.Dat
else: else:
export_price = import_price # default = full saldering export_price = import_price # default = full saldering
g_no = (out["demand_kwh"] - out["pv_kwh"]).to_numpy() g_no = raw
g_yes = g_no + c_arr - d_arr g_yes = g_no + c_arr - d_arr
out["grid_kwh_no_battery"] = g_no out["grid_kwh_no_battery"] = g_no
@ -201,32 +172,12 @@ def simulate(df: pd.DataFrame, battery: Battery, schedule: np.ndarray) -> pd.Dat
return out return out
def oracle_daily_schedule(df: pd.DataFrame, battery: Battery) -> np.ndarray: def _pass1_reactive(df: pd.DataFrame, battery: Battery) -> tuple[np.ndarray, np.ndarray]:
"""Two-pass dispatch: greedy self-consumption + daily grid arbitrage. """Greedy self-consumption: charge from surplus, discharge to cover demand.
This matches what a modern plug-in battery with smart-charging firmware Pure function of raw_demand_kw. No foresight exactly the "default
(Tibber controllers, Marstek 'Smart Charging' mode, EcoFlow with dynamic mode" behaviour of every plug-in battery firmware out there.
tariff integration) actually does in practice and it always produces Returns (schedule, soc) where soc has length n+1 (soc[0] = initial).
visually intuitive schedules without LP indifference artefacts.
Pass 1 (greedy self-consumption):
For each hour in order, charge from any meter export, discharge to
cover any meter import. Exactly the "default mode" behaviour.
Pass 2 (per-UTC-day arbitrage on residual capacity):
For each day, repeatedly find the most profitable
cheap-hour expensive-hour pair, considering:
- Both charge and discharge already have residual room (power
and SoC headroom).
- Discharge fits within demand at the expensive hour (plug-in
cannot push to grid; allows_export=True relaxes this).
- Profit per shifted kWh = η · price[discharge] price[charge] / η
must be positive.
Execute the most profitable, update SoC trajectory, repeat. Stops
when no profitable cycle remains for that day.
Pass 1 is order-of-day dependent (real firmware acts moment-by-moment),
so it always runs first. Pass 2 sees what's left.
""" """
cap = battery.capacity_kwh cap = battery.capacity_kwh
pc_max = battery.max_charge_kw pc_max = battery.max_charge_kw
@ -234,38 +185,50 @@ def oracle_daily_schedule(df: pd.DataFrame, battery: Battery) -> np.ndarray:
eta = battery.round_trip_eff ** 0.5 eta = battery.round_trip_eff ** 0.5
n = len(df) n = len(df)
demand = df["demand_kwh"].to_numpy() raw = df["raw_demand_kw"].to_numpy()
pv = df["pv_kwh"].to_numpy() if "pv_kwh" in df.columns else np.zeros(n)
net = demand - pv
prices = df["eur_per_kwh"].to_numpy()
schedule = np.zeros((n, 2)) schedule = np.zeros((n, 2))
soc = np.zeros(n + 1) soc = np.zeros(n + 1)
soc[0] = battery.initial_soc_kwh soc[0] = battery.initial_soc_kwh
# ── Pass 1: greedy self-consumption ─────────────────────────────────
for t in range(n): for t in range(n):
if net[t] < -1e-9: # exporting if raw[t] < -1e-9: # exporting (surplus from existing PV)
surplus = -net[t] surplus = -raw[t]
room = (cap - soc[t]) / eta if eta > 0 else 0.0 room = (cap - soc[t]) / eta if eta > 0 else 0.0
c = max(0.0, min(pc_max, surplus, room)) c = max(0.0, min(pc_max, surplus, room))
schedule[t, 0] = c schedule[t, 0] = c
soc[t + 1] = min(cap, soc[t] + c * eta) soc[t + 1] = min(cap, soc[t] + c * eta)
elif net[t] > 1e-9: # importing elif raw[t] > 1e-9: # importing
avail = soc[t] * eta avail = soc[t] * eta
cap_d = pd_max if battery.allows_export else net[t] cap_d = pd_max if battery.allows_export else raw[t]
d = max(0.0, min(pd_max, avail, cap_d)) d = max(0.0, min(pd_max, avail, cap_d))
schedule[t, 1] = d schedule[t, 1] = d
soc[t + 1] = max(0.0, soc[t] - d / eta) soc[t + 1] = max(0.0, soc[t] - d / eta)
else: else:
soc[t + 1] = soc[t] soc[t + 1] = soc[t]
return schedule, soc
# ── Pass 2: per-day arbitrage on residual capacity ──────────────────
def _pass2_arbitrage(df: pd.DataFrame, battery: Battery,
schedule: np.ndarray, soc: np.ndarray) -> None:
"""Per-UTC-day arbitrage layered on top of (schedule, soc). Mutates in place.
For each day, repeatedly find the most profitable (ci, di) pair where
profit = η·price[di] price[ci]/η > 0 and both endpoints have power +
SoC headroom. Execute, update soc trajectory, repeat. Stops when no
profitable cycle remains.
"""
cap = battery.capacity_kwh
pc_max = battery.max_charge_kw
pd_max = battery.max_discharge_kw
eta = battery.round_trip_eff ** 0.5
if eta <= 0: if eta <= 0:
return schedule return
inv_eta = 1.0 / eta inv_eta = 1.0 / eta
# Index hours by UTC date (timeline is short enough that this is cheap). raw = df["raw_demand_kw"].to_numpy()
prices = df["eur_per_kwh"].to_numpy()
by_date: dict = {} by_date: dict = {}
for i, ts in enumerate(df.index): for i, ts in enumerate(df.index):
by_date.setdefault(ts.date(), []).append(i) by_date.setdefault(ts.date(), []).append(i)
@ -279,8 +242,6 @@ def oracle_daily_schedule(df: pd.DataFrame, battery: Battery) -> np.ndarray:
room_c = pc_max - schedule[ci, 0] room_c = pc_max - schedule[ci, 0]
if room_c < 1e-9: if room_c < 1e-9:
continue continue
# SoC headroom across (ci, end-of-day]: how much can we add
# to soc[ci+1..last+1] without exceeding cap?
tail = soc[ci + 1 : last + 2] tail = soc[ci + 1 : last + 2]
hr = (cap - tail.max()) / eta if tail.size else 0.0 hr = (cap - tail.max()) / eta if tail.size else 0.0
if hr < 1e-9: if hr < 1e-9:
@ -291,25 +252,16 @@ def oracle_daily_schedule(df: pd.DataFrame, battery: Battery) -> np.ndarray:
if room_d < 1e-9: if room_d < 1e-9:
continue continue
if not battery.allows_export: if not battery.allows_export:
# Discharge cannot exceed demand at di. # Plug-in: extra discharge limited by the post-Pass-1 import demand.
room_d = min(room_d, net[di] - schedule[di, 1]) room_d = min(room_d, raw[di] - schedule[di, 1])
if room_d < 1e-9: if room_d < 1e-9:
continue continue
# SoC must remain ≥ 0 across (ci, di]; new schedule has
# +c at ci shifting trajectory up, then -d at di pulling
# it down. Trajectory between ci+1..di stays elevated by
# +x·η; minimum allowed kWh limited by min SoC in that
# range plus future-discharge availability.
profit = eta * prices[di] - prices[ci] * inv_eta profit = eta * prices[di] - prices[ci] * inv_eta
if profit <= 1e-6: if profit <= 1e-6:
continue continue
# Max charge in AC kWh: limited by power, capacity headroom,
# discharge-side capacity.
max_kwh = min(room_c, hr, room_d * inv_eta) max_kwh = min(room_c, hr, room_d * inv_eta)
if max_kwh < 1e-6: if max_kwh < 1e-6:
continue continue
if best is None or profit > best[2]: if best is None or profit > best[2]:
best = (ci, di, profit, max_kwh) best = (ci, di, profit, max_kwh)
if best is None: if best is None:
@ -318,130 +270,20 @@ def oracle_daily_schedule(df: pd.DataFrame, battery: Battery) -> np.ndarray:
ci, di, _, kwh = best ci, di, _, kwh = best
schedule[ci, 0] += kwh schedule[ci, 0] += kwh
schedule[di, 1] += kwh * eta schedule[di, 1] += kwh * eta
# Trajectory: SoC between ci+1 and di stays elevated by kwh*eta.
# SoC at di+1 onwards returns to original (+kwh*eta) - (kwh*eta) = unchanged).
for t in range(ci, di): for t in range(ci, di):
soc[t + 1] += kwh * eta soc[t + 1] += kwh * eta
# Beyond di, soc[di+1] = previous - d/eta = previous - kwh # soc[di+1] onwards: +kwh*eta from charge cancels with -kwh*eta
# We added kwh*eta at soc[di], then removed kwh*eta on the # from discharge → unchanged.
# discharge so soc[di+1] should already match — but we updated
# the loop with soc trajectory; need to ensure di+1 onward stays
# consistent.
# Easier: walk soc again from di onward to verify consistency.
# Since both sides cancel, soc[di+1..end] should be unchanged.
return schedule
def _oracle_daily_schedule_legacy(df: pd.DataFrame, battery: Battery) -> np.ndarray:
"""Legacy per-day LP (daily-myopic). Kept for reference / regression
testing only the public oracle_daily_schedule() now solves the whole
window in one go to avoid SoC-resets-to-zero-overnight artefacts."""
cap = battery.capacity_kwh
pc_max = battery.max_charge_kw
pd_max = battery.max_discharge_kw
eta = battery.round_trip_eff ** 0.5
n = len(df)
schedule = np.zeros((n, 2))
soc = battery.initial_soc_kwh
# Position lookup so we can fill the global schedule from each day's solution.
pos = {ts: i for i, ts in enumerate(df.index)}
has_pv = "pv_kwh" in df.columns
has_export_price = "export_eur_per_kwh" in df.columns
for _, day_df in df.groupby(df.index.date, sort=True):
m = len(day_df)
import_price = day_df["eur_per_kwh"].to_numpy()
export_price = (
day_df["export_eur_per_kwh"].to_numpy() if has_export_price else import_price
)
demand = day_df["demand_kwh"].to_numpy()
pv = day_df["pv_kwh"].to_numpy() if has_pv else np.zeros(m)
# Variables: x = [c_0..c_{m-1}, d_0..d_{m-1}, gI_0..gI_{m-1}, gE_0..gE_{m-1}]
# c = charge from bus to battery (kWh AC)
# d = discharge from battery to bus (kWh AC)
# gI = grid import to bus (kWh, ≥ 0)
# gE = grid export from bus (kWh, ≥ 0)
# Energy balance per hour: gI - gE = demand - pv + c - d
# → gI - gE - c + d = demand - pv
# Cost = sum(import_price * gI - export_price * gE).
# When import_price > export_price, the LP automatically picks
# min(gI, gE) = 0 because keeping both > 0 costs (imp - exp) > 0.
#
# Tiny tie-breakers: when several surplus hours are equally good for
# charging (true whenever export_price is constant and we're capturing
# surplus), the simplex would otherwise pick an arbitrary subset.
# Adding a microscopic penalty proportional to hour-index makes early
# charging strictly cheaper than late charging by a fraction of a
# cent — so the battery visibly fills as soon as surplus appears.
# Symmetric for discharge: prefer late hours (when prices are
# typically highest anyway, but ties happen during evening plateaus).
EPS = 1e-6
hr = np.arange(m)
f = np.concatenate([
EPS * hr, # c: prefer earlier charging (smaller h ⇒ less cost)
EPS * (m - 1 - hr), # d: prefer later discharging
import_price, # pay for imports
-export_price, # earn export credit
])
# Variable bounds.
c_bounds = [(0.0, pc_max)] * m
if battery.allows_export:
d_bounds = [(0.0, pd_max)] * m
else:
# Plug-in: discharge ≤ net demand after solar (battery cannot push current backwards).
d_bounds = [
(0.0, min(pd_max, max(0.0, demand[t] - pv[t]))) for t in range(m)
]
# Physical caps on grid flow per hour. Without these the LP can find
# zero-cost cycles when export_price ≈ 0 (charge from cheap surplus,
# discharge into cheap export — both sides zero, so any volume is "free").
# gI: practical max draw is demand + max charge; cap generously at 100 kWh/h.
# gE: bounded by *natural* export capacity = max(0, pv demand). This
# also handles the case where the demand series is already net of an
# external PV (so demand can be negative even when pv = 0).
gi_bounds = [(0.0, 100.0)] * m
natural_export = np.maximum(0.0, pv - demand)
if battery.allows_export:
ge_bounds = [(0.0, max(1e-9, natural_export[t] + pd_max)) for t in range(m)]
else:
ge_bounds = [(0.0, max(1e-9, natural_export[t])) for t in range(m)]
bounds = c_bounds + d_bounds + gi_bounds + ge_bounds
# Energy balance equality (m rows): -c[t] + d[t] + gI[t] - gE[t] = demand[t] - pv[t]
I = np.eye(m)
A_eq = np.hstack([-I, I, I, -I])
b_eq = demand - pv
# SoC inequality (2m rows). SoC(t) = soc0 + eta*cumsum(c)[t] - cumsum(d)[t]/eta.
# Apply only to c, d columns; gI, gE are zero in these rows.
L = np.tril(np.ones((m, m)))
Z = np.zeros((m, m))
A_ub = np.vstack([
np.hstack([-eta * L, (1.0 / eta) * L, Z, Z]), # -SoC ≤ soc0 ⇒ SoC ≥ 0
np.hstack([ eta * L, -(1.0 / eta) * L, Z, Z]), # SoC ≤ cap - soc0 + soc0
])
b_ub = np.concatenate([np.full(m, soc), np.full(m, cap - soc)])
result = linprog(
f, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq=b_eq, bounds=bounds, method="highs"
)
if not result.success:
raise RuntimeError(f"LP failed for {day_df.index[0].date()}: {result.message}")
c_day = result.x[:m]
d_day = result.x[m : 2 * m]
for t, ts in enumerate(day_df.index):
schedule[pos[ts], 0] = c_day[t]
schedule[pos[ts], 1] = d_day[t]
soc = soc + c_day.sum() * eta - d_day.sum() / eta
soc = max(0.0, min(cap, soc))
def oracle_daily_schedule(df: pd.DataFrame, battery: Battery) -> np.ndarray:
"""Greedy self-consumption (Pass 1) + perfect-foresight per-day arbitrage (Pass 2).
The ceiling for what a controller could achieve given a day-ahead price
series. Cross-day shifting is intentionally not allowed (the next day's
SoC starts wherever the previous day left it, but no within-day
arbitrage looks across the midnight boundary).
"""
schedule, soc = _pass1_reactive(df, battery)
_pass2_arbitrage(df, battery, schedule, soc)
return schedule return schedule

View file

@ -56,8 +56,6 @@ function readScenario() {
return { return {
demand_kwh: parseFloat(fd.get("demand_kwh")), demand_kwh: parseFloat(fd.get("demand_kwh")),
retail: parseFloat(fd.get("retail")), retail: parseFloat(fd.get("retail")),
pv_kwp: parseFloat(fd.get("pv_kwp")),
pv_yield: 875.0,
fixed_rate: fd.get("fixed_rate") === "true", fixed_rate: fd.get("fixed_rate") === "true",
saldering: fd.get("saldering") === "true", saldering: fd.get("saldering") === "true",
eta: parseFloat(fd.get("eta")), eta: parseFloat(fd.get("eta")),
@ -267,7 +265,7 @@ const tlSummary = mustFind("tl-summary");
let activeBattery = null; // the row clicked let activeBattery = null; // the row clicked
let tlData = null; // last fetched timeline payload let tlData = null; // last fetched timeline payload
let charts = { irr: null, soc: null, grid: null }; let charts = { price: null, grid: null, soc: null };
function openModalForBattery(row) { function openModalForBattery(row) {
activeBattery = row; activeBattery = row;
@ -292,7 +290,7 @@ function openModalForBattery(row) {
function closeModal() { function closeModal() {
modal.hidden = true; modal.hidden = true;
for (const c of Object.values(charts)) c?.destroy?.(); for (const c of Object.values(charts)) c?.destroy?.();
charts = { irr: null, soc: null, grid: null }; charts = { price: null, grid: null, soc: null };
} }
modal.querySelectorAll('[data-close="modal"]').forEach( modal.querySelectorAll('[data-close="modal"]').forEach(
@ -325,7 +323,7 @@ async function refreshTimeline() {
start: tlDate.value || "2024-06-17", start: tlDate.value || "2024-06-17",
days: tlDays.value, days: tlDays.value,
demand_kwh: sc.demand_kwh, retail: sc.retail, demand_kwh: sc.demand_kwh, retail: sc.retail,
pv_kwp: sc.pv_kwp, fixed_rate: sc.fixed_rate, fixed_rate: sc.fixed_rate,
saldering: sc.saldering, eta: sc.eta, saldering: sc.saldering, eta: sc.eta,
}); });
if (sc.export_rate !== null) params.set("export_rate", sc.export_rate); if (sc.export_rate !== null) params.set("export_rate", sc.export_rate);
@ -356,12 +354,12 @@ function paintTimeline(d) {
tlSummary.replaceChildren(); tlSummary.replaceChildren();
for (const [k, v] of [ for (const [k, v] of [
["window cost", `${cost.toFixed(2)}`], ["window cost", `${cost.toFixed(2)}`],
["imports", `${imports.toFixed(1)} kWh`], ["imports", `${imports.toFixed(2)} kWh`],
["exports", `${exports_.toFixed(1)} kWh`], ["exports", `${exports_.toFixed(2)} kWh`],
["battery throughput", batteryOn ["battery throughput", batteryOn
? `${s.battery_charge_kwh.toFixed(1)} in / ${s.battery_discharge_kwh.toFixed(1)} out kWh` ? `${s.battery_charge_kwh.toFixed(2)} in / ${s.battery_discharge_kwh.toFixed(2)} out kWh`
: `— (battery off)`], : `— (battery off)`],
["window saving", `${savings.toFixed(2)}`], ["window saving", `${savings.toFixed(4)}`],
]) { ]) {
const row = document.createElement("div"); const row = document.createElement("div");
const lbl = document.createElement("span"); lbl.textContent = k; const lbl = document.createElement("span"); lbl.textContent = k;
@ -370,26 +368,26 @@ function paintTimeline(d) {
} }
// X axis: unix seconds (uPlot wants epoch seconds) // X axis: unix seconds (uPlot wants epoch seconds)
const xs = d.hours.map(h => Date.parse(h.ts) / 1000); const xs = d.hours.map(h => Date.parse(h.ts) / 1000);
const irr = d.hours.map(h => h.irradiance); const price = d.hours.map(h => h.price);
const soc = batteryOn ? d.hours.map(h => h.soc) : d.hours.map(() => 0); const soc = batteryOn ? d.hours.map(h => h.soc) : d.hours.map(() => 0);
const grid = batteryOn ? d.hours.map(h => h.grid_with_bat) const grid = batteryOn ? d.hours.map(h => h.grid_with_bat)
: d.hours.map(h => h.grid_no_bat); : d.hours.map(h => h.grid_no_bat);
drawChart("irr", ["Time", "W/m²"], xs, irr, "tl-chart-irradiance", { drawChart("price", ["Time", "€/kWh"], xs, price, "tl-chart-price", {
stroke: "#fbbf24", fill: "rgba(251, 191, 36, 0.2)", stroke: "#a78bfa", fill: "rgba(167, 139, 250, 0.2)",
fmtY: (v) => v == null ? "" : `${v.toFixed(0)} W/m²`, fmtY: (v) => v == null ? "" : `${v.toFixed(3)}/kWh`,
}); });
drawChart("soc", ["Time", "kWh"], xs, soc, "tl-chart-soc", { drawChart("grid", ["Time", "kWh/h"], xs, grid, "tl-chart-grid", {
stroke: "#4ade80", fill: "rgba(74, 222, 128, 0.2)",
fmtY: (v) => v == null ? "" : `${v.toFixed(2)} kWh`,
});
drawChart("grid", ["Time", "kWh/h"], xs, grid, "tl-chart-grid", {
stroke: batteryOn ? "#93c5fd" : "#fb7185", stroke: batteryOn ? "#93c5fd" : "#fb7185",
fill: batteryOn ? "rgba(147, 197, 253, 0.18)" : "rgba(251, 113, 133, 0.18)", fill: batteryOn ? "rgba(147, 197, 253, 0.18)" : "rgba(251, 113, 133, 0.18)",
zeroline: true, zeroline: true,
fmtY: (v) => v == null ? "" : `${v >= 0 ? "+" : ""}${v.toFixed(2)} kWh/h`, fmtY: (v) => v == null ? "" : `${v >= 0 ? "+" : ""}${v.toFixed(2)} kWh/h`,
}); });
drawChart("soc", ["Time", "kWh"], xs, soc, "tl-chart-soc", {
stroke: "#4ade80", fill: "rgba(74, 222, 128, 0.2)",
fmtY: (v) => v == null ? "" : `${v.toFixed(2)} kWh`,
});
} }
function drawChart(key, axes, xs, ys, containerId, opts = {}) { function drawChart(key, axes, xs, ys, containerId, opts = {}) {

View file

@ -238,16 +238,25 @@ table#leaderboard tbody tr a { color: inherit; text-decoration: underline dotted
} }
.modal-summary div { display: flex; justify-content: space-between; gap: 0.5rem; } .modal-summary div { display: flex; justify-content: space-between; gap: 0.5rem; }
.modal-summary div strong { color: var(--accent); } .modal-summary div strong { color: var(--accent); }
.modal-strategy {
border: 1px solid var(--border); border-radius: 8px; background: var(--panel2);
padding: 0.45rem 0.75rem 0.6rem; margin-bottom: 0.75rem;
display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; align-items: center;
}
.modal-strategy legend { padding: 0 0.4rem; font-size: 0.78rem; color: var(--muted); }
.modal-strategy .radio { display: inline-flex; align-items: center; gap: 0.4rem;
font-size: 0.85rem; color: var(--text); cursor: pointer; }
.modal-strategy .radio input { margin: 0; }
.modal-charts { display: flex; flex-direction: column; gap: 0.6rem; } .modal-charts { display: flex; flex-direction: column; gap: 0.6rem; }
.modal-charts figure { margin: 0; background: var(--panel); border: 1px solid var(--border); .modal-charts figure { margin: 0; background: var(--panel); border: 1px solid var(--border);
border-radius: 8px; padding: 0.6rem 0.75rem 0.4rem; } border-radius: 8px; padding: 0.6rem 0.75rem 1rem; }
.modal-charts figcaption { font-size: 0.78rem; color: var(--muted); margin-bottom: 0.3rem; } .modal-charts figcaption { font-size: 0.78rem; color: var(--muted); margin-bottom: 0.3rem; }
.modal-charts .chart { width: 100%; height: 110px; } .modal-charts .chart { width: 100%; height: 150px; }
.modal-charts figure:nth-child(3) .chart { height: 200px; } /* taller grid panel */ .modal-charts figure:nth-child(4) .chart { height: 230px; } /* taller grid panel (4th now) */
/* uPlot dark-theme tweaks */ /* uPlot dark-theme tweaks */
.uplot { color: var(--text); } .uplot { color: var(--text); }
.uplot .u-legend { color: var(--text); font-size: 0.75rem; padding: 0.15rem 0; } .uplot .u-legend { color: var(--text); font-size: 0.75rem; padding: 1rem 0 0.6rem; }
.uplot .u-legend th, .uplot .u-legend td { color: var(--text); padding: 0 0.4rem; } .uplot .u-legend th, .uplot .u-legend td { color: var(--text); padding: 0 0.4rem; }
.uplot .u-legend .u-marker { display: inline-block; } .uplot .u-legend .u-marker { display: inline-block; }
.uplot .u-axis { color: var(--muted); } .uplot .u-axis { color: var(--muted); }

View file

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Honest battery payback — vs thuisbatterijgids.nl</title> <title>Honest battery payback — vs thuisbatterijgids.nl</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=20"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=24">
</head> </head>
<body> <body>
@ -22,21 +22,18 @@
<aside id="inputs"> <aside id="inputs">
<h2>Scenario</h2> <h2>Scenario</h2>
<form id="scenario-form"> <form id="scenario-form">
<label>Annual demand (kWh) <label>Annual net draw at meter (kWh)
<input type="number" name="demand_kwh" min="500" max="20000" step="100" value="{{ defaults.demand_kwh }}"> <input type="number" name="demand_kwh" min="0" max="20000" step="1" value="{{ defaults.demand_kwh }}">
<small>Default 2325 kWh = dad's actual 2024 net (delivered 4301,
returned 1976). Increasing it adds a flat always-on load — more
imports, less surplus exported. The existing-PV export shape stays
untouched.</small>
</label> </label>
<label>Average retail (€/kWh) <label>Average retail (€/kWh)
<input type="number" name="retail" min="0.05" max="0.80" step="0.01" value="{{ defaults.retail }}"> <input type="number" name="retail" min="0.05" max="0.80" step="0.01" value="{{ defaults.retail }}">
</label> </label>
<label>PV system (kWp)
<input type="number" name="pv_kwp" min="0" max="100" step="any" value="{{ defaults.pv_kwp }}">
<small>0 = none (or already netted in your P1 data). Synthesizes
additional PV at 875 kWh/kWp/yr — leave at 0 unless you want to
model extra PV beyond what's in the load signal.</small>
</label>
<fieldset> <fieldset>
<legend>Tariff</legend> <legend>Tariff</legend>
<label class="radio"><input type="radio" name="fixed_rate" value="false" checked> Dynamic (EPEX-based)</label> <label class="radio"><input type="radio" name="fixed_rate" value="false" checked> Dynamic (EPEX-based)</label>
@ -87,7 +84,7 @@
<div class="card"> <div class="card">
<h2> <h2>
Full leaderboard Full leaderboard
<small id="meta">(<span id="count">{{ initial.batteries|length }}</span> batteries · <span id="elapsed">{{ initial.elapsed_seconds }}</span>s · capturable surplus (= grid exports without a battery): <span id="total-surplus">{{ '%.0f' % initial.total_surplus_kwh }}</span> kWh/yr)</small> <small id="meta">(<span id="count">{{ initial.batteries|length }}</span> batteries · <span id="elapsed">{{ initial.elapsed_seconds }}</span>s · existing PV exports at the meter (capturable by a battery): <span id="total-surplus">{{ '%.0f' % initial.total_surplus_kwh }}</span> kWh/yr)</small>
</h2> </h2>
<div class="filters"> <div class="filters">
<label class="filter-toggle"><input type="checkbox" data-cat="small_plugin" checked> ≤ 0.8 kW plug-in (no electrician)</label> <label class="filter-toggle"><input type="checkbox" data-cat="small_plugin" checked> ≤ 0.8 kW plug-in (no electrician)</label>
@ -106,10 +103,10 @@
<th data-sort="lp_payback" data-default-dir="asc" title="Honest payback with inflation">LP yr</th> <th data-sort="lp_payback" data-default-dir="asc" title="Honest payback with inflation">LP yr</th>
<th data-sort="ten_year_profit_eur" data-default-dir="desc" title="Cumulative savings over 10 yrs (with inflation) minus the battery cost">10-yr net</th> <th data-sort="ten_year_profit_eur" data-default-dir="desc" title="Cumulative savings over 10 yrs (with inflation) minus the battery cost">10-yr net</th>
<th data-sort="cycles_per_year" data-default-dir="desc" title="Total discharge throughput / capacity. Includes both surplus capture AND grid arbitrage.">cycles</th> <th data-sort="cycles_per_year" data-default-dir="desc" title="Total discharge throughput / capacity. Includes both surplus capture AND grid arbitrage.">cycles</th>
<th data-sort="captured_kwh" data-default-dir="desc" title="kWh of solar surplus the battery actually absorbed (vs total surplus shown above)">kWh capt</th> <th data-sort="captured_kwh" data-default-dir="desc" title="kWh of meter-export (existing PV pushing back) the battery actually absorbed (vs total surplus shown above)">kWh capt</th>
<th data-sort="captured_pct" data-default-dir="desc" title="Captured kWh as % of total available solar surplus">% capt</th> <th data-sort="captured_pct" data-default-dir="desc" title="Captured kWh as % of total existing-PV export at the meter">% capt</th>
<th data-sort="pct_hours_battery_full" data-default-dir="desc" title="% of surplus hours where the battery was already full and couldn't store more">% full</th> <th data-sort="pct_hours_battery_full" data-default-dir="desc" title="% of export hours where the battery was already full and couldn't store more">% full</th>
<th data-sort="pct_hours_charge_saturated" data-default-dir="desc" title="% of surplus hours where charging was pegged at max kW and couldn't keep up">% sat</th> <th data-sort="pct_hours_charge_saturated" data-default-dir="desc" title="% of export hours where the meter's surplus exceeded the battery's AC intake rate — the AC port physically can't absorb it all">% sat</th>
</tr> </tr>
</thead> </thead>
<tbody id="leaderboard-body">{# rendered cells start below; legend follows the table. #} <tbody id="leaderboard-body">{# rendered cells start below; legend follows the table. #}
@ -138,10 +135,10 @@
<dt>LP yr</dt> <dd>Years until cumulative savings (with the chosen inflation rate) recoup the battery cost.</dd> <dt>LP yr</dt> <dd>Years until cumulative savings (with the chosen inflation rate) recoup the battery cost.</dd>
<dt>10-yr net</dt> <dd>Total profit over 10 years: cumulative savings (compounded by inflation) minus the battery cost. Red = doesn't break even within 10 yr.</dd> <dt>10-yr net</dt> <dd>Total profit over 10 years: cumulative savings (compounded by inflation) minus the battery cost. Red = doesn't break even within 10 yr.</dd>
<dt>cycles</dt> <dd>Total discharge throughput per year, divided by capacity. Includes <em>both</em> surplus capture and grid arbitrage (charging during cheap hours from the grid even when no surplus is exporting). Useful as a "how busy is the battery" metric.</dd> <dt>cycles</dt> <dd>Total discharge throughput per year, divided by capacity. Includes <em>both</em> surplus capture and grid arbitrage (charging during cheap hours from the grid even when no surplus is exporting). Useful as a "how busy is the battery" metric.</dd>
<dt>kWh capt</dt> <dd>kWh of capturable surplus (i.e. of grid exports that would happen without a battery) that this battery absorbs.</dd> <dt>kWh capt</dt> <dd>kWh of meter-export (existing PV pushing back) that this battery absorbs before it crosses the meter.</dd>
<dt>% capt</dt> <dd>That, as a percentage of the scenario's capturable surplus shown in the heading. <em>Note:</em> the denominator is exports, not gross PV — solar that's directly self-consumed during sunny hours never crosses the meter and is invisible to a battery.</dd> <dt>% capt</dt> <dd>That, as a percentage of the scenario's existing-PV meter-export shown in the heading. <em>Note:</em> solar that's directly self-consumed during sunny hours never crosses the meter and is invisible to a battery.</dd>
<dt>% full</dt> <dd>Of all surplus hours, the % when the battery was already full and couldn't store more — capacity-bottlenecked.</dd> <dt>% full</dt> <dd>Of all export hours, the % when the battery was already full and couldn't store more — capacity-bottlenecked.</dd>
<dt>% sat</dt> <dd>Of all surplus hours, the % when charging was pegged at max kW and couldn't keep up — power-bottlenecked.</dd> <dt>% sat</dt> <dd>Of all export hours, the % when charging was pegged at max kW and couldn't keep up — power-bottlenecked.</dd>
</dl> </dl>
</div> </div>
</section> </section>
@ -149,10 +146,12 @@
<footer> <footer>
<p> <p>
LP = 24h-foresight oracle (linear-program optimal dispatch) on 2023-09 → 2024-09 EPEX, P1, and irradiance. LP = greedy self-consumption + perfect-foresight per-day price arbitrage, run over the recorded
Store calculator is reverse-engineered from observed quotes and matches them within €0.50. EPEX + P1 net-meter trace. The simulator works on what the meter shows — solar already netted in,
The store's dynamic-rate quote treats charge cost as raw EPEX wholesale (no VAT, no energy tax), inflating no separate PV synthesis. Store calculator is reverse-engineered from observed quotes and matches
arbitrage savings ~2× over what any real customer with a Dutch tariff actually pays. them within €0.50. The store's dynamic-rate quote treats charge cost as raw EPEX wholesale
(no VAT, no energy tax), inflating arbitrage savings ~2× over what any real customer with a
Dutch tariff actually pays.
</p> </p>
</footer> </footer>
@ -171,6 +170,7 @@
<option value="1">1 day</option> <option value="1">1 day</option>
<option value="3">3 days</option> <option value="3">3 days</option>
<option value="7" selected>7 days</option> <option value="7" selected>7 days</option>
<option value="28">28 days</option>
</select> </select>
<label class="filter-toggle"><input type="checkbox" id="tl-battery-on" checked> battery on</label> <label class="filter-toggle"><input type="checkbox" id="tl-battery-on" checked> battery on</label>
<span id="tl-status" class="status"></span> <span id="tl-status" class="status"></span>
@ -178,22 +178,22 @@
<div id="tl-summary" class="modal-summary"></div> <div id="tl-summary" class="modal-summary"></div>
<div class="modal-charts"> <div class="modal-charts">
<figure> <figure>
<figcaption>Solar irradiance — proxy for sun (W/m², from our weather station, season-aligned)</figcaption> <figcaption>Consumer electricity price (€/kWh) — drives the arbitrage decisions</figcaption>
<div id="tl-chart-irradiance" class="chart"></div> <div id="tl-chart-price" class="chart"></div>
</figure>
<figure>
<figcaption>Net meter (kWh/h). Positive = importing, negative = exporting.</figcaption>
<div id="tl-chart-grid" class="chart"></div>
</figure> </figure>
<figure> <figure>
<figcaption>Battery state of charge (kWh)</figcaption> <figcaption>Battery state of charge (kWh)</figcaption>
<div id="tl-chart-soc" class="chart"></div> <div id="tl-chart-soc" class="chart"></div>
</figure> </figure>
<figure>
<figcaption>Net meter flow (kWh per hour). Positive = importing, negative = exporting.</figcaption>
<div id="tl-chart-grid" class="chart"></div>
</figure>
</div> </div>
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.iife.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.iife.min.js"></script>
<script src="{{ url_for('static', filename='app.js') }}?v=20"></script> <script src="{{ url_for('static', filename='app.js') }}?v=24"></script>
</body> </body>
</html> </html>

View file

@ -4,6 +4,12 @@ Single page: inputs on the left, top recommendation + ranked catalog on the
right. POST /api/calculate accepts a scenario JSON and returns a sorted list right. POST /api/calculate accepts a scenario JSON and returns a sorted list
of every battery scored against both our LP and the cracked store formula. of every battery scored against both our LP and the cracked store formula.
The simulator works on the raw P1 net-meter signal (``raw_demand_kw``,
signed). Whatever the meter shows is the input solar already netted in,
no separate PV simulation. To model a different PV system you'd need an
actual measurement of that system; reconstructing one from a remote
irradiance sensor was tried and didn't survive contact with reality.
Run locally: Run locally:
uv run flask --app pluginbattery.web run --debug --port 8000 uv run flask --app pluginbattery.web run --debug --port 8000
@ -28,7 +34,6 @@ from pluginbattery.sim import (
load_hourly, load_hourly,
oracle_daily_schedule, oracle_daily_schedule,
simulate, simulate,
synthesize_pv,
) )
from pluginbattery.store_calc import ( from pluginbattery.store_calc import (
Scenario, Scenario,
@ -43,7 +48,17 @@ DATA_DIR = Path(__file__).resolve().parents[2] / "data" / "raw"
print("Loading historical data…", flush=True) print("Loading historical data…", flush=True)
_BASE_DF = apply_nl_tariff(load_hourly(DATA_DIR)) _BASE_DF = apply_nl_tariff(load_hourly(DATA_DIR))
_ANNUAL_DEMAND_BASE = float(_BASE_DF["demand_kwh"].sum() * 8766.0 / len(_BASE_DF)) # Linear extrapolation of what the recorded window says the annual net
# draw would be. Used as the reference the form's additive demand slider
# is measured against — so a slider value of `_ANNUAL_NET_DEMAND_BASE`
# means "use the data unmodified".
_ANNUAL_NET_DEMAND_BASE = float(_BASE_DF["raw_demand_kw"].sum() * 8766.0 / len(_BASE_DF))
# Dad's actual recorded annual net for the most recent full calendar
# year (his quote: "vorig jaar geleverd 4301 kWh, terug geleverd 1976
# kWh, resteerde 2325 kWh"). Used as the default form value because our
# data window is partial (~8 months, summer-heavy) and undershoots the
# real annual figure.
_REAL_ANNUAL_NET_DEMAND = 2325.0
_AVG_CONSUMER_BASE = float(_BASE_DF["eur_per_kwh"].mean()) _AVG_CONSUMER_BASE = float(_BASE_DF["eur_per_kwh"].mean())
with open(DATA_DIR / "thuisbatterijgids_catalog.json") as _f: with open(DATA_DIR / "thuisbatterijgids_catalog.json") as _f:
@ -65,12 +80,7 @@ def _category(power_kw: float, allows_export: bool) -> str:
def _lifetime_profit(year1: float, cost: float, years: int, inflation: float) -> float: def _lifetime_profit(year1: float, cost: float, years: int, inflation: float) -> float:
"""Cumulative savings over `years` minus the up-front battery cost. """Cumulative savings over `years` minus the up-front battery cost."""
Each successive year's savings are inflated by `inflation` (matches the
payback formula). Negative result = the battery doesn't break even
within the horizon.
"""
if inflation == 0: if inflation == 0:
return year1 * years - cost return year1 * years - cost
return year1 * ((1 + inflation) ** years - 1) / inflation - cost return year1 * ((1 + inflation) ** years - 1) / inflation - cost
@ -104,10 +114,11 @@ def _worker_run_lp(spec):
) )
out = simulate(_WORKER_DF, bat, oracle_daily_schedule(_WORKER_DF, bat)) out = simulate(_WORKER_DF, bat, oracle_daily_schedule(_WORKER_DF, bat))
demand = _WORKER_DF["demand_kwh"].to_numpy() # "Surplus" = hours where the meter was already exporting before any
pv = (_WORKER_DF["pv_kwh"].to_numpy() # battery action, i.e. existing PV pushing back. The battery's job on
if "pv_kwh" in _WORKER_DF.columns else np.zeros(len(_WORKER_DF))) # those hours is to absorb that flow before it crosses the meter.
surplus = np.maximum(0.0, pv - demand) raw = _WORKER_DF["raw_demand_kw"].to_numpy()
surplus = np.maximum(0.0, -raw)
surplus_mask = surplus > 1e-6 surplus_mask = surplus > 1e-6
n_surplus_hours = int(surplus_mask.sum()) n_surplus_hours = int(surplus_mask.sum())
@ -117,9 +128,12 @@ def _worker_run_lp(spec):
if n_surplus_hours > 0: if n_surplus_hours > 0:
soc = out["soc_kwh"].to_numpy() soc = out["soc_kwh"].to_numpy()
charge = out["charge_kwh"].to_numpy()
pct_full = float((surplus_mask & (soc >= cap - 1e-3)).sum()) / n_surplus_hours * 100.0 pct_full = float((surplus_mask & (soc >= cap - 1e-3)).sum()) / n_surplus_hours * 100.0
pct_saturated = float((surplus_mask & (charge >= pw - 1e-3)).sum()) / n_surplus_hours * 100.0 # Power bottleneck: the meter is exporting *more* than the battery's
# AC port can absorb in a single hour. This is purely a function of
# surplus vs pc_max — it does not get confounded by Pass-2 arbitrage
# topping the battery off from the grid during cheap export hours.
pct_saturated = float((surplus >= pw - 1e-3).sum()) / n_surplus_hours * 100.0
else: else:
pct_full = 0.0 pct_full = 0.0
pct_saturated = 0.0 pct_saturated = 0.0
@ -135,18 +149,24 @@ def _worker_run_lp(spec):
# ─── Core compute ───────────────────────────────────────────────────── # ─── Core compute ─────────────────────────────────────────────────────
def _build_df(demand_kwh: float, retail: float, pv_kwp: float, pv_yield: float, def _build_df(demand_kwh: float, retail: float,
fixed_rate: bool, saldering: bool, export_rate: float | None): fixed_rate: bool, saldering: bool, export_rate: float | None):
df = _BASE_DF.copy() df = _BASE_DF.copy()
df["demand_kwh"] = _BASE_DF["demand_kwh"] * (demand_kwh / _ANNUAL_DEMAND_BASE) # Apply the demand slider as an *additive* shift, not a multiplicative
# one. The signal is `gross_demand PV` and we can't separate them,
# but we can model "this same house plus a constant always-on load"
# by adding a flat kW baseline. That leaves the PV-export shape
# untouched (a 3 kWp system still produces what it produces), and
# lifts the imports + reduces the surplus by the same amount —
# exactly what an extra fixed load would do.
delta_kw = (demand_kwh - _ANNUAL_NET_DEMAND_BASE) / 8766.0
df["raw_demand_kw"] = _BASE_DF["raw_demand_kw"] + delta_kw
if fixed_rate: if fixed_rate:
df["eur_per_kwh"] = float(retail) df["eur_per_kwh"] = float(retail)
else: else:
scale = retail / _AVG_CONSUMER_BASE scale = retail / _AVG_CONSUMER_BASE
df["eur_per_kwh"] = _BASE_DF["eur_per_kwh"] * scale df["eur_per_kwh"] = _BASE_DF["eur_per_kwh"] * scale
df["epex_eur_per_kwh"] = _BASE_DF["epex_eur_per_kwh"] * scale df["epex_eur_per_kwh"] = _BASE_DF["epex_eur_per_kwh"] * scale
if pv_kwp > 0:
df = synthesize_pv(df, kwp=pv_kwp, target_kwh_per_kwp_per_year=pv_yield)
if export_rate is not None: if export_rate is not None:
df["export_eur_per_kwh"] = float(export_rate) df["export_eur_per_kwh"] = float(export_rate)
elif saldering: elif saldering:
@ -160,41 +180,34 @@ def compute_leaderboard(
*, *,
demand_kwh: float, demand_kwh: float,
retail: float, retail: float,
pv_kwp: float,
pv_yield: float,
fixed_rate: bool, fixed_rate: bool,
saldering: bool, saldering: bool,
eta: float, eta: float,
inflation: float, inflation: float,
export_rate: float | None = None, export_rate: float | None = None,
) -> dict: ) -> dict:
key = (round(demand_kwh, 1), round(retail, 4), round(pv_kwp, 2), key = (round(demand_kwh, 1), round(retail, 4),
round(pv_yield, 1), fixed_rate, saldering, round(eta, 3), fixed_rate, saldering, round(eta, 3),
round(inflation, 4), round(inflation, 4),
None if export_rate is None else round(export_rate, 4)) None if export_rate is None else round(export_rate, 4))
if key in _RESULT_CACHE: if key in _RESULT_CACHE:
return _RESULT_CACHE[key] return _RESULT_CACHE[key]
t0 = time.time() t0 = time.time()
df = _build_df(demand_kwh, retail, pv_kwp, pv_yield, fixed_rate, saldering, export_rate) df = _build_df(demand_kwh, retail, fixed_rate, saldering, export_rate)
avg_epex = float(df["epex_eur_per_kwh"].mean()) avg_epex = float(df["epex_eur_per_kwh"].mean())
store_params = StoreParams(avg_epex_eur_per_kwh=avg_epex) store_params = StoreParams(avg_epex_eur_per_kwh=avg_epex)
# Annualisation factor: our data window may be shorter than a year (e.g., # Annualisation factor: our data window may be shorter than a year.
# 8 months when solar coverage limits the overlap). Scale all per-window # Scale all per-window extensive quantities so they represent a full year.
# extensive quantities (savings, kWh shifted, captured, surplus) so they
# represent a full year. Intensive metrics like % full / % saturated stay
# as-is.
annualize = 8766.0 / len(df) annualize = 8766.0 / len(df)
# Total PV / surplus available across the whole scenario (no battery). # Existing-PV surplus visible at the meter across the whole window
pv_col = df["pv_kwh"].to_numpy() if "pv_kwh" in df.columns else np.zeros(len(df)) # (hours where raw_demand_kw < 0, i.e. dad's PV exporting).
window_surplus = float(np.maximum(0.0, pv_col - df["demand_kwh"].to_numpy()).sum()) raw = df["raw_demand_kw"].to_numpy()
window_surplus = float(np.maximum(0.0, -raw).sum())
total_surplus_kwh = window_surplus * annualize total_surplus_kwh = window_surplus * annualize
# Collect every distinct (capacity, power) the catalog asks for, then run
# the per-spec LPs in parallel. linprog/HiGHS release the GIL so threads
# give a near-linear speedup on this VM (4 cores).
valid_rows = [] valid_rows = []
specs: set[tuple[float, float, bool]] = set() specs: set[tuple[float, float, bool]] = set()
for b in _CATALOG: for b in _CATALOG:
@ -210,26 +223,21 @@ def compute_leaderboard(
valid_rows.append((b, cap, pw, price, allows_export)) valid_rows.append((b, cap, pw, price, allows_export))
specs.add((round(cap, 3), round(pw, 3), allows_export)) specs.add((round(cap, 3), round(pw, 3), allows_export))
# Cache key for "what does this LP depend on?" — everything that goes df_sig = (round(demand_kwh, 1), round(retail, 4),
# into the dataframe construction plus eta. Inflation isn't in here, so fixed_rate, saldering, round(eta, 3),
# tweaking only inflation hits the cache for every catalog entry.
df_sig = (round(demand_kwh, 1), round(retail, 4), round(pv_kwp, 2),
round(pv_yield, 1), fixed_rate, saldering, round(eta, 3),
None if export_rate is None else round(export_rate, 4)) None if export_rate is None else round(export_rate, 4))
cached_results = {} cached_results = {}
todo = [] todo = []
for spec in specs: for spec in specs:
key = (df_sig, *spec) cache_key = (df_sig, *spec)
if key in _LP_CACHE: if cache_key in _LP_CACHE:
cached_results[spec] = _LP_CACHE[key] cached_results[spec] = _LP_CACHE[cache_key]
else: else:
todo.append(spec) todo.append(spec)
if todo: if todo:
workers = min(len(todo), int(os.environ.get("PLUGINBATTERY_LP_WORKERS", "4"))) workers = min(len(todo), int(os.environ.get("PLUGINBATTERY_LP_WORKERS", "4")))
# Fork pool: each worker inherits `df` via copy-on-write rather than
# pickling it on every task — fast cold scenarios.
ctx = get_context("fork") ctx = get_context("fork")
with ProcessPoolExecutor( with ProcessPoolExecutor(
max_workers=max(1, workers), max_workers=max(1, workers),
@ -248,8 +256,8 @@ def compute_leaderboard(
payload = lp_cache[(round(cap, 3), round(pw, 3), allows_export)] payload = lp_cache[(round(cap, 3), round(pw, 3), allows_export)]
lp_year1 = payload["year1"] * annualize lp_year1 = payload["year1"] * annualize
captured_kwh = payload["captured_kwh"] * annualize captured_kwh = payload["captured_kwh"] * annualize
pct_full = payload["pct_full"] # already a ratio pct_full = payload["pct_full"]
pct_saturated = payload["pct_saturated"] # already a ratio pct_saturated = payload["pct_saturated"]
discharge_kwh = payload["discharge_kwh"] * annualize discharge_kwh = payload["discharge_kwh"] * annualize
cycles_per_year = discharge_kwh / cap if cap > 0 else 0.0 cycles_per_year = discharge_kwh / cap if cap > 0 else 0.0
captured_pct = (captured_kwh / total_surplus_kwh * 100.0) if total_surplus_kwh > 0 else 0.0 captured_pct = (captured_kwh / total_surplus_kwh * 100.0) if total_surplus_kwh > 0 else 0.0
@ -265,7 +273,7 @@ def compute_leaderboard(
scn = Scenario( scn = Scenario(
capacity_kwh=cap, max_charge_kw=pw, battery_cost_eur=price, capacity_kwh=cap, max_charge_kw=pw, battery_cost_eur=price,
avg_retail_eur_per_kwh=retail, avg_retail_eur_per_kwh=retail,
has_pv=pv_kwp > 0, has_saldering=saldering, has_pv=total_surplus_kwh > 0, has_saldering=saldering,
dynamic_rate=not fixed_rate, dynamic_rate=not fixed_rate,
) )
sq = store_quote(scn, store_params, inflation=inflation) sq = store_quote(scn, store_params, inflation=inflation)
@ -301,7 +309,6 @@ def compute_leaderboard(
result = { result = {
"scenario": { "scenario": {
"demand_kwh": demand_kwh, "retail": retail, "demand_kwh": demand_kwh, "retail": retail,
"pv_kwp": pv_kwp, "pv_yield": pv_yield,
"fixed_rate": fixed_rate, "saldering": saldering, "fixed_rate": fixed_rate, "saldering": saldering,
"eta": eta, "inflation": inflation, "eta": eta, "inflation": inflation,
"export_rate": export_rate, "export_rate": export_rate,
@ -321,15 +328,14 @@ def compute_leaderboard(
app = Flask(__name__) app = Flask(__name__)
DEFAULTS = dict( DEFAULTS = dict(
# Window: 2025-01-01 → 2025-09-01 (8 months of dad's actual data with # Demand default = dad's real full-year annual net (2325 kWh per his
# matching 2025 prices + solar). The simulator scales demand by # quote). Larger than what the partial-window data extrapolates to
# demand_kwh / ANNUAL_DEMAND_BASE; ANNUAL_DEMAND_BASE annualises the # (~1515 kWh) because we're missing the winter months; the slider's
# 8-month window's 1010 kWh → 1518 kWh-equivalent/yr, so set the # additive shift compensates.
# default to 1518 to keep the per-hour P1 values unscaled (scale = 1.0). # export_rate = 0 by default. Dad's actual situation has
# export_rate = 0 by default to keep the model simple. Dad's actual # terugleveringskosten of €0.106/kWh; set the field explicitly to
# situation has terugleveringskosten of €0.106/kWh; set the field # that value when modelling his real bill.
# explicitly to that value when modelling his real bill. demand_kwh=round(_REAL_ANNUAL_NET_DEMAND), retail=0.25,
demand_kwh=1518.0, retail=0.25, pv_kwp=0.0, pv_yield=875.0,
fixed_rate=False, saldering=False, eta=0.88, inflation=0.03, fixed_rate=False, saldering=False, eta=0.88, inflation=0.03,
export_rate=0.0, export_rate=0.0,
) )
@ -338,7 +344,13 @@ DEFAULTS = dict(
@app.route("/") @app.route("/")
def index(): def index():
initial = compute_leaderboard(**DEFAULTS) initial = compute_leaderboard(**DEFAULTS)
return render_template("index.html", initial=initial, defaults=DEFAULTS) resp = app.make_response(render_template("index.html", initial=initial, defaults=DEFAULTS))
# Cache-bust query strings on app.js/style.css only help if the browser
# re-fetches index.html — force a fresh page on every request.
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
resp.headers["Expires"] = "0"
return resp
@app.route("/api/calculate", methods=["POST"]) @app.route("/api/calculate", methods=["POST"])
@ -356,8 +368,6 @@ def calculate():
result = compute_leaderboard( result = compute_leaderboard(
demand_kwh=float(p.get("demand_kwh", DEFAULTS["demand_kwh"])), demand_kwh=float(p.get("demand_kwh", DEFAULTS["demand_kwh"])),
retail=float(p.get("retail", DEFAULTS["retail"])), retail=float(p.get("retail", DEFAULTS["retail"])),
pv_kwp=float(p.get("pv_kwp", DEFAULTS["pv_kwp"])),
pv_yield=float(p.get("pv_yield", DEFAULTS["pv_yield"])),
fixed_rate=bool(p.get("fixed_rate", DEFAULTS["fixed_rate"])), fixed_rate=bool(p.get("fixed_rate", DEFAULTS["fixed_rate"])),
saldering=bool(p.get("saldering", DEFAULTS["saldering"])), saldering=bool(p.get("saldering", DEFAULTS["saldering"])),
eta=float(p.get("eta", DEFAULTS["eta"])), eta=float(p.get("eta", DEFAULTS["eta"])),
@ -371,12 +381,7 @@ def calculate():
@app.route("/api/timeline") @app.route("/api/timeline")
def timeline(): def timeline():
"""Per-hour LP trajectory for a battery over a chosen window. """Per-hour trajectory for a battery over a chosen window."""
Returns both 'with battery' and 'without battery' series so the UI can
toggle between them without round-tripping. Defaults to a sunny mid-June
week.
"""
q = request.args q = request.args
try: try:
cap = float(q.get("cap", "1.92")) cap = float(q.get("cap", "1.92"))
@ -387,16 +392,11 @@ def timeline():
demand_kwh = float(q.get("demand_kwh", DEFAULTS["demand_kwh"])) demand_kwh = float(q.get("demand_kwh", DEFAULTS["demand_kwh"]))
retail = float(q.get("retail", DEFAULTS["retail"])) retail = float(q.get("retail", DEFAULTS["retail"]))
pv_kwp = float(q.get("pv_kwp", DEFAULTS["pv_kwp"]))
pv_yield = float(q.get("pv_yield", DEFAULTS["pv_yield"]))
fixed_rate = q.get("fixed_rate", "false").lower() == "true" fixed_rate = q.get("fixed_rate", "false").lower() == "true"
saldering = q.get("saldering", "false").lower() == "true" saldering = q.get("saldering", "false").lower() == "true"
eta = float(q.get("eta", DEFAULTS["eta"])) eta = float(q.get("eta", DEFAULTS["eta"]))
raw_export = q.get("export_rate") raw_export = q.get("export_rate")
if raw_export is None: if raw_export is None:
# Param absent → use the configured default (matches the form's
# initial value). 'export_rate=' (explicit empty) still means
# 'fall back to saldering toggle'.
export_rate = DEFAULTS.get("export_rate") export_rate = DEFAULTS.get("export_rate")
elif raw_export in ("", "null"): elif raw_export in ("", "null"):
export_rate = None export_rate = None
@ -405,7 +405,7 @@ def timeline():
except (TypeError, ValueError) as e: except (TypeError, ValueError) as e:
return jsonify({"error": f"bad query param: {e}"}), 400 return jsonify({"error": f"bad query param: {e}"}), 400
df = _build_df(demand_kwh, retail, pv_kwp, pv_yield, fixed_rate, saldering, export_rate) df = _build_df(demand_kwh, retail, fixed_rate, saldering, export_rate)
bat = Battery( bat = Battery(
capacity_kwh=cap, max_charge_kw=power, max_discharge_kw=power, capacity_kwh=cap, max_charge_kw=power, max_discharge_kw=power,
round_trip_eff=eta, allows_export=allows_export, round_trip_eff=eta, allows_export=allows_export,
@ -420,7 +420,7 @@ def timeline():
return jsonify({"error": f"no data for {start_raw} + {days} days " return jsonify({"error": f"no data for {start_raw} + {days} days "
f"(window: {out.index[0].date()}..{out.index[-1].date()})"}), 400 f"(window: {out.index[0].date()}..{out.index[-1].date()})"}), 400
g_no = (win["demand_kwh"] - win.get("pv_kwh", 0)).to_numpy() g_no = win["raw_demand_kw"].to_numpy()
g_yes = win["grid_kwh_with_battery"].to_numpy() g_yes = win["grid_kwh_with_battery"].to_numpy()
imp_p = win["eur_per_kwh"].to_numpy() imp_p = win["eur_per_kwh"].to_numpy()
exp_p = win.get("export_eur_per_kwh", win["eur_per_kwh"]).to_numpy() exp_p = win.get("export_eur_per_kwh", win["eur_per_kwh"]).to_numpy()
@ -431,9 +431,9 @@ def timeline():
for i, (ts, r) in enumerate(win.iterrows()): for i, (ts, r) in enumerate(win.iterrows()):
hours_payload.append({ hours_payload.append({
"ts": ts.isoformat(), "ts": ts.isoformat(),
"irradiance": float(r.get("irradiance_w_m2", 0.0)),
"price": float(r["eur_per_kwh"]), "price": float(r["eur_per_kwh"]),
"export_price": float(r.get("export_eur_per_kwh", r["eur_per_kwh"])), "export_price": float(r.get("export_eur_per_kwh", r["eur_per_kwh"])),
"raw_demand": float(r["raw_demand_kw"]),
"soc": float(r["soc_kwh"]), "soc": float(r["soc_kwh"]),
"charge": float(r["charge_kwh"]), "charge": float(r["charge_kwh"]),
"discharge": float(r["discharge_kwh"]), "discharge": float(r["discharge_kwh"]),
@ -447,7 +447,7 @@ def timeline():
"battery": {"capacity_kwh": cap, "power_kw": power, "battery": {"capacity_kwh": cap, "power_kw": power,
"allows_export": allows_export}, "allows_export": allows_export},
"scenario": {"demand_kwh": demand_kwh, "retail": retail, "scenario": {"demand_kwh": demand_kwh, "retail": retail,
"pv_kwp": pv_kwp, "saldering": saldering, "saldering": saldering,
"fixed_rate": fixed_rate, "eta": eta, "fixed_rate": fixed_rate, "eta": eta,
"export_rate": export_rate}, "export_rate": export_rate},
"window": {"start": start_ts.isoformat(), "days": days, "window": {"start": start_ts.isoformat(), "days": days,

View file

@ -10,18 +10,21 @@ from pluginbattery.sim import (
apply_nl_tariff, apply_nl_tariff,
oracle_daily_schedule, oracle_daily_schedule,
simulate, simulate,
synthesize_pv,
) )
def make_df(prices: list[float], demands_kwh: list[float]) -> pd.DataFrame: def make_df(prices: list[float], raw_demands_kw: list[float]) -> pd.DataFrame:
"""Build an hourly fixture DataFrame.
raw_demands_kw is signed: positive = importing, negative = exporting
(existing PV pushing back through the meter).
"""
idx = pd.date_range("2024-01-01", periods=len(prices), freq="h", tz="UTC") idx = pd.date_range("2024-01-01", periods=len(prices), freq="h", tz="UTC")
return pd.DataFrame( return pd.DataFrame(
{ {
"eur_per_kwh": prices, "eur_per_kwh": prices,
"power_w": np.array(demands_kwh) * 1000.0, "power_w": np.array(raw_demands_kw) * 1000.0,
"irradiance_w_m2": 0.0, "raw_demand_kw": raw_demands_kw,
"demand_kwh": demands_kwh,
}, },
index=idx, index=idx,
) )
@ -43,12 +46,12 @@ def test_plugin_never_exports():
round_trip_eff=1.0, allows_export=False, initial_soc_kwh=2.0) round_trip_eff=1.0, allows_export=False, initial_soc_kwh=2.0)
schedule = np.array([[0.0, 0.8]] * 3) # try to dump at full power schedule = np.array([[0.0, 0.8]] * 3) # try to dump at full power
out = simulate(df, bat, schedule) out = simulate(df, bat, schedule)
assert (out["discharge_kwh"] <= out["demand_kwh"] + 1e-9).all() assert (out["discharge_kwh"] <= out["raw_demand_kw"] + 1e-9).all()
assert (out["grid_kwh_with_battery"] >= -1e-9).all() assert (out["grid_kwh_with_battery"] >= -1e-9).all()
def test_grid_arbitrage_kicks_in_on_no_sun_days(): def test_grid_arbitrage_kicks_in_on_no_sun_days():
"""Without PV but with a daily price spread, the dispatcher should """Without surplus but with a daily price spread, the dispatcher should
charge during the cheapest hours and discharge during the most charge during the cheapest hours and discharge during the most
expensive same dynamic-tariff behaviour Tibber-style controllers do.""" expensive same dynamic-tariff behaviour Tibber-style controllers do."""
prices = [0.05] * 12 + [0.50] * 12 prices = [0.05] * 12 + [0.50] * 12
@ -66,23 +69,23 @@ def test_grid_arbitrage_kicks_in_on_no_sun_days():
assert out["savings"].sum() > 0 assert out["savings"].sum() > 0
def test_greedy_fills_from_surplus_then_overflows(): def test_greedy_fills_from_meter_export_then_overflows():
"""With PV surplus, greedy fills the battery as fast as power allows """When the meter is already exporting (existing PV pushing back),
until capacity is reached, then lets the rest export.""" greedy fills the battery as fast as power allows until capacity is
reached, then lets the rest flow out the meter."""
prices = [0.20] * 24 prices = [0.20] * 24
demand = [0.1] * 24 # 6 hours of net-export (3 kW each), then 12 hours of import demand.
pv = [0.0] * 6 + [3.0] * 6 + [0.0] * 12 # 6 sunny hours, 3 kWh/h surplus raw = [0.1] * 6 + [-3.0] * 6 + [0.1] * 12
df = make_df(prices, demand) df = make_df(prices, raw)
df["pv_kwh"] = pv
bat = Battery(capacity_kwh=2.0, max_charge_kw=0.8, max_discharge_kw=0.8, bat = Battery(capacity_kwh=2.0, max_charge_kw=0.8, max_discharge_kw=0.8,
round_trip_eff=1.0, allows_export=False) round_trip_eff=1.0, allows_export=False)
schedule = oracle_daily_schedule(df, bat) schedule = oracle_daily_schedule(df, bat)
out = simulate(df, bat, schedule) out = simulate(df, bat, schedule)
# Charging happens during the first surplus hours, capped at 0.8 kW # Charging happens during the first export hours, capped at 0.8 kW
assert out["charge_kwh"].iloc[6:9].sum() == pytest.approx(2.0, abs=1e-6) assert out["charge_kwh"].iloc[6:9].sum() == pytest.approx(2.0, abs=1e-6)
# Once full, no more charging even though surplus continues # Once full, no more charging even though export continues
assert out["charge_kwh"].iloc[9:12].sum() == 0 assert out["charge_kwh"].iloc[9:12].sum() == 0
# Battery discharges into evening demand # Battery discharges into evening import demand
assert out["discharge_kwh"].iloc[12:].sum() > 0 assert out["discharge_kwh"].iloc[12:].sum() > 0
@ -119,55 +122,40 @@ def test_oracle_skips_arbitrage_when_eff_kills_it():
assert out["savings"].sum() < 1e-6 assert out["savings"].sum() < 1e-6
def test_synthesize_pv_hits_target_annual(): def test_plugin_never_pushes_to_grid_during_export_hours():
"""synthesize_pv should calibrate so annual output ≈ target × kWp.""" """Plug-in battery during meter-export hours: discharge must be 0
n = 24 * 30 # 30 days (battery cannot push current backwards, and the meter is already
idx = pd.date_range("2024-06-01", periods=n, freq="h", tz="UTC") flowing the wrong way)."""
# Simple square wave: 600 W/m² for 8 daylight hours, zero otherwise. prices = [0.30] * 24
irr = np.zeros(n) # First 12 hours: meter is exporting (-3 kWh/h). Last 12: importing (+0.5 kWh/h).
for d in range(30): raw = [-3.0] * 12 + [0.5] * 12
irr[d * 24 + 8 : d * 24 + 16] = 600.0 df = make_df(prices, raw)
df = pd.DataFrame({
"eur_per_kwh": 0.20, "power_w": 0.0,
"irradiance_w_m2": irr, "demand_kwh": 0.0,
}, index=idx)
out = synthesize_pv(df, kwp=3.0, target_kwh_per_kwp_per_year=900.0)
annual_pv = out["pv_kwh"].sum() * (8766 / n)
assert abs(annual_pv - 3.0 * 900.0) < 1.0
def test_plugin_with_pv_does_not_push_to_grid():
"""Plug-in battery + surplus solar: discharge must be 0 in surplus hours."""
df = make_df([0.30] * 24, [0.5] * 24)
df["pv_kwh"] = [3.0] * 12 + [0.0] * 12 # huge midday surplus
bat = Battery(capacity_kwh=2.0, max_charge_kw=0.8, max_discharge_kw=0.8, bat = Battery(capacity_kwh=2.0, max_charge_kw=0.8, max_discharge_kw=0.8,
round_trip_eff=0.9, allows_export=False, initial_soc_kwh=2.0) round_trip_eff=0.9, allows_export=False, initial_soc_kwh=2.0)
schedule = np.array([[0.0, 0.8]] * 24) # try to dump every hour schedule = np.array([[0.0, 0.8]] * 24) # try to dump every hour
out = simulate(df, bat, schedule) out = simulate(df, bat, schedule)
surplus_hours = out["pv_kwh"] > out["demand_kwh"] export_hours = out["raw_demand_kw"] < 0
assert (out.loc[surplus_hours, "discharge_kwh"] == 0).all() assert (out.loc[export_hours, "discharge_kwh"] == 0).all()
def test_no_saldering_increases_battery_savings(): def test_no_saldering_increases_battery_savings():
"""Removing saldering should make a battery on a PV system more valuable. """Removing saldering should make a battery on a house-with-PV more valuable.
Reason: surplus solar that previously credited at consumer price now only Reason: meter-export that previously credited at consumer price now only
earns raw EPEX. Storing it for later self-consumption is now strictly earns raw EPEX. Storing it for later self-consumption is now strictly
better than the previous opportunity cost. better than the previous opportunity cost.
""" """
# Day with cheap morning EPEX, noon surplus solar, expensive evening. # Day with cheap morning EPEX, midday meter-export (existing PV), expensive evening.
prices_consumer = [0.20] * 6 + [0.15] * 6 + [0.40] * 12 prices_consumer = [0.20] * 6 + [0.15] * 6 + [0.40] * 12
prices_epex = [0.05] * 6 + [0.02] * 6 + [0.20] * 12 # before VAT/tax prices_epex = [0.05] * 6 + [0.02] * 6 + [0.20] * 12 # before VAT/tax
demands = [0.5] * 24 # Net: small import morning, big export midday (PV peak), evening import.
pv = [0.0] * 8 + [3.0] * 6 + [0.0] * 10 raw = [0.5] * 8 + [-3.0] * 6 + [0.5] * 10
n = 24 n = 24
idx = pd.date_range("2024-01-01", periods=n, freq="h", tz="UTC") idx = pd.date_range("2024-01-01", periods=n, freq="h", tz="UTC")
base = pd.DataFrame({ base = pd.DataFrame({
"eur_per_kwh": prices_consumer, "eur_per_kwh": prices_consumer,
"power_w": np.array(demands) * 1000.0, "power_w": np.array(raw) * 1000.0,
"irradiance_w_m2": 0.0, "raw_demand_kw": raw,
"demand_kwh": demands,
"pv_kwh": pv,
"epex_eur_per_kwh": prices_epex, "epex_eur_per_kwh": prices_epex,
}, index=idx) }, index=idx)
bat = Battery(capacity_kwh=5.0, max_charge_kw=2.5, max_discharge_kw=2.5, bat = Battery(capacity_kwh=5.0, max_charge_kw=2.5, max_discharge_kw=2.5,