From 1804606ff6291e46ccebbe40e788907f16f386bc Mon Sep 17 00:00:00 2001 From: Michiel Berger Date: Thu, 30 Apr 2026 22:02:33 +0200 Subject: [PATCH] =?UTF-8?q?Add=20'cycles/yr'=20column=20=E2=80=94=20total?= =?UTF-8?q?=20throughput=20including=20grid=20arbitrage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pluginbattery/static/app.js | 10 ++++++---- src/pluginbattery/templates/index.html | 7 +++++-- src/pluginbattery/web.py | 5 +++++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/pluginbattery/static/app.js b/src/pluginbattery/static/app.js index 0051eb1..d3853c4 100644 --- a/src/pluginbattery/static/app.js +++ b/src/pluginbattery/static/app.js @@ -126,6 +126,7 @@ function renderTable(rows) { td(`€${fmtNum(r.lp_year1, 0)}`), td(fmtPayback(r.lp_payback)), td(`€${fmtNum(r.ten_year_profit_eur, 0)}`, { className: tenYrCls }), + td(fmtNum(r.cycles_per_year, 0)), td(fmtNum(r.captured_kwh, 0)), td(`${fmtNum(r.captured_pct, 0)}%`), td(`${fmtNum(r.pct_hours_battery_full, 0)}%`), @@ -225,10 +226,11 @@ function captureInitialFromDOM() { lp_year1: parseFloat(cells[5].textContent.replace("€", "")), lp_payback: cells[6].textContent.trim() === "—" ? null : parseFloat(cells[6].textContent), ten_year_profit_eur: parseFloat(cells[7].textContent.replace("€", "")), - captured_kwh: parseFloat(cells[8].textContent), - captured_pct: parseFloat(cells[9].textContent), - pct_hours_battery_full: parseFloat(cells[10].textContent), - pct_hours_charge_saturated: parseFloat(cells[11].textContent), + cycles_per_year: parseFloat(cells[8].textContent), + captured_kwh: parseFloat(cells[9].textContent), + captured_pct: parseFloat(cells[10].textContent), + pct_hours_battery_full: parseFloat(cells[11].textContent), + pct_hours_charge_saturated: parseFloat(cells[12].textContent), }; }); } diff --git a/src/pluginbattery/templates/index.html b/src/pluginbattery/templates/index.html index 21ae52b..4ccaf57 100644 --- a/src/pluginbattery/templates/index.html +++ b/src/pluginbattery/templates/index.html @@ -4,7 +4,7 @@ Honest battery payback — vs thuisbatterijgids.nl - + @@ -104,6 +104,7 @@ LP €/yr LP yr 10-yr net + cycles kWh capt % capt % full @@ -121,6 +122,7 @@ €{{ '%.0f' % r.lp_year1 }} {% if r.lp_payback %}{{ '%.1f' % r.lp_payback }}{% else %}—{% endif %} €{{ '%.0f' % r.ten_year_profit_eur }} + {{ '%.0f' % r.cycles_per_year }} {{ '%.0f' % r.captured_kwh }} {{ '%.0f' % r.captured_pct }}% {{ '%.0f' % r.pct_hours_battery_full }}% @@ -134,6 +136,7 @@
LP €/yr
Honest year-1 savings from our linear-program oracle (perfect 24h foresight).
LP yr
Years until cumulative savings (with the chosen inflation rate) recoup the battery cost.
10-yr net
Total profit over 10 years: cumulative savings (compounded by inflation) minus the battery cost. Red = doesn't break even within 10 yr.
+
cycles
Total discharge throughput per year, divided by capacity. Includes both surplus capture and grid arbitrage (charging during cheap hours from the grid even when no surplus is exporting). Useful as a "how busy is the battery" metric.
kWh capt
kWh of capturable surplus (i.e. of grid exports that would happen without a battery) that this battery absorbs.
% capt
That, as a percentage of the scenario's capturable surplus shown in the heading. Note: the denominator is exports, not gross PV — solar that's directly self-consumed during sunny hours never crosses the meter and is invisible to a battery.
% full
Of all surplus hours, the % when the battery was already full and couldn't store more — capacity-bottlenecked.
@@ -152,6 +155,6 @@

- + diff --git a/src/pluginbattery/web.py b/src/pluginbattery/web.py index f6d1896..3ef220f 100644 --- a/src/pluginbattery/web.py +++ b/src/pluginbattery/web.py @@ -124,11 +124,13 @@ def _worker_run_lp(spec): pct_full = 0.0 pct_saturated = 0.0 + discharge_total = float(out["discharge_kwh"].sum()) return spec, { "year1": float(out["savings"].sum()), "captured_kwh": captured_kwh, "pct_full": pct_full, "pct_saturated": pct_saturated, + "discharge_kwh": discharge_total, } @@ -240,6 +242,8 @@ def compute_leaderboard( captured_kwh = payload["captured_kwh"] pct_full = payload["pct_full"] pct_saturated = payload["pct_saturated"] + discharge_kwh = payload["discharge_kwh"] + 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 lp_payback = payback_years(lp_year1, price, inflation) @@ -272,6 +276,7 @@ def compute_leaderboard( "lp_year1": round(lp_year1, 2), "lp_payback": round(lp_payback, 2) if np.isfinite(lp_payback) else None, "ten_year_profit_eur": round(_lifetime_profit(lp_year1, price, 10, inflation), 2), + "cycles_per_year": round(cycles_per_year, 1), "captured_kwh": round(captured_kwh, 1), "captured_pct": round(captured_pct, 1), "pct_hours_battery_full": round(pct_full, 1),