Add solar-capture columns; drop Store columns from leaderboard

New columns:
- kWh capt:  absolute kWh of solar surplus the battery absorbed
- % capt:    that, as a % of total available surplus for the scenario
- % full:    of all surplus hours, the % when the battery was already full
- % sat:     of all surplus hours, the % when charging was at max kW
The leaderboard heading also shows total scenario surplus in kWh/yr so
the percentages have a denominator.

These make the limits visible: e.g. on dad's data, an 0.8 kW plug-in is
saturated 19% of surplus hours, while a 2.5 kW unit is never saturated
(0%) — but both end up bottlenecked by 'battery full' on multi-day sunny
stretches when demand can't drain it overnight.

Removed: Store €/yr, Store yr, ×over columns. The store_calc module is
still in the codebase and the API still returns the values; the columns
are just not displayed since dad isn't shopping based on the dishonest
quote.
This commit is contained in:
Michiel Berger 2026-04-30 21:39:38 +02:00
parent 91ea434912
commit a3b9b2a0e9
3 changed files with 66 additions and 27 deletions

View file

@ -97,13 +97,8 @@ function renderBest(b) {
dt("Payback"), dd(`${fmtPayback(b.lp_payback)} years`),
dt("10-yr net profit"), dd(`${fmtNum(b.ten_year_profit_eur, 0)}`),
dt("Battery"), dd(`${fmtNum(b.capacity_kwh)} kWh / ${fmtNum(b.power_kw, 1)} kW · €${fmtNum(b.price_eur, 0)}`),
dt("Solar surplus captured"), dd(`${fmtNum(b.captured_kwh, 0)} kWh (${fmtNum(b.captured_pct, 0)}% of total)`),
);
if (b.store_year1 != null) {
dl.append(
dt("Store quote"),
dd(`${fmtNum(b.store_year1, 0)}/yr (${fmtPayback(b.store_payback)} yr) — overstates by ${fmtNum(b.overstatement)}×`),
);
}
bestEl.appendChild(dl);
}
@ -131,9 +126,10 @@ 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(r.store_year1 == null ? "—" : `${fmtNum(r.store_year1, 0)}`),
td(fmtPayback(r.store_payback)),
td(r.overstatement == null ? "—" : `${fmtNum(r.overstatement)}×`),
td(fmtNum(r.captured_kwh, 0)),
td(`${fmtNum(r.captured_pct, 0)}%`),
td(`${fmtNum(r.pct_hours_battery_full, 0)}%`),
td(`${fmtNum(r.pct_hours_charge_saturated, 0)}%`),
);
tbody.appendChild(tr);
});
@ -219,7 +215,6 @@ function captureInitialFromDOM() {
lastBatteries = rows.map((tr) => {
const cells = tr.children;
const link = cells[1].querySelector("a");
const storeText = cells[8].textContent.trim();
return {
category: tr.dataset.category || null,
title: (link ? link.textContent : cells[1].textContent).trim(),
@ -230,9 +225,10 @@ 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("€", "")),
store_year1: storeText === "—" ? null : parseFloat(storeText.replace("€", "")),
store_payback: cells[9].textContent.trim() === "—" ? null : parseFloat(cells[9].textContent),
overstatement: cells[10].textContent.trim() === "—" ? null : parseFloat(cells[10].textContent),
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),
};
});
}

View file

@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Honest battery payback — vs thuisbatterijgids.nl</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=9">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=10">
</head>
<body>
@ -84,7 +84,7 @@
<div class="card">
<h2>
Full leaderboard
<small id="meta">(<span id="count">{{ initial.batteries|length }}</span> batteries · <span id="elapsed">{{ initial.elapsed_seconds }}</span>s)</small>
<small id="meta">(<span id="count">{{ initial.batteries|length }}</span> batteries · <span id="elapsed">{{ initial.elapsed_seconds }}</span>s · total solar surplus: <span id="total-surplus">{{ '%.0f' % initial.total_surplus_kwh }}</span> kWh/yr)</small>
</h2>
<div class="filters">
<label class="filter-toggle"><input type="checkbox" data-cat="small_plugin" checked> ≤ 0.8 kW plug-in (no electrician)</label>
@ -102,9 +102,10 @@
<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="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="store_year1" data-default-dir="desc" title="What the store advertises">Store €/yr</th>
<th data-sort="store_payback" data-default-dir="asc" title="Store-advertised payback">Store yr</th>
<th data-sort="overstatement" data-default-dir="desc" title="Store / LP — how much they overstate">×over</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="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_charge_saturated" data-default-dir="desc" title="% of surplus hours where charging was pegged at max kW and couldn't keep up">% sat</th>
</tr>
</thead>
<tbody id="leaderboard-body">
@ -118,9 +119,10 @@
<td>€{{ '%.0f' % r.lp_year1 }}</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>{% if r.store_year1 is not none %}€{{ '%.0f' % r.store_year1 }}{% else %}—{% endif %}</td>
<td>{% if r.store_payback %}{{ '%.1f' % r.store_payback }}{% else %}—{% endif %}</td>
<td>{% if r.overstatement %}{{ '%.2f' % r.overstatement }}×{% else %}—{% endif %}</td>
<td>{{ '%.0f' % r.captured_kwh }}</td>
<td>{{ '%.0f' % r.captured_pct }}%</td>
<td>{{ '%.0f' % r.pct_hours_battery_full }}%</td>
<td>{{ '%.0f' % r.pct_hours_charge_saturated }}%</td>
</tr>
{% endfor %}
</tbody>
@ -138,6 +140,6 @@
</p>
</footer>
<script src="{{ url_for('static', filename='app.js') }}?v=9"></script>
<script src="{{ url_for('static', filename='app.js') }}?v=10"></script>
</body>
</html>

