From a3b9b2a0e9e3228d59f746f6bd5a656840271a12 Mon Sep 17 00:00:00 2001 From: Michiel Berger Date: Thu, 30 Apr 2026 21:39:38 +0200 Subject: [PATCH] Add solar-capture columns; drop Store columns from leaderboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/pluginbattery/static/app.js | 22 +++++------ src/pluginbattery/templates/index.html | 20 +++++----- src/pluginbattery/web.py | 51 +++++++++++++++++++++++--- 3 files changed, 66 insertions(+), 27 deletions(-) diff --git a/src/pluginbattery/static/app.js b/src/pluginbattery/static/app.js index d9e77fa..0051eb1 100644 --- a/src/pluginbattery/static/app.js +++ b/src/pluginbattery/static/app.js @@ -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), }; }); } diff --git a/src/pluginbattery/templates/index.html b/src/pluginbattery/templates/index.html index ab815ce..2f42eb0 100644 --- a/src/pluginbattery/templates/index.html +++ b/src/pluginbattery/templates/index.html @@ -4,7 +4,7 @@ Honest battery payback — vs thuisbatterijgids.nl - + @@ -84,7 +84,7 @@

Full leaderboard - ({{ initial.batteries|length }} batteries · {{ initial.elapsed_seconds }}s) + ({{ initial.batteries|length }} batteries · {{ initial.elapsed_seconds }}s · total solar surplus: {{ '%.0f' % initial.total_surplus_kwh }} kWh/yr)

@@ -102,9 +102,10 @@ LP €/yr LP yr 10-yr net - Store €/yr - Store yr - ×over + kWh capt + % capt + % full + % sat @@ -118,9 +119,10 @@ €{{ '%.0f' % r.lp_year1 }} {% if r.lp_payback %}{{ '%.1f' % r.lp_payback }}{% else %}—{% endif %} €{{ '%.0f' % r.ten_year_profit_eur }} - {% if r.store_year1 is not none %}€{{ '%.0f' % r.store_year1 }}{% else %}—{% endif %} - {% if r.store_payback %}{{ '%.1f' % r.store_payback }}{% else %}—{% endif %} - {% if r.overstatement %}{{ '%.2f' % r.overstatement }}×{% else %}—{% endif %} + {{ '%.0f' % r.captured_kwh }} + {{ '%.0f' % r.captured_pct }}% + {{ '%.0f' % r.pct_hours_battery_full }}% + {{ '%.0f' % r.pct_hours_charge_saturated }}% {% endfor %} @@ -138,6 +140,6 @@

- + diff --git a/src/pluginbattery/web.py b/src/pluginbattery/web.py index 27d6b56..146418c 100644 --- a/src/pluginbattery/web.py +++ b/src/pluginbattery/web.py @@ -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),