Battery timeline modal + switch to real 2025 data

Modal:
- Click any battery row to open a modal with three uPlot charts:
  irradiance (W/m²), battery SoC (kWh), net meter flow (kWh/h).
- Date picker + prev/next buttons + 1/3/7-day window selector.
- 'Battery on' toggle flips the SoC and grid charts to the
  no-battery baseline; summary numbers update accordingly.
- New /api/timeline endpoint returns the full per-hour series for
  any (battery, scenario, window).

Data:
- Re-exported prices + solar from data-vm for 2025-01-01 → 2025-09-01
  (8 months — solar coverage limit). All three signals now share
  real 2025 timestamps; no more month/day alignment fudge.
- Re-processed dad's CSV against the new window so power_w is real
  net consumption per actual hour.
- Compute_leaderboard now applies an 8766/window-hours annualisation
  factor to year-1 savings, capt kWh, throughput, and total surplus
  so payback / 10-yr net stay correct on the shorter window.
- Defaults: demand 1010 (= dad's net over 8 months, scale 1.0),
  start 2025-06-15.

Tests pass. Local run: PORT=8765 uv run python -m pluginbattery.web,
click any row.
This commit is contained in:
Michiel Berger 2026-05-01 09:21:16 +02:00
parent 10cc32ddc0
commit b39501ca21
7 changed files with 17794 additions and 26343 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -255,3 +255,179 @@ captureInitialFromDOM();
repaint(); // honour saved filter + sort on first paint repaint(); // honour saved filter + sort on first paint
form.addEventListener("submit", (e) => { e.preventDefault(); recalc(); }); form.addEventListener("submit", (e) => { e.preventDefault(); recalc(); });
// ─── Battery timeline modal ────────────────────────────────────────────
const modal = document.getElementById("timeline-modal");
const tlTitle = document.getElementById("tl-title");
const tlDate = document.getElementById("tl-date");
const tlDays = document.getElementById("tl-days");
const tlOnBox = document.getElementById("tl-battery-on");
const tlPrev = document.getElementById("tl-prev");
const tlNext = document.getElementById("tl-next");
const tlStatus = document.getElementById("tl-status");
const tlSummary = document.getElementById("tl-summary");
let activeBattery = null; // the row clicked
let tlData = null; // last fetched timeline payload
let charts = { irr: null, soc: null, grid: null };
function openModalForBattery(row) {
activeBattery = row;
tlTitle.textContent = `${row.title}${fmtNum(row.capacity_kwh)} kWh / ${fmtNum(row.power_kw, 1)} kW`;
if (!tlDate.value) tlDate.value = "2025-06-15";
modal.hidden = false;
refreshTimeline();
}
function closeModal() {
modal.hidden = true;
for (const c of Object.values(charts)) c?.destroy?.();
charts = { irr: null, soc: null, grid: null };
}
modal.querySelectorAll('[data-close="modal"]').forEach(
(el) => el.addEventListener("click", closeModal),
);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !modal.hidden) closeModal();
});
function shiftDate(days) {
const d = new Date(tlDate.value || "2024-06-17");
d.setUTCDate(d.getUTCDate() + days);
tlDate.value = d.toISOString().slice(0, 10);
}
tlPrev.addEventListener("click", () => { shiftDate(-parseInt(tlDays.value, 10)); refreshTimeline(); });
tlNext.addEventListener("click", () => { shiftDate(+parseInt(tlDays.value, 10)); refreshTimeline(); });
tlDate.addEventListener("change", refreshTimeline);
tlDays.addEventListener("change", refreshTimeline);
tlOnBox.addEventListener("change", () => paintTimeline(tlData)); // local toggle, no refetch
async function refreshTimeline() {
if (!activeBattery) return;
tlStatus.textContent = "Loading…";
const sc = readScenario();
const params = new URLSearchParams({
cap: activeBattery.capacity_kwh,
power: activeBattery.power_kw,
allows_export: activeBattery.category === "hybrid",
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,
saldering: sc.saldering, eta: sc.eta,
});
if (sc.export_rate !== null) params.set("export_rate", sc.export_rate);
try {
const r = await fetch(`/api/timeline?${params}`);
if (!r.ok) {
const err = await r.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${r.status}`);
}
tlData = await r.json();
paintTimeline(tlData);
tlStatus.textContent = "";
} catch (e) {
tlStatus.textContent = `Error: ${e.message}`;
}
}
function paintTimeline(d) {
if (!d) return;
const batteryOn = tlOnBox.checked;
// Summary, switching when toggle flips.
const s = d.summary;
const cost = batteryOn ? s.cost_with_battery_eur : s.cost_no_battery_eur;
const imports = batteryOn ? s.imports_with_bat : s.imports_no_bat;
const exports_ = batteryOn ? s.exports_with_bat : s.exports_no_bat;
const savings = batteryOn ? s.savings_eur : 0;
tlSummary.replaceChildren();
for (const [k, v] of [
["window cost", `${cost.toFixed(2)}`],
["imports", `${imports.toFixed(1)} kWh`],
["exports", `${exports_.toFixed(1)} kWh`],
["battery throughput", batteryOn
? `${s.battery_charge_kwh.toFixed(1)} in / ${s.battery_discharge_kwh.toFixed(1)} out kWh`
: `— (battery off)`],
["window saving", `${savings.toFixed(2)}`],
]) {
const row = document.createElement("div");
const lbl = document.createElement("span"); lbl.textContent = k;
const val = document.createElement("strong"); val.textContent = v;
row.append(lbl, val); tlSummary.appendChild(row);
}
// 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);
drawChart("irr", ["Time", "W/m²"], xs, irr, "tl-chart-irradiance",
{ stroke: "#fbbf24", fill: "rgba(251, 191, 36, 0.2)" });
drawChart("soc", ["Time", "kWh"], xs, soc, "tl-chart-soc",
{ stroke: "#4ade80", fill: "rgba(74, 222, 128, 0.2)" });
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 });
}
function drawChart(key, axes, xs, ys, containerId, opts = {}) {
const el = document.getElementById(containerId);
if (charts[key]) charts[key].destroy();
el.innerHTML = "";
const data = [xs, ys];
const u = new uPlot({
width: el.clientWidth, height: el.clientHeight,
cursor: { y: false, lock: false },
legend: { show: false },
series: [
{},
{
stroke: opts.stroke || "#93c5fd",
fill: opts.fill,
width: 1.5,
points: { show: false },
},
],
axes: [
{ stroke: "#9aa6b2",
grid: { stroke: "#2a323b", width: 1 },
ticks: { stroke: "#2a323b" } },
{ stroke: "#9aa6b2",
grid: { stroke: "#2a323b", width: 1 },
ticks: { stroke: "#2a323b" },
size: 45 },
],
hooks: opts.zeroline ? {
drawAxes: [(u) => {
const ctx = u.ctx;
const y0 = u.valToPos(0, "y", true);
ctx.save();
ctx.strokeStyle = "#9aa6b2"; ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(u.bbox.left, y0);
ctx.lineTo(u.bbox.left + u.bbox.width, y0);
ctx.stroke();
ctx.restore();
}],
} : {},
}, data, el);
charts[key] = u;
}
// Hook up battery row clicks.
tbody.addEventListener("click", (e) => {
// Ignore link clicks — let those go to the shop page if user explicitly wants it.
if (e.target.closest("a")) return;
const tr = e.target.closest("tr");
if (!tr) return;
const idx = Array.from(tbody.children).indexOf(tr);
const visible = applySort(applyFilter(lastBatteries));
if (visible[idx]) openModalForBattery(visible[idx]);
});

View file

@ -200,6 +200,53 @@ dl.legend {
} }
dl.legend dt { font-weight: 600; color: var(--text); } dl.legend dt { font-weight: 600; color: var(--text); }
dl.legend dd { margin: 0; } dl.legend dd { margin: 0; }
/* Make leaderboard rows look clickable */
table#leaderboard tbody tr { cursor: pointer; }
table#leaderboard tbody tr a { color: inherit; text-decoration: underline dotted; text-underline-offset: 2px; }
/* Modal */
.modal { position: fixed; inset: 0; z-index: 100; display: flex;
align-items: stretch; justify-content: center; }
.modal[hidden] { display: none; }
.modal-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.7); }
.modal-card {
position: relative; background: var(--bg); border: 1px solid var(--border);
border-radius: 10px; max-width: 1200px; width: calc(100% - 2rem);
margin: 1rem auto; padding: 1.25rem; overflow-y: auto; max-height: calc(100vh - 2rem);
}
.modal-card header { display: flex; justify-content: space-between;
align-items: baseline; margin-bottom: 0.75rem; }
.modal-card h2 { margin: 0; font-size: 1.1rem; }
.modal-close { background: none; border: 0; font-size: 1.6rem; line-height: 1;
color: var(--muted); cursor: pointer; padding: 0 0.5rem; }
.modal-close:hover { color: var(--text); }
.modal-controls { display: flex; gap: 0.6rem; align-items: center;
flex-wrap: wrap; margin-bottom: 0.75rem; font-size: 0.85rem; }
.modal-controls button, .modal-controls input, .modal-controls select {
background: var(--panel2); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 0.35rem 0.6rem; font-size: 0.85rem;
}
.modal-controls button { cursor: pointer; }
.modal-summary {
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.5rem 1.25rem; padding: 0.6rem 0.75rem; background: var(--panel2);
border: 1px solid var(--border); border-radius: 8px; margin-bottom: 0.75rem;
font-size: 0.85rem;
}
.modal-summary div { display: flex; justify-content: space-between; gap: 0.5rem; }
.modal-summary div strong { color: var(--accent); }
.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; }
.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 */
/* uPlot dark-theme tweaks */
.uplot { color: var(--text); }
.uplot .u-legend { color: var(--text); font-size: 0.75rem; }
.uplot .u-axis { color: var(--muted); }
table#leaderboard tbody tr:hover { background: var(--panel2); } table#leaderboard tbody tr:hover { background: var(--panel2); }
table#leaderboard tbody tr:first-child { background: rgba(74, 222, 128, 0.08); } table#leaderboard tbody tr:first-child { background: rgba(74, 222, 128, 0.08); }

View file

@ -4,7 +4,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<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="{{ url_for('static', filename='style.css') }}?v=14"> <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=16">
</head> </head>
<body> <body>
@ -155,6 +156,44 @@
</p> </p>
</footer> </footer>
<script src="{{ url_for('static', filename='app.js') }}?v=14"></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=16"></script>
<div id="timeline-modal" class="modal" hidden>
<div class="modal-backdrop" data-close="modal"></div>
<div class="modal-card">
<header>
<h2 id="tl-title">Battery timeline</h2>
<button class="modal-close" data-close="modal" aria-label="close">×</button>
</header>
<div class="modal-controls">
<button id="tl-prev">◀ prev day</button>
<input type="date" id="tl-date">
<button id="tl-next">next day ▶</button>
<select id="tl-days">
<option value="1">1 day</option>
<option value="3">3 days</option>
<option value="7" selected>7 days</option>
</select>
<label class="filter-toggle"><input type="checkbox" id="tl-battery-on" checked> battery on</label>
<span id="tl-status" class="status"></span>
</div>
<div id="tl-summary" class="modal-summary"></div>
<div class="modal-charts">
<figure>
<figcaption>Solar irradiance — proxy for sun (W/m², from our weather station, season-aligned)</figcaption>
<div id="tl-chart-irradiance" class="chart"></div>
</figure>
<figure>
<figcaption>Battery state of charge (kWh)</figcaption>
<div id="tl-chart-soc" class="chart"></div>
</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>
</body> </body>
</html> </html>

View file

@ -180,9 +180,17 @@ def compute_leaderboard(
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.,
# 8 months when solar coverage limits the overlap). Scale all per-window
# 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)
# Total PV / surplus available across the whole scenario (no battery). # Total PV / surplus available across the whole scenario (no battery).
pv_col = df["pv_kwh"].to_numpy() if "pv_kwh" in df.columns else np.zeros(len(df)) pv_col = df["pv_kwh"].to_numpy() if "pv_kwh" in df.columns else np.zeros(len(df))
total_surplus_kwh = float(np.maximum(0.0, pv_col - df["demand_kwh"].to_numpy()).sum()) window_surplus = float(np.maximum(0.0, pv_col - df["demand_kwh"].to_numpy()).sum())
total_surplus_kwh = window_surplus * annualize
# Collect every distinct (capacity, power) the catalog asks for, then run # Collect every distinct (capacity, power) the catalog asks for, then run
# the per-spec LPs in parallel. linprog/HiGHS release the GIL so threads # the per-spec LPs in parallel. linprog/HiGHS release the GIL so threads
@ -238,11 +246,11 @@ def compute_leaderboard(
rows = [] rows = []
for b, cap, pw, price, allows_export in valid_rows: for b, cap, pw, price, allows_export in valid_rows:
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"] lp_year1 = payload["year1"] * annualize
captured_kwh = payload["captured_kwh"] captured_kwh = payload["captured_kwh"] * annualize
pct_full = payload["pct_full"] pct_full = payload["pct_full"] # already a ratio
pct_saturated = payload["pct_saturated"] pct_saturated = payload["pct_saturated"] # already a ratio
discharge_kwh = payload["discharge_kwh"] 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
lp_payback = payback_years(lp_year1, price, inflation) lp_payback = payback_years(lp_year1, price, inflation)
@ -313,10 +321,11 @@ def compute_leaderboard(
app = Flask(__name__) app = Flask(__name__)
DEFAULTS = dict( DEFAULTS = dict(
# Dad's actual: 2350 kWh/yr net (P1 data already nets his 3 kWp PV # Window: 2025-01-01 → 2025-09-01 (8 months of dad's actual data with
# exports), retail €0.25, terugleveringskosten €0.106/kWh. # matching 2025 prices + solar). Net consumption over those 8 months
# Set pv_kwp = 0 because the 3 kWp solar is already inside the P1. # is 1010 kWh; setting demand_kwh = 1010 gives scale factor 1.0 so the
demand_kwh=2350.0, retail=0.25, pv_kwp=0.0, pv_yield=875.0, # P1 series passes through unscaled.
demand_kwh=1010.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.106, export_rate=-0.106,
) )
@ -356,6 +365,99 @@ def calculate():
return jsonify(result) return jsonify(result)
@app.route("/api/timeline")
def timeline():
"""Per-hour LP 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
try:
cap = float(q.get("cap", "1.92"))
power = float(q.get("power", "0.8"))
allows_export = q.get("allows_export", "false").lower() == "true"
start_raw = q.get("start", "2025-06-15")
days = int(q.get("days", "7"))
demand_kwh = float(q.get("demand_kwh", DEFAULTS["demand_kwh"]))
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"
saldering = q.get("saldering", "false").lower() == "true"
eta = float(q.get("eta", DEFAULTS["eta"]))
raw_export = q.get("export_rate")
if raw_export in (None, "", "null"):
export_rate = None
else:
export_rate = float(raw_export)
except (TypeError, ValueError) as e:
return jsonify({"error": f"bad query param: {e}"}), 400
df = _build_df(demand_kwh, retail, pv_kwp, pv_yield, fixed_rate, saldering, export_rate)
bat = Battery(
capacity_kwh=cap, max_charge_kw=power, max_discharge_kw=power,
round_trip_eff=eta, allows_export=allows_export,
)
out = simulate(df, bat, oracle_daily_schedule(df, bat))
import pandas as _pd
start_ts = _pd.Timestamp(start_raw, tz="UTC")
end_ts = start_ts + _pd.Timedelta(days=days)
win = out.loc[(out.index >= start_ts) & (out.index < end_ts)]
if win.empty:
return jsonify({"error": f"no data for {start_raw} + {days} days "
f"(window: {out.index[0].date()}..{out.index[-1].date()})"}), 400
g_no = (win["demand_kwh"] - win.get("pv_kwh", 0)).to_numpy()
g_yes = win["grid_kwh_with_battery"].to_numpy()
imp_p = win["eur_per_kwh"].to_numpy()
exp_p = win.get("export_eur_per_kwh", win["eur_per_kwh"]).to_numpy()
cost_no = np.where(g_no > 0, g_no * imp_p, g_no * exp_p)
cost_yes = np.where(g_yes > 0, g_yes * imp_p, g_yes * exp_p)
hours_payload = []
for i, (ts, r) in enumerate(win.iterrows()):
hours_payload.append({
"ts": ts.isoformat(),
"irradiance": float(r.get("irradiance_w_m2", 0.0)),
"price": float(r["eur_per_kwh"]),
"export_price": float(r.get("export_eur_per_kwh", r["eur_per_kwh"])),
"soc": float(r["soc_kwh"]),
"charge": float(r["charge_kwh"]),
"discharge": float(r["discharge_kwh"]),
"grid_no_bat": float(g_no[i]),
"grid_with_bat": float(g_yes[i]),
"cost_no_bat": float(cost_no[i]),
"cost_with_bat": float(cost_yes[i]),
})
return jsonify({
"battery": {"capacity_kwh": cap, "power_kw": power,
"allows_export": allows_export},
"scenario": {"demand_kwh": demand_kwh, "retail": retail,
"pv_kwp": pv_kwp, "saldering": saldering,
"fixed_rate": fixed_rate, "eta": eta,
"export_rate": export_rate},
"window": {"start": start_ts.isoformat(), "days": days,
"hours": len(hours_payload)},
"summary": {
"cost_no_battery_eur": float(cost_no.sum()),
"cost_with_battery_eur": float(cost_yes.sum()),
"savings_eur": float((cost_no - cost_yes).sum()),
"imports_no_bat": float(np.maximum(g_no, 0).sum()),
"imports_with_bat": float(np.maximum(g_yes, 0).sum()),
"exports_no_bat": float(-np.minimum(g_no, 0).sum()),
"exports_with_bat": float(-np.minimum(g_yes, 0).sum()),
"battery_charge_kwh": float(win["charge_kwh"].sum()),
"battery_discharge_kwh": float(win["discharge_kwh"].sum()),
},
"hours": hours_payload,
})
@app.route("/healthz") @app.route("/healthz")
def healthz(): def healthz():
return {"ok": True, "rows": len(_BASE_DF), "catalog": len(_CATALOG)} return {"ok": True, "rows": len(_BASE_DF), "catalog": len(_CATALOG)}