From 2f05a40d40f4ac6888a38f8d6b7c1fda4ae2956d Mon Sep 17 00:00:00 2001 From: Michiel Berger Date: Thu, 30 Apr 2026 22:37:05 +0200 Subject: [PATCH] =?UTF-8?q?Add=20timeline=20inspector=20=E2=80=94=20print?= =?UTF-8?q?=20hour-by-hour=20LP=20schedule=20for=20any=20week?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/timeline_inspect.py | 134 ++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 scripts/timeline_inspect.py diff --git a/scripts/timeline_inspect.py b/scripts/timeline_inspect.py new file mode 100644 index 0000000..644152b --- /dev/null +++ b/scripts/timeline_inspect.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Print the hour-by-hour LP schedule for a battery over a chosen 7 days. + +Same LP we use everywhere else — the "shortcut" is just the per-day LP +optimum aggregated to a yearly number. This script shows the disaggregated +trajectory so you can read the actual decisions. + +Default scenario matches the web app's defaults (dad's P1, retail €0.25, +terugleveringskosten −€0.106). Pick a different week with --start. + +Usage: + uv run python scripts/timeline_inspect.py + uv run python scripts/timeline_inspect.py --start 2024-06-17 --days 7 +""" +from __future__ import annotations + +import argparse +import datetime as dt + +import numpy as np +import pandas as pd + +from pluginbattery.sim import ( + Battery, apply_nl_tariff, load_hourly, oracle_daily_schedule, simulate, +) + + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--start", type=lambda s: dt.date.fromisoformat(s), + default=dt.date(2024, 6, 17), + help="First UTC day to print (yyyy-mm-dd). Default: a sunny mid-June week.") + p.add_argument("--days", type=int, default=7) + p.add_argument("--retail", type=float, default=0.25) + p.add_argument("--export-rate", type=float, default=-0.106) + p.add_argument("--demand-kwh", type=float, default=2350.0) + + # Zendure SolarFlow 800 Plus + p.add_argument("--cap", type=float, default=1.92) + p.add_argument("--power", type=float, default=0.8) + p.add_argument("--eta", type=float, default=0.88) + p.add_argument("--cost", type=float, default=479.0) + + args = p.parse_args() + + # ─── 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) + scale = args.retail / base["eur_per_kwh"].mean() + df["eur_per_kwh"] = base["eur_per_kwh"] * scale + df["epex_eur_per_kwh"] = base["epex_eur_per_kwh"] * scale + df["export_eur_per_kwh"] = float(args.export_rate) + + bat = Battery(args.cap, args.power, args.power, args.eta, allows_export=False) + schedule = oracle_daily_schedule(df, bat) + out = simulate(df, bat, schedule) + + # Pre-compute import / export split for clarity. + g = out["grid_kwh_with_battery"] + 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"]), + cost_with_battery_eur=np.where(g > 0, g * out["eur_per_kwh"], + g * out["export_eur_per_kwh"]), + ) + out["savings_eur"] = out["cost_no_battery_eur"] - out["cost_with_battery_eur"] + + # ─── Slice the chosen window ─── + start = pd.Timestamp(args.start, tz="UTC") + end = start + pd.Timedelta(days=args.days) + win = out.loc[(out.index >= start) & (out.index < end)].copy() + if win.empty: + raise SystemExit(f"No data in range {start} .. {end}; window is " + f"{out.index[0].date()} .. {out.index[-1].date()}") + + # ─── Print per-day blocks ─── + print(f"Battery: {args.cap} kWh / {args.power} kW / €{args.cost:.0f} ; " + f"η_rt = {args.eta}; retail €{args.retail}; export €{args.export_rate}") + print(f"Window: {start.date()} → {end.date()-pd.Timedelta(days=1)} ({len(win)} hours)\n") + + for date, day in win.groupby(win.index.date): + print(f"── {date} ────────────────────────────────────────────────────────────────") + print(f" {'hr':>2s} {'price':>5s} {'net (no bat)':>13s} " + f"{'charge':>7s} {'disch':>7s} {'SoC':>5s} {'grid':>7s} {'sav':>6s}") + for ts, r in day.iterrows(): + net_no = r["demand_kwh"] + mark = "↓ surplus" if net_no < -0.01 else "↑ import" if net_no > 0.01 else "·" + grid = r["grid_kwh_with_battery"] + grid_str = f"{grid:+6.2f}" + print(f" {ts.hour:02d} €{r['eur_per_kwh']:.3f} " + f"{net_no:+8.2f} {mark:5s} " + f"{r['charge_kwh']:>6.2f} {r['discharge_kwh']:>6.2f} " + f"{r['soc_kwh']:>4.2f} {grid_str:>7s} €{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_yes = day['import_kwh'].sum() + d_exp_yes = day['export_kwh'].sum() + d_sav = day['savings_eur'].sum() + d_charge = day['charge_kwh'].sum() + d_discharge = day['discharge_kwh'].sum() + print(f" day total imports: {d_imp_no:5.1f} → {d_imp_yes:5.1f} " + f"exports: {d_exp_no:5.1f} → {d_exp_yes:5.1f} " + f"battery in/out: {d_charge:5.2f}/{d_discharge:5.2f} kWh " + f"SAVED €{d_sav:+.2f}\n") + + # ─── Window aggregate vs annual rate ─── + 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() + + annual_savings = out["savings_eur"].sum() + annualised = week_savings * 365.25 / args.days + + print(f"=== {args.days}-day totals ===") + print(f" imports without bat : {week_imports:6.1f} kWh") + print(f" surplus without bat : {week_surplus:6.1f} kWh") + print(f" battery throughput : charge {week_charge:.1f} / discharge {week_disch:.1f} kWh " + f"({week_disch/args.cap:.1f} cycles)") + print(f" savings this {args.days:>2d} days: €{week_savings:6.2f}") + print(f" annualised → 365 days : €{annualised:6.2f}") + print(f" full-year LP savings : €{annual_savings:6.2f} " + f"(this week is {week_savings/annual_savings*100:.1f}% of annual)") + + +if __name__ == "__main__": + main()