From e2fb72a0ab21e315bb4e3c8471d2557af17eaf54 Mon Sep 17 00:00:00 2001 From: Michiel Berger Date: Fri, 8 May 2026 21:32:39 +0200 Subject: [PATCH 1/2] chore(catalog): add Zendure SolarFlow 2400AC 5.8 kWh plug-in set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5.8 kWh / 2.4 kW / €1759, sourced from solar-outlet.nl. Co-Authored-By: Claude Opus 4.7 (1M context) --- data/raw/extra_batteries.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/data/raw/extra_batteries.json b/data/raw/extra_batteries.json index 45f1c0b..fdaecb7 100644 --- a/data/raw/extra_batteries.json +++ b/data/raw/extra_batteries.json @@ -13,5 +13,20 @@ "recommended": false, "installation": "plugin", "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": "" } ] From fc90e652715262eab03456fafce28502ec27cacf Mon Sep 17 00:00:00 2001 From: Michiel Berger Date: Fri, 8 May 2026 21:32:59 +0200 Subject: [PATCH 2/2] refactor: drop PV synthesis, model raw P1 net signal directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/battery_leaderboard.py | 26 +-- scripts/capacity_sweep.py | 4 +- scripts/compare_batteries.py | 32 +-- scripts/compare_with_store.py | 29 +-- scripts/import_dad_p1.py | 6 +- scripts/timeline_inspect.py | 22 +- src/pluginbattery/sim.py | 298 ++++++------------------- src/pluginbattery/static/app.js | 42 ++-- src/pluginbattery/static/style.css | 17 +- src/pluginbattery/templates/index.html | 60 ++--- src/pluginbattery/web.py | 150 ++++++------- tests/test_sim.py | 86 +++---- 12 files changed, 296 insertions(+), 476 deletions(-) diff --git a/scripts/battery_leaderboard.py b/scripts/battery_leaderboard.py index e50145c..df8c5e1 100644 --- a/scripts/battery_leaderboard.py +++ b/scripts/battery_leaderboard.py @@ -2,10 +2,11 @@ """Run every battery in the catalog through both calculators and rank by payback. Default scenario matches the dynamic-mode store-test: - 4000 kWh/yr demand, avg retail €0.25, no PV, no saldering, 3% inflation. + 4000 kWh/yr net demand, avg retail €0.25, no saldering, 3% inflation. -Override with CLI flags to test a different scenario (e.g., dad's situation -with --pv-kwp 3.0). Output: data/processed/battery_leaderboard.csv. +The simulator works on the raw P1 net-meter trace — whatever PV is on the +roof is already netted in. To model a different PV setup, swap the input +data, not a CLI flag. Output: data/processed/battery_leaderboard.csv. """ from __future__ import annotations @@ -19,7 +20,6 @@ from pluginbattery.sim import ( load_hourly, oracle_daily_schedule, simulate, - synthesize_pv, ) from pluginbattery.store_calc import ( Scenario, @@ -36,8 +36,6 @@ def main() -> None: p.add_argument("--retail", type=float, default=0.25) p.add_argument("--demand", type=float, default=4000.0) p.add_argument("--eta", type=float, default=0.88) - p.add_argument("--pv-kwp", type=float, default=0.0) - p.add_argument("--pv-yield", type=float, default=875.0) p.add_argument("--saldering", action="store_true") p.add_argument("--inflation", type=float, default=0.03) p.add_argument("--top", type=int, default=20, help="How many rows to print") @@ -46,15 +44,14 @@ def main() -> None: # ─── Load and prep the simulation data once ──────────────────────── base = load_hourly("data/raw"); base = apply_nl_tariff(base) df = base.copy() - annual_demand = base["demand_kwh"].sum() * (8766.0 / len(base)) - df["demand_kwh"] = base["demand_kwh"] * (args.demand / annual_demand) + annual_net_demand = base["raw_demand_kw"].sum() * (8766.0 / len(base)) + df["raw_demand_kw"] = base["raw_demand_kw"] * (args.demand / annual_net_demand) scale = args.retail / base["eur_per_kwh"].mean() df["eur_per_kwh"] = base["eur_per_kwh"] * scale df["epex_eur_per_kwh"] = base["epex_eur_per_kwh"] * scale - if args.pv_kwp > 0: - df = synthesize_pv(df, kwp=args.pv_kwp, - target_kwh_per_kwp_per_year=args.pv_yield) df["export_eur_per_kwh"] = df["eur_per_kwh"] if args.saldering else 0.0 + # Existing-PV export visible at the meter (any hour where raw_demand_kw < 0). + has_existing_pv = bool((df["raw_demand_kw"] < 0).any()) avg_epex = float(df["epex_eur_per_kwh"].mean()) store_params = StoreParams(avg_epex_eur_per_kwh=avg_epex) @@ -80,8 +77,9 @@ def main() -> None: # Cache LP results by (capacity, power) — many batteries share identical specs. lp_cache: dict[tuple[float, float], float] = {} - print(f"Scenario: demand {args.demand:.0f} kWh/yr, avg retail €{args.retail}, " - f"PV {args.pv_kwp} kWp, saldering={args.saldering}, inflation {args.inflation*100:.1f}%/yr") + print(f"Scenario: net demand {args.demand:.0f} kWh/yr, avg retail €{args.retail}, " + f"existing-PV exports={'yes' if has_existing_pv else 'no'}, " + f"saldering={args.saldering}, inflation {args.inflation*100:.1f}%/yr") print(f"Running {len(rows_in)} batteries (caching by (capacity, power))...") print() @@ -104,7 +102,7 @@ def main() -> None: scn = Scenario( capacity_kwh=r["capacity_kwh"], max_charge_kw=r["power_kw"], battery_cost_eur=r["price_eur"], avg_retail_eur_per_kwh=args.retail, - has_pv=args.pv_kwp > 0, has_saldering=args.saldering, dynamic_rate=True, + has_pv=has_existing_pv, has_saldering=args.saldering, dynamic_rate=True, ) sq = store_quote(scn, store_params, inflation=args.inflation) diff --git a/scripts/capacity_sweep.py b/scripts/capacity_sweep.py index 0f96b7c..40a6fd0 100644 --- a/scripts/capacity_sweep.py +++ b/scripts/capacity_sweep.py @@ -48,9 +48,9 @@ def main() -> None: base = load_hourly("data/raw"); base = apply_nl_tariff(base) # Scale to the test scenario. - annual_demand = base["demand_kwh"].sum() * (8766.0 / len(base)) + annual_demand = base["raw_demand_kw"].sum() * (8766.0 / len(base)) df = base.copy() - df["demand_kwh"] = base["demand_kwh"] * (args.demand / annual_demand) + df["raw_demand_kw"] = base["raw_demand_kw"] * (args.demand / annual_demand) scale = args.retail / base["eur_per_kwh"].mean() df["eur_per_kwh"] = base["eur_per_kwh"] * scale df["epex_eur_per_kwh"] = base["epex_eur_per_kwh"] * scale diff --git a/scripts/compare_batteries.py b/scripts/compare_batteries.py index 794ec17..ba5619a 100644 --- a/scripts/compare_batteries.py +++ b/scripts/compare_batteries.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 -"""Run the 24h-foresight oracle for several battery configs and print a comparison. +"""Run the price-arbitrage oracle for several battery configs and print a comparison. Saves per-hour CSVs for each config plus a summary table. Tariff: NL consumer. -With --pv-kwp > 0, synthesizes PV output from horizontal irradiance and treats -saldering as full export credit at consumer price (NL pre-2027). +The simulator works on the raw P1 net-meter trace — whatever PV is on the +roof is already netted in. Existing-PV exports show up as negative +``raw_demand_kw`` and are absorbed by the battery before crossing the meter. """ from __future__ import annotations @@ -18,7 +19,6 @@ from pluginbattery.sim import ( load_hourly, oracle_daily_schedule, simulate, - synthesize_pv, ) CONFIGS = [ @@ -79,14 +79,6 @@ def payback_years(year1_savings: float, cost: float, inflation: float = 0.0) -> def main() -> None: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--pv-kwp", type=float, default=0.0, - help="PV system size in kWp. 0 = no solar (default).", - ) - parser.add_argument( - "--pv-target", type=float, default=900.0, - help="Calibrate PV to this annual yield in kWh/kWp/year. Default 900 = NL norm.", - ) parser.add_argument( "--saldering", choices=["none", "full"], default="none", help="'full' = export at consumer price (NL pre-2027). " @@ -121,11 +113,12 @@ def main() -> None: elif args.price_mult != 1.0: df["eur_per_kwh"] = df["eur_per_kwh"] * args.price_mult - pv_note = "no solar" - if args.pv_kwp > 0: - df = synthesize_pv(df, kwp=args.pv_kwp, target_kwh_per_kwp_per_year=args.pv_target) - pv_kwh_year = float(df["pv_kwh"].sum() * 8766.0 / len(df)) - pv_note = f"{args.pv_kwp:.1f} kWp PV, calibrated to {pv_kwh_year:.0f} kWh/yr" + # Existing PV (if any) is already in raw_demand_kw via the meter signal. + annual_export = float(-df["raw_demand_kw"].clip(upper=0).sum() * 8766.0 / len(df)) + if annual_export > 1.0: + pv_note = f"{annual_export:.0f} kWh/yr exported at meter (existing PV)" + else: + pv_note = "no measurable existing PV in the data" if args.export_rate is not None: df["export_eur_per_kwh"] = float(args.export_rate) @@ -146,10 +139,10 @@ def main() -> None: print(f"Tariff: import = {price_note}; {sald_note}") if args.price_inflation > 0: print(f"Payback uses {args.price_inflation*100:.1f}%/yr energy-price inflation") - print(f"Solar : {pv_note}\n") + print(f"Meter : {pv_note}\n") # Headline figures with no battery, just for context. - g_no = df["demand_kwh"] - df.get("pv_kwh", 0) + g_no = df["raw_demand_kw"] imp_p = df["eur_per_kwh"] exp_p = df.get("export_eur_per_kwh", df["eur_per_kwh"]) cost_no_battery_total = float((g_no.where(g_no > 0, 0) * imp_p + @@ -187,7 +180,6 @@ def main() -> None: "max_charge_kw": battery.max_charge_kw, "max_discharge_kw": battery.max_discharge_kw, "price_eur": price, - "pv_kwp": args.pv_kwp, "saldering": args.saldering, "annual_savings_eur": round(savings, 2), "kwh_shifted": round(kwh_shifted, 1), diff --git a/scripts/compare_with_store.py b/scripts/compare_with_store.py index 7634fa8..4d793ea 100644 --- a/scripts/compare_with_store.py +++ b/scripts/compare_with_store.py @@ -10,8 +10,8 @@ Examples: python scripts/compare_with_store.py --capacity 5.12 --power 0.8 --cost 1339 --retail 0.25 python scripts/compare_with_store.py --capacity 5.12 --power 2.5 --cost 1339 --retail 0.25 - # PV scenario, fixed retail, no saldering: - python scripts/compare_with_store.py --pv-kwp 3.0 --fixed --retail 0.28 + # Fixed retail, no saldering: + python scripts/compare_with_store.py --fixed --retail 0.28 """ from __future__ import annotations @@ -23,7 +23,6 @@ from pluginbattery.sim import ( load_hourly, oracle_daily_schedule, simulate, - synthesize_pv, ) from pluginbattery.store_calc import ( Scenario, @@ -47,10 +46,6 @@ def main() -> None: help="Fixed-rate mode (no time-of-day variation; kills arbitrage)") p.add_argument("--saldering", action="store_true", help="Full saldering on (export = retail). Default: no saldering.") - # Solar - p.add_argument("--pv-kwp", type=float, default=0.0, help="PV size in kWp; 0 = no PV") - p.add_argument("--pv-yield", type=float, default=875.0, - help="Annual PV yield in kWh/kWp (store uses 875 in their footer)") # Inflation p.add_argument("--inflation", type=float, default=0.03) args = p.parse_args() @@ -59,9 +54,10 @@ def main() -> None: base = load_hourly("data/raw"); base = apply_nl_tariff(base) df = base.copy() - # Demand scaling. - annual_demand = base["demand_kwh"].sum() * (8766.0 / len(base)) - df["demand_kwh"] = base["demand_kwh"] * (args.demand / annual_demand) + # Demand scaling — scale the entire signed signal so existing-PV exports + # scale with the household. + annual_net_demand = base["raw_demand_kw"].sum() * (8766.0 / len(base)) + df["raw_demand_kw"] = base["raw_demand_kw"] * (args.demand / annual_net_demand) # Retail price. if args.fixed: @@ -73,16 +69,14 @@ def main() -> None: df["epex_eur_per_kwh"] = base["epex_eur_per_kwh"] * scale epex_in_use = df["epex_eur_per_kwh"] - # PV. - if args.pv_kwp > 0: - df = synthesize_pv(df, kwp=args.pv_kwp, target_kwh_per_kwp_per_year=args.pv_yield) - # Saldering. if args.saldering: df["export_eur_per_kwh"] = df["eur_per_kwh"] else: df["export_eur_per_kwh"] = 0.0 # post-saldering default in this script + has_existing_pv = bool((df["raw_demand_kw"] < 0).any()) + bat = Battery( capacity_kwh=args.capacity, max_charge_kw=args.power, @@ -102,7 +96,7 @@ def main() -> None: max_charge_kw=args.power, battery_cost_eur=args.cost, avg_retail_eur_per_kwh=args.retail, - has_pv=args.pv_kwp > 0, + has_pv=has_existing_pv, has_saldering=args.saldering, dynamic_rate=not args.fixed, ) @@ -114,9 +108,8 @@ def main() -> None: print(f" Tariff : avg retail €{args.retail}, " f"{'FIXED' if args.fixed else 'DYNAMIC'} rate, " f"{'WITH' if args.saldering else 'NO'} saldering") - print(f" PV : {args.pv_kwp} kWp" - f"{' @ ' + str(args.pv_yield) + ' kWh/kWp/yr' if args.pv_kwp else ''}") - print(f" Demand : {args.demand:.0f} kWh/yr") + print(f" PV : {'existing PV in meter signal' if has_existing_pv else 'no measurable existing PV'}") + print(f" Net demand : {args.demand:.0f} kWh/yr") print(f" Inflation : {args.inflation*100:.1f}%/yr") print() print(f"{'':22s} {'year-1 €':>10s} {'payback':>9s}") diff --git a/scripts/import_dad_p1.py b/scripts/import_dad_p1.py index 2595478..2c114e2 100644 --- a/scripts/import_dad_p1.py +++ b/scripts/import_dad_p1.py @@ -6,9 +6,9 @@ meter readings, every 15 min, 2025 calendar year, Europe/Amsterdam local time). Output: data/raw/p1_hourly.csv (timestamp UTC, power_w mean over hour). Net of PV exports — `power_w` can be negative when his solar exceeded household -demand. The simulator interprets `demand_kwh = power_w / 1000`, and a plug-in -battery's discharge cap `max(0, demand - pv)` correctly handles negative -demand by forbidding discharge during surplus hours. +demand. The simulator interprets `raw_demand_kw = power_w / 1000`, and a +plug-in battery's discharge cap `max(0, raw_demand_kw)` correctly forbids +discharge during net-export hours (the meter is already flowing the wrong way). The simulator's window is 2023-09-01 → 2024-09-01 UTC. Dad's data is the 2025 calendar year. We align by (month, day, hour) so seasonal patterns diff --git a/scripts/timeline_inspect.py b/scripts/timeline_inspect.py index 0cdc337..a7c6838 100644 --- a/scripts/timeline_inspect.py +++ b/scripts/timeline_inspect.py @@ -46,8 +46,8 @@ def main() -> None: # ─── Build the same df the web app sees ─── base = apply_nl_tariff(load_hourly("data/raw")) df = base.copy() - annual = base["demand_kwh"].sum() * 8766 / len(base) - df["demand_kwh"] = base["demand_kwh"] * (args.demand_kwh / annual) + annual = base["raw_demand_kw"].sum() * 8766 / len(base) + df["raw_demand_kw"] = base["raw_demand_kw"] * (args.demand_kwh / annual) scale = args.retail / base["eur_per_kwh"].mean() df["eur_per_kwh"] = base["eur_per_kwh"] * scale df["epex_eur_per_kwh"] = base["epex_eur_per_kwh"] * scale @@ -62,10 +62,10 @@ def main() -> None: out = out.assign( import_kwh=np.maximum(0.0, g), export_kwh=np.maximum(0.0, -g), - no_bat_grid=out["demand_kwh"], # equals the original net since pv=0 - cost_no_battery_eur=np.where(out["demand_kwh"] > 0, - out["demand_kwh"] * out["eur_per_kwh"], - out["demand_kwh"] * out["export_eur_per_kwh"]), + no_bat_grid=out["raw_demand_kw"], # equals the original net since pv=0 + cost_no_battery_eur=np.where(out["raw_demand_kw"] > 0, + out["raw_demand_kw"] * out["eur_per_kwh"], + out["raw_demand_kw"] * out["export_eur_per_kwh"]), cost_with_battery_eur=np.where(g > 0, g * out["eur_per_kwh"], g * out["export_eur_per_kwh"]), ) @@ -89,7 +89,7 @@ def main() -> None: print(f" {'hr':>2s} {'price':>5s} {'no-bat meter':>15s} " f"{'charge':>7s} {'disch':>7s} {'SoC':>5s} {'with-bat meter':>16s} {'sav':>6s}") for ts, r in day.iterrows(): - net_no = r["demand_kwh"] + net_no = r["raw_demand_kw"] mark = "↓ exp" if net_no < -0.01 else "↑ imp" if net_no > 0.01 else "·" grid = r["grid_kwh_with_battery"] grid_mark = "↓ exp" if grid < -0.01 else "↑ imp" if grid > 0.01 else "·" @@ -97,8 +97,8 @@ def main() -> None: f"{net_no:+7.2f} {mark:5s} " f"{r['charge_kwh']:>6.2f} {r['discharge_kwh']:>6.2f} " f"{r['soc_kwh']:>4.2f} {grid:+8.2f} {grid_mark:5s} €{r['savings_eur']:+5.2f}") - d_imp_no = max(0.0, day['demand_kwh'].clip(lower=0).sum()) - d_exp_no = -day['demand_kwh'].clip(upper=0).sum() + d_imp_no = max(0.0, day['raw_demand_kw'].clip(lower=0).sum()) + d_exp_no = -day['raw_demand_kw'].clip(upper=0).sum() d_imp_yes = day['import_kwh'].sum() d_exp_yes = day['export_kwh'].sum() d_sav = day['savings_eur'].sum() @@ -113,8 +113,8 @@ def main() -> None: week_savings = win["savings_eur"].sum() week_charge = win["charge_kwh"].sum() week_disch = win["discharge_kwh"].sum() - week_surplus = (-win["demand_kwh"].clip(upper=0)).sum() - week_imports = win["demand_kwh"].clip(lower=0).sum() + week_surplus = (-win["raw_demand_kw"].clip(upper=0)).sum() + week_imports = win["raw_demand_kw"].clip(lower=0).sum() annual_savings = out["savings_eur"].sum() annualised = week_savings * 365.25 / args.days diff --git a/src/pluginbattery/sim.py b/src/pluginbattery/sim.py index b8e3471..02fa178 100644 --- a/src/pluginbattery/sim.py +++ b/src/pluginbattery/sim.py @@ -1,13 +1,22 @@ """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: - load_hourly(): CSVs in -> single hourly DataFrame, missing-hour rows dropped. - simulate(df, bat, sched): walks SoC hour by hour, clamps to physical limits, returns trajectory + grid flows + costs. - - oracle_daily_schedule(): solves a 24h LP per UTC day for perfect-foresight - within-day arbitrage. No cross-day shifting. + - oracle_daily_schedule(): greedy self-consumption (Pass 1) + perfect-foresight + 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. """ from __future__ import annotations @@ -17,7 +26,6 @@ from pathlib import Path import numpy as np import pandas as pd -from scipy.optimize import linprog @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( df: pd.DataFrame, vat: float = 0.21, @@ -95,17 +70,16 @@ def apply_nl_tariff( 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 + - dropna). Adds a derived `demand_kwh` column = power_w / 1000 (since power_w - is the hour mean and 1 W * 1 h = 1 Wh). + Drops every hour where either signal is missing (inner join + dropna). + Adds ``raw_demand_kw = power_w / 1000`` — the signed P1 net meter + reading. Negative = exporting, positive = importing. """ data_dir = Path(data_dir) files = { "eur_per_kwh": "prices_hourly.csv", - "power_w": "p1_hourly.csv", - "irradiance_w_m2": "solar_hourly.csv", + "power_w": "p1_hourly.csv", } parts = [] 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") parts.append(sub[[col]]) 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 @@ -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*. Column conventions read by this function: - - eur_per_kwh: import price (always required) - - pv_kwh: optional, hourly PV production + - raw_demand_kw: signed meter reading (always required). +import, −export. + - eur_per_kwh: import price (always required). - export_eur_per_kwh: optional, export credit price. Defaults to eur_per_kwh (full saldering) if missing. - Per hour: `g = demand − pv + charge − discharge`. Cost uses the import - price when g > 0, the export price when g < 0. + Per hour: ``g = raw_demand_kw + charge − discharge``. Cost uses the + import price when g > 0, the export price when g < 0. """ n = len(df) 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) soc_arr = np.empty(n) - demand = df["demand_kwh"].to_numpy() - pv = df["pv_kwh"].to_numpy() if "pv_kwh" in df.columns else np.zeros(n) + raw = df["raw_demand_kw"].to_numpy() requested_c = schedule[:, 0] 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)) if not battery.allows_export: # Plug-in: cannot push current backwards through the meter. - # Discharge can't exceed the *net* demand after solar. - d = min(d, max(0.0, demand[t] - pv[t])) + # Discharge can't take the net flow below zero. + d = min(d, max(0.0, raw[t])) soc = soc + c * eta - d / eta # 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 out = df.copy() - if "pv_kwh" not in out.columns: - out["pv_kwh"] = 0.0 out["charge_kwh"] = c_arr out["discharge_kwh"] = d_arr out["soc_kwh"] = soc_arr @@ -190,7 +161,7 @@ def simulate(df: pd.DataFrame, battery: Battery, schedule: np.ndarray) -> pd.Dat else: 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 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 -def oracle_daily_schedule(df: pd.DataFrame, battery: Battery) -> np.ndarray: - """Two-pass dispatch: greedy self-consumption + daily grid arbitrage. +def _pass1_reactive(df: pd.DataFrame, battery: Battery) -> tuple[np.ndarray, np.ndarray]: + """Greedy self-consumption: charge from surplus, discharge to cover demand. - This matches what a modern plug-in battery with smart-charging firmware - (Tibber controllers, Marstek 'Smart Charging' mode, EcoFlow with dynamic - tariff integration) actually does in practice — and it always produces - 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. + Pure function of raw_demand_kw. No foresight — exactly the "default + mode" behaviour of every plug-in battery firmware out there. + Returns (schedule, soc) where soc has length n+1 (soc[0] = initial). """ cap = battery.capacity_kwh 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 n = len(df) - demand = df["demand_kwh"].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() + raw = df["raw_demand_kw"].to_numpy() schedule = np.zeros((n, 2)) soc = np.zeros(n + 1) soc[0] = battery.initial_soc_kwh - # ── Pass 1: greedy self-consumption ───────────────────────────────── for t in range(n): - if net[t] < -1e-9: # exporting - surplus = -net[t] + if raw[t] < -1e-9: # exporting (surplus from existing PV) + surplus = -raw[t] room = (cap - soc[t]) / eta if eta > 0 else 0.0 c = max(0.0, min(pc_max, surplus, room)) schedule[t, 0] = c 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 - 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)) schedule[t, 1] = d soc[t + 1] = max(0.0, soc[t] - d / eta) else: 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: - return schedule + return 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 = {} for i, ts in enumerate(df.index): 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] if room_c < 1e-9: 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] hr = (cap - tail.max()) / eta if tail.size else 0.0 if hr < 1e-9: @@ -291,25 +252,16 @@ def oracle_daily_schedule(df: pd.DataFrame, battery: Battery) -> np.ndarray: if room_d < 1e-9: continue if not battery.allows_export: - # Discharge cannot exceed demand at di. - room_d = min(room_d, net[di] - schedule[di, 1]) + # Plug-in: extra discharge limited by the post-Pass-1 import demand. + room_d = min(room_d, raw[di] - schedule[di, 1]) if room_d < 1e-9: 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 if profit <= 1e-6: continue - - # Max charge in AC kWh: limited by power, capacity headroom, - # discharge-side capacity. max_kwh = min(room_c, hr, room_d * inv_eta) if max_kwh < 1e-6: continue - if best is None or profit > best[2]: best = (ci, di, profit, max_kwh) if best is None: @@ -318,130 +270,20 @@ def oracle_daily_schedule(df: pd.DataFrame, battery: Battery) -> np.ndarray: ci, di, _, kwh = best schedule[ci, 0] += kwh 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): soc[t + 1] += kwh * eta - # Beyond di, soc[di+1] = previous - d/eta = previous - kwh - # We added kwh*eta at soc[di], then removed kwh*eta on the - # 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)) + # soc[di+1] onwards: +kwh*eta from charge cancels with -kwh*eta + # from discharge → unchanged. + +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 diff --git a/src/pluginbattery/static/app.js b/src/pluginbattery/static/app.js index 6a1f4fe..bb598f5 100644 --- a/src/pluginbattery/static/app.js +++ b/src/pluginbattery/static/app.js @@ -56,8 +56,6 @@ function readScenario() { return { demand_kwh: parseFloat(fd.get("demand_kwh")), retail: parseFloat(fd.get("retail")), - pv_kwp: parseFloat(fd.get("pv_kwp")), - pv_yield: 875.0, fixed_rate: fd.get("fixed_rate") === "true", saldering: fd.get("saldering") === "true", eta: parseFloat(fd.get("eta")), @@ -267,7 +265,7 @@ const tlSummary = mustFind("tl-summary"); let activeBattery = null; // the row clicked 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) { activeBattery = row; @@ -292,7 +290,7 @@ function openModalForBattery(row) { function closeModal() { modal.hidden = true; 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( @@ -325,7 +323,7 @@ async function refreshTimeline() { start: tlDate.value || "2024-06-17", days: tlDays.value, 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, }); if (sc.export_rate !== null) params.set("export_rate", sc.export_rate); @@ -356,12 +354,12 @@ function paintTimeline(d) { tlSummary.replaceChildren(); for (const [k, v] of [ ["window cost", `€${cost.toFixed(2)}`], - ["imports", `${imports.toFixed(1)} kWh`], - ["exports", `${exports_.toFixed(1)} kWh`], + ["imports", `${imports.toFixed(2)} kWh`], + ["exports", `${exports_.toFixed(2)} kWh`], ["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)`], - ["window saving", `€${savings.toFixed(2)}`], + ["window saving", `€${savings.toFixed(4)}`], ]) { const row = document.createElement("div"); const lbl = document.createElement("span"); lbl.textContent = k; @@ -370,26 +368,26 @@ function paintTimeline(d) { } // X axis: unix seconds (uPlot wants epoch seconds) - const xs = d.hours.map(h => Date.parse(h.ts) / 1000); - const irr = d.hours.map(h => h.irradiance); - const soc = batteryOn ? d.hours.map(h => h.soc) : d.hours.map(() => 0); - const grid = batteryOn ? d.hours.map(h => h.grid_with_bat) - : d.hours.map(h => h.grid_no_bat); + const xs = d.hours.map(h => Date.parse(h.ts) / 1000); + const price = d.hours.map(h => h.price); + const soc = batteryOn ? d.hours.map(h => h.soc) : d.hours.map(() => 0); + const grid = batteryOn ? d.hours.map(h => h.grid_with_bat) + : d.hours.map(h => h.grid_no_bat); - drawChart("irr", ["Time", "W/m²"], xs, irr, "tl-chart-irradiance", { - stroke: "#fbbf24", fill: "rgba(251, 191, 36, 0.2)", - fmtY: (v) => v == null ? "" : `${v.toFixed(0)} W/m²`, + drawChart("price", ["Time", "€/kWh"], xs, price, "tl-chart-price", { + stroke: "#a78bfa", fill: "rgba(167, 139, 250, 0.2)", + fmtY: (v) => v == null ? "" : `€${v.toFixed(3)}/kWh`, }); - 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`, - }); - drawChart("grid", ["Time", "kWh/h"], xs, grid, "tl-chart-grid", { + drawChart("grid", ["Time", "kWh/h"], xs, grid, "tl-chart-grid", { stroke: batteryOn ? "#93c5fd" : "#fb7185", fill: batteryOn ? "rgba(147, 197, 253, 0.18)" : "rgba(251, 113, 133, 0.18)", zeroline: true, 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 = {}) { diff --git a/src/pluginbattery/static/style.css b/src/pluginbattery/static/style.css index f3f4781..0c314f5 100644 --- a/src/pluginbattery/static/style.css +++ b/src/pluginbattery/static/style.css @@ -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 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 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 .chart { width: 100%; height: 110px; } -.modal-charts figure:nth-child(3) .chart { height: 200px; } /* taller grid panel */ +.modal-charts .chart { width: 100%; height: 150px; } +.modal-charts figure:nth-child(4) .chart { height: 230px; } /* taller grid panel (4th now) */ /* uPlot dark-theme tweaks */ .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 .u-marker { display: inline-block; } .uplot .u-axis { color: var(--muted); } diff --git a/src/pluginbattery/templates/index.html b/src/pluginbattery/templates/index.html index dcce9cf..6b3c90a 100644 --- a/src/pluginbattery/templates/index.html +++ b/src/pluginbattery/templates/index.html @@ -5,7 +5,7 @@ Honest battery payback — vs thuisbatterijgids.nl - + @@ -22,21 +22,18 @@