View file

@ -103,7 +103,33 @@ def _worker_run_lp(spec):
round_trip_eff=_WORKER_ETA, allows_export=allows_export,
)
out = simulate(_WORKER_DF, bat, oracle_daily_schedule(_WORKER_DF, bat))
return spec, float(out["savings"].sum())
demand = _WORKER_DF["demand_kwh"].to_numpy()
pv = (_WORKER_DF["pv_kwh"].to_numpy()
if "pv_kwh" in _WORKER_DF.columns else np.zeros(len(_WORKER_DF)))
surplus = np.maximum(0.0, pv - demand)
surplus_mask = surplus > 1e-6
n_surplus_hours = int(surplus_mask.sum())
g_with = out["grid_kwh_with_battery"].to_numpy()
export_with_battery = float(np.maximum(0.0, -g_with).sum())
captured_kwh = max(0.0, float(surplus.sum()) - export_with_battery)
if n_surplus_hours > 0:
soc = out["soc_kwh"].to_numpy()
charge = out["charge_kwh"].to_numpy()
pct_full = float((surplus_mask & (soc >= cap - 1e-3)).sum()) / n_surplus_hours * 100.0
pct_saturated = float((surplus_mask & (charge >= pw - 1e-3)).sum()) / n_surplus_hours * 100.0
else:
pct_full = 0.0
pct_saturated = 0.0
return spec, {
"year1": float(out["savings"].sum()),
"captured_kwh": captured_kwh,
"pct_full": pct_full,
"pct_saturated": pct_saturated,
}
# ─── Core compute ─────────────────────────────────────────────────────
@ -152,6 +178,10 @@ def compute_leaderboard(
avg_epex = float(df["epex_eur_per_kwh"].mean())
store_params = StoreParams(avg_epex_eur_per_kwh=avg_epex)
# 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))
total_surplus_kwh = float(np.maximum(0.0, pv_col - df["demand_kwh"].to_numpy()).sum())
# Collect every distinct (capacity, power) the catalog asks for, then run
# the per-spec LPs in parallel. linprog/HiGHS release the GIL so threads
# give a near-linear speedup on this VM (4 cores).
@ -197,15 +227,20 @@ def compute_leaderboard(
initializer=_worker_init,
initargs=(df, eta),
) as ex:
for spec, year1 in ex.map(_worker_run_lp, todo):
cached_results[spec] = year1
_LP_CACHE[(df_sig, *spec)] = year1
for spec, payload in ex.map(_worker_run_lp, todo):
cached_results[spec] = payload
_LP_CACHE[(df_sig, *spec)] = payload
lp_cache = cached_results
rows = []
for b, cap, pw, price, allows_export in valid_rows:
lp_year1 = 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"]
captured_kwh = payload["captured_kwh"]
pct_full = payload["pct_full"]
pct_saturated = payload["pct_saturated"]
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)
# The store calculator on thuisbatterijgids.net only models plug-in;
@ -237,6 +272,10 @@ 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),
"captured_kwh": round(captured_kwh, 1),
"captured_pct": round(captured_pct, 1),
"pct_hours_battery_full": round(pct_full, 1),
"pct_hours_charge_saturated": round(pct_saturated, 1),
"store_year1": store_y1,
"store_payback": store_pay,
"overstatement": overstate,
@ -252,8 +291,10 @@ def compute_leaderboard(
"pv_kwp": pv_kwp, "pv_yield": pv_yield,
"fixed_rate": fixed_rate, "saldering": saldering,
"eta": eta, "inflation": inflation,
"export_rate": export_rate,
},
"avg_epex": avg_epex,
"total_surplus_kwh": round(total_surplus_kwh, 1),
"batteries": rows,
"best": rows[0] if rows else None,
"elapsed_seconds": round(time.time() - t0, 2),