Add 'cycles/yr' column — total throughput including grid arbitrage

This commit is contained in:
Michiel Berger 2026-04-30 22:02:33 +02:00
parent 9d43e1802b
commit 1804606ff6
3 changed files with 16 additions and 6 deletions

View file

@ -126,6 +126,7 @@ function renderTable(rows) {
td(`${fmtNum(r.lp_year1, 0)}`), td(`${fmtNum(r.lp_year1, 0)}`),
td(fmtPayback(r.lp_payback)), td(fmtPayback(r.lp_payback)),
td(`${fmtNum(r.ten_year_profit_eur, 0)}`, { className: tenYrCls }), 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_kwh, 0)),
td(`${fmtNum(r.captured_pct, 0)}%`), td(`${fmtNum(r.captured_pct, 0)}%`),
td(`${fmtNum(r.pct_hours_battery_full, 0)}%`), td(`${fmtNum(r.pct_hours_battery_full, 0)}%`),
@ -225,10 +226,11 @@ function captureInitialFromDOM() {
lp_year1: parseFloat(cells[5].textContent.replace("€", "")), lp_year1: parseFloat(cells[5].textContent.replace("€", "")),
lp_payback: cells[6].textContent.trim() === "—" ? null : parseFloat(cells[6].textContent), lp_payback: cells[6].textContent.trim() === "—" ? null : parseFloat(cells[6].textContent),
ten_year_profit_eur: parseFloat(cells[7].textContent.replace("€", "")), ten_year_profit_eur: parseFloat(cells[7].textContent.replace("€", "")),
captured_kwh: parseFloat(cells[8].textContent), cycles_per_year: parseFloat(cells[8].textContent),
captured_pct: parseFloat(cells[9].textContent), captured_kwh: parseFloat(cells[9].textContent),
pct_hours_battery_full: parseFloat(cells[10].textContent), captured_pct: parseFloat(cells[10].textContent),
pct_hours_charge_saturated: parseFloat(cells[11].textContent), pct_hours_battery_full: parseFloat(cells[11].textContent),
pct_hours_charge_saturated: parseFloat(cells[12].textContent),
}; };
}); });
} }

View file

