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:
parent
91ea434912
commit
a3b9b2a0e9
3 changed files with 66 additions and 27 deletions
|
|
@ -97,13 +97,8 @@ function renderBest(b) {
|
||||||
dt("Payback"), dd(`${fmtPayback(b.lp_payback)} years`),
|
dt("Payback"), dd(`${fmtPayback(b.lp_payback)} years`),
|
||||||
dt("10-yr net profit"), dd(`€${fmtNum(b.ten_year_profit_eur, 0)}`),
|
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("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);
|
bestEl.appendChild(dl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,9 +126,10 @@ 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(r.store_year1 == null ? "—" : `€${fmtNum(r.store_year1, 0)}`),
|
td(fmtNum(r.captured_kwh, 0)),
|
||||||
td(fmtPayback(r.store_payback)),
|
td(`${fmtNum(r.captured_pct, 0)}%`),
|
||||||
td(r.overstatement == null ? "—" : `${fmtNum(r.overstatement)}×`),
|
td(`${fmtNum(r.pct_hours_battery_full, 0)}%`),
|
||||||
|
td(`${fmtNum(r.pct_hours_charge_saturated, 0)}%`),
|
||||||
);
|
);
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
|
@ -219,7 +215,6 @@ function captureInitialFromDOM() {
|
||||||
lastBatteries = rows.map((tr) => {
|
lastBatteries = rows.map((tr) => {
|
||||||
const cells = tr.children;
|
const cells = tr.children;
|
||||||
const link = cells[1].querySelector("a");
|
const link = cells[1].querySelector("a");
|
||||||
const storeText = cells[8].textContent.trim();
|
|
||||||
return {
|
return {
|
||||||
category: tr.dataset.category || null,
|
category: tr.dataset.category || null,
|
||||||
title: (link ? link.textContent : cells[1].textContent).trim(),
|
title: (link ? link.textContent : cells[1].textContent).trim(),
|
||||||
|
|
@ -230,9 +225,10 @@ 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("€", "")),
|
||||||
store_year1: storeText === "—" ? null : parseFloat(storeText.replace("€", "")),
|
captured_kwh: parseFloat(cells[8].textContent),
|
||||||
store_payback: cells[9].textContent.trim() === "—" ? null : parseFloat(cells[9].textContent),
|
captured_pct: parseFloat(cells[9].textContent),
|
||||||
overstatement: cells[10].textContent.trim() === "—" ? null : parseFloat(cells[10].textContent),
|
pct_hours_battery_full: parseFloat(cells[10].textContent),
|
||||||
|
pct_hours_charge_saturated: parseFloat(cells[11].textContent),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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=9">
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=10">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
@ -84,7 +84,7 @@
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>
|
<h2>
|
||||||
Full leaderboard
|
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>
|
</h2>
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<label class="filter-toggle"><input type="checkbox" data-cat="small_plugin" checked> ≤ 0.8 kW plug-in (no electrician)</label>
|
<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_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="store_year1" data-default-dir="desc" title="What the store advertises">Store €/yr</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="store_payback" data-default-dir="asc" title="Store-advertised payback">Store yr</th>
|
<th data-sort="captured_pct" data-default-dir="desc" title="Captured kWh as % of total available solar surplus">% capt</th>
|
||||||
<th data-sort="overstatement" data-default-dir="desc" title="Store / LP — how much they overstate">×over</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="leaderboard-body">
|
<tbody id="leaderboard-body">
|
||||||
|
|
@ -118,9 +119,10 @@
|
||||||
<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>{% if r.store_year1 is not none %}€{{ '%.0f' % r.store_year1 }}{% else %}—{% endif %}</td>
|
<td>{{ '%.0f' % r.captured_kwh }}</td>
|
||||||
<td>{% if r.store_payback %}{{ '%.1f' % r.store_payback }}{% else %}—{% endif %}</td>
|
<td>{{ '%.0f' % r.captured_pct }}%</td>
|
||||||
<td>{% if r.overstatement %}{{ '%.2f' % r.overstatement }}×{% else %}—{% endif %}</td>
|
<td>{{ '%.0f' % r.pct_hours_battery_full }}%</td>
|
||||||
|
<td>{{ '%.0f' % r.pct_hours_charge_saturated }}%</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -138,6 +140,6 @@
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='app.js') }}?v=9"></script>
|
<script src="{{ url_for('static', filename='app.js') }}?v=10"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,33 @@ def _worker_run_lp(spec):
|
||||||
round_trip_eff=_WORKER_ETA, allows_export=allows_export,
|
round_trip_eff=_WORKER_ETA, allows_export=allows_export,
|
||||||
)
|
)
|
||||||
out = simulate(_WORKER_DF, bat, oracle_daily_schedule(_WORKER_DF, bat))
|
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 ─────────────────────────────────────────────────────
|
# ─── Core compute ─────────────────────────────────────────────────────
|
||||||
|
|
@ -152,6 +178,10 @@ 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)
|
||||||
|
|
||||||
|
# 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
|
# 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
|
||||||
# give a near-linear speedup on this VM (4 cores).
|
# give a near-linear speedup on this VM (4 cores).
|
||||||
|
|
@ -197,15 +227,20 @@ def compute_leaderboard(
|
||||||
initializer=_worker_init,
|
initializer=_worker_init,
|
||||||
initargs=(df, eta),
|
initargs=(df, eta),
|
||||||
) as ex:
|
) as ex:
|
||||||
for spec, year1 in ex.map(_worker_run_lp, todo):
|
for spec, payload in ex.map(_worker_run_lp, todo):
|
||||||
cached_results[spec] = year1
|
cached_results[spec] = payload
|
||||||
_LP_CACHE[(df_sig, *spec)] = year1
|
_LP_CACHE[(df_sig, *spec)] = payload
|
||||||
|
|
||||||
lp_cache = cached_results
|
lp_cache = cached_results
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
for b, cap, pw, price, allows_export in valid_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)
|
lp_payback = payback_years(lp_year1, price, inflation)
|
||||||
|
|
||||||
# The store calculator on thuisbatterijgids.net only models plug-in;
|
# The store calculator on thuisbatterijgids.net only models plug-in;
|
||||||
|
|
@ -237,6 +272,10 @@ 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),
|
||||||
|
"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_year1": store_y1,
|
||||||
"store_payback": store_pay,
|
"store_payback": store_pay,
|
||||||
"overstatement": overstate,
|
"overstatement": overstate,
|
||||||
|
|
@ -252,8 +291,10 @@ def compute_leaderboard(
|
||||||
"pv_kwp": pv_kwp, "pv_yield": pv_yield,
|
"pv_kwp": pv_kwp, "pv_yield": pv_yield,
|
||||||
"fixed_rate": fixed_rate, "saldering": saldering,
|
"fixed_rate": fixed_rate, "saldering": saldering,
|
||||||
"eta": eta, "inflation": inflation,
|
"eta": eta, "inflation": inflation,
|
||||||
|
"export_rate": export_rate,
|
||||||
},
|
},
|
||||||
"avg_epex": avg_epex,
|
"avg_epex": avg_epex,
|
||||||
|
"total_surplus_kwh": round(total_surplus_kwh, 1),
|
||||||
"batteries": rows,
|
"batteries": rows,
|
||||||
"best": rows[0] if rows else None,
|
"best": rows[0] if rows else None,
|
||||||
"elapsed_seconds": round(time.time() - t0, 2),
|
"elapsed_seconds": round(time.time() - t0, 2),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue