pluginbattery/scripts/timeline_inspect.py

134 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

#!/usr/bin/env python3
"""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()