#!/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["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 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["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"]), ) 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} {'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["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 "·" print(f" {ts.hour:02d} €{r['eur_per_kwh']:.3f} " 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['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() 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["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 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()