@ -4,7 +4,7 @@
<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=13"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=14">
</head> </head>
<body> <body>
@ -104,6 +104,7 @@
<th data-sort="lp_year1" data-default-dir="desc" title="Honest year-1 savings (our LP)">LP €/yr</th> <th data-sort="lp_year1" data-default-dir="desc" title="Honest year-1 savings (our LP)">LP €/yr</th>
<th data-sort="lp_payback" data-default-dir="asc" title="Honest payback with inflation">LP yr</th> <th data-sort="lp_payback" data-default-dir="asc" title="Honest payback with inflation">LP yr</th>
<th data-sort="ten_year_profit_eur" data-default-dir="desc" title="Cumulative savings over 10 yrs (with inflation) minus the battery cost">10-yr net</th> <th data-sort="ten_year_profit_eur" data-default-dir="desc" title="Cumulative savings over 10 yrs (with inflation) minus the battery cost">10-yr net</th>
<th data-sort="cycles_per_year" data-default-dir="desc" title="Total discharge throughput / capacity. Includes both surplus capture AND grid arbitrage.">cycles</th>
<th data-sort="captured_kwh" data-default-dir="desc" title="kWh of solar surplus the battery actually absorbed (vs total surplus shown above)">kWh capt</th> <th data-sort="captured_kwh" data-default-dir="desc" title="kWh of solar surplus the battery actually absorbed (vs total surplus shown above)">kWh capt</th>
<th data-sort="captured_pct" data-default-dir="desc" title="Captured kWh as % of total available solar surplus">% capt</th> <th data-sort="captured_pct" data-default-dir="desc" title="Captured kWh as % of total available solar surplus">% capt</th>
<th data-sort="pct_hours_battery_full" data-default-dir="desc" title="% of surplus hours where the battery was already full and couldn't store more">% full</th> <th data-sort="pct_hours_battery_full" data-default-dir="desc" title="% of surplus hours where the battery was already full and couldn't store more">% full</th>
@ -121,6 +122,7 @@
<td>€{{ '%.0f' % r.lp_year1 }}</td> <td>€{{ '%.0f' % r.lp_year1 }}</td>
<td>{% if r.lp_payback %}{{ '%.1f' % r.lp_payback }}{% else %}—{% endif %}</td> <td>{% if r.lp_payback %}{{ '%.1f' % r.lp_payback }}{% else %}—{% endif %}</td>
<td class="{% if r.ten_year_profit_eur < 0 %}neg{% else %}pos{% endif %}">€{{ '%.0f' % r.ten_year_profit_eur }}</td> <td class="{% if r.ten_year_profit_eur < 0 %}neg{% else %}pos{% endif %}">€{{ '%.0f' % r.ten_year_profit_eur }}</td>
<td>{{ '%.0f' % r.cycles_per_year }}</td>
<td>{{ '%.0f' % r.captured_kwh }}</td> <td>{{ '%.0f' % r.captured_kwh }}</td>
<td>{{ '%.0f' % r.captured_pct }}%</td> <td>{{ '%.0f' % r.captured_pct }}%</td>
<td>{{ '%.0f' % r.pct_hours_battery_full }}%</td> <td>{{ '%.0f' % r.pct_hours_battery_full }}%</td>
@ -134,6 +136,7 @@
<dt>LP €/yr</dt> <dd>Honest year-1 savings from our linear-program oracle (perfect 24h foresight).</dd> <dt>LP €/yr</dt> <dd>Honest year-1 savings from our linear-program oracle (perfect 24h foresight).</dd>
<dt>LP yr</dt> <dd>Years until cumulative savings (with the chosen inflation rate) recoup the battery cost.</dd> <dt>LP yr</dt> <dd>Years until cumulative savings (with the chosen inflation rate) recoup the battery cost.</dd>
<dt>10-yr net</dt> <dd>Total profit over 10 years: cumulative savings (compounded by inflation) minus the battery cost. Red = doesn't break even within 10 yr.</dd> <dt>10-yr net</dt> <dd>Total profit over 10 years: cumulative savings (compounded by inflation) minus the battery cost. Red = doesn't break even within 10 yr.</dd>
<dt>cycles</dt> <dd>Total discharge throughput per year, divided by capacity. Includes <em>both</em> 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.</dd>
<dt>kWh capt</dt> <dd>kWh of capturable surplus (i.e. of grid exports that would happen without a battery) that this battery absorbs.</dd> <dt>kWh capt</dt> <dd>kWh of capturable surplus (i.e. of grid exports that would happen without a battery) that this battery absorbs.</dd>
<dt>% capt</dt> <dd>That, as a percentage of the scenario's capturable surplus shown in the heading. <em>Note:</em> 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.</dd> <dt>% capt</dt> <dd>That, as a percentage of the scenario's capturable surplus shown in the heading. <em>Note:</em> 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.</dd>
<dt>% full</dt> <dd>Of all surplus hours, the % when the battery was already full and couldn't store more — capacity-bottlenecked.</dd> <dt>% full</dt> <dd>Of all surplus hours, the % when the battery was already full and couldn't store more — capacity-bottlenecked.</dd>
@ -152,6 +155,6 @@
</p> </p>
</footer> </footer>
<script src="{{ url_for('static', filename='app.js') }}?v=13"></script> <script src="{{ url_for('static', filename='app.js') }}?v=14"></script>
</body> </body>
</html> </html>

View file

@ -124,11 +124,13 @@ def _worker_run_lp(spec):
pct_full = 0.0 pct_full = 0.0
pct_saturated = 0.0 pct_saturated = 0.0
discharge_total = float(out["discharge_kwh"].sum())
return spec, { return spec, {
"year1": float(out["savings"].sum()), "year1": float(out["savings"].sum()),
"captured_kwh": captured_kwh, "captured_kwh": captured_kwh,
"pct_full": pct_full, "pct_full": pct_full,
"pct_saturated": pct_saturated, "pct_saturated": pct_saturated,
"discharge_kwh": discharge_total,
} }
@ -240,6 +242,8 @@ def compute_leaderboard(
captured_kwh = payload["captured_kwh"] captured_kwh = payload["captured_kwh"]
pct_full = payload["pct_full"] pct_full = payload["pct_full"]
pct_saturated = payload["pct_saturated"] 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 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)
@ -272,6 +276,7 @@ def compute_leaderboard(
"lp_year1": round(lp_year1, 2), "lp_year1": round(lp_year1, 2),
"lp_payback": round(lp_payback, 2) if np.isfinite(lp_payback) else None, "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), "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_kwh": round(captured_kwh, 1),
"captured_pct": round(captured_pct, 1), "captured_pct": round(captured_pct, 1),
"pct_hours_battery_full": round(pct_full, 1), "pct_hours_battery_full": round(pct_full, 1),