Add 10-yr net profit column + clickable column sorting
- Backend: every row now carries ten_year_profit_eur = year1 × cumulative inflation factor − battery cost. Negative for batteries that don't pay back within 10 years (the Powerwall). - New column shows green for positive net, red for negative. - Every column header is now clickable: click to sort, click again to toggle direction. Sort indicator (↑/↓) on the active header. - Sort + filter selections persist in localStorage independently. - Default sort stays 'LP yr ascending' (best payback first), but a single click on '10-yr net' flips to absolute lifetime profit and larger batteries surface to the top.
This commit is contained in:
parent
386a758517
commit
d6a3d53a04
4 changed files with 100 additions and 21 deletions
|
|
@ -10,10 +10,13 @@ const countEl = document.getElementById("count");
|
||||||
const elapsed = document.getElementById("elapsed");
|
const elapsed = document.getElementById("elapsed");
|
||||||
const bestEl = document.getElementById("best-content");
|
const bestEl = document.getElementById("best-content");
|
||||||
const filterBoxes = Array.from(document.querySelectorAll('.filter-toggle input[data-cat]'));
|
const filterBoxes = Array.from(document.querySelectorAll('.filter-toggle input[data-cat]'));
|
||||||
|
const sortHeaders = Array.from(document.querySelectorAll('thead th[data-sort]'));
|
||||||
const FILTER_KEY = "pluginbattery_categories";
|
const FILTER_KEY = "pluginbattery_categories";
|
||||||
|
const SORT_KEY = "pluginbattery_sort";
|
||||||
|
|
||||||
// Latest fetched dataset; the filter toggles re-paint from this.
|
// Latest fetched dataset; toggles + sorts re-paint from this.
|
||||||
let lastBatteries = null;
|
let lastBatteries = null;
|
||||||
|
let sortState = { key: "lp_payback", dir: "asc" };
|
||||||
|
|
||||||
function categorize(b) {
|
function categorize(b) {
|
||||||
// Use the category provided by the API, or fall back to power-class for the
|
// Use the category provided by the API, or fall back to power-class for the
|
||||||
|
|
@ -90,9 +93,15 @@ function renderBest(b) {
|
||||||
dl.append(
|
dl.append(
|
||||||
dt("Annual savings (year-1)"), dd(`€${fmtNum(b.lp_year1, 0)}/yr`),
|
dt("Annual savings (year-1)"), dd(`€${fmtNum(b.lp_year1, 0)}/yr`),
|
||||||
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("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("Store quote"), dd(`€${fmtNum(b.store_year1, 0)}/yr (${fmtPayback(b.store_payback)} yr) — overstates by ${fmtNum(b.overstatement)}×`),
|
|
||||||
);
|
);
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,15 +121,17 @@ function renderTable(rows) {
|
||||||
}
|
}
|
||||||
tr.appendChild(tdTitle);
|
tr.appendChild(tdTitle);
|
||||||
|
|
||||||
|
const tenYrCls = r.ten_year_profit_eur < 0 ? "neg" : "pos";
|
||||||
tr.append(
|
tr.append(
|
||||||
td(fmtNum(r.capacity_kwh)),
|
td(fmtNum(r.capacity_kwh)),
|
||||||
td(fmtNum(r.power_kw, 1)),
|
td(fmtNum(r.power_kw, 1)),
|
||||||
td(`€${fmtNum(r.price_eur, 0)}`),
|
td(`€${fmtNum(r.price_eur, 0)}`),
|
||||||
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.store_year1, 0)}`),
|
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(fmtPayback(r.store_payback)),
|
||||||
td(r.overstatement === null ? "—" : `${fmtNum(r.overstatement)}×`),
|
td(r.overstatement == null ? "—" : `${fmtNum(r.overstatement)}×`),
|
||||||
);
|
);
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
|
@ -132,12 +143,44 @@ function applyFilter(batteries) {
|
||||||
return batteries.filter((b) => cats.has(categorize(b)));
|
return batteries.filter((b) => cats.has(categorize(b)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applySort(rows) {
|
||||||
|
const { key, dir } = sortState;
|
||||||
|
const mult = dir === "asc" ? 1 : -1;
|
||||||
|
return rows.slice().sort((a, b) => {
|
||||||
|
const av = a[key]; const bv = b[key];
|
||||||
|
// Nulls always sort to the bottom regardless of direction.
|
||||||
|
if (av == null && bv == null) return 0;
|
||||||
|
if (av == null) return 1;
|
||||||
|
if (bv == null) return -1;
|
||||||
|
if (typeof av === "string") return av.localeCompare(bv) * mult;
|
||||||
|
return (av - bv) * mult;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSortIndicators() {
|
||||||
|
sortHeaders.forEach((th) => {
|
||||||
|
th.classList.remove("sort-asc", "sort-desc");
|
||||||
|
if (th.dataset.sort === sortState.key) {
|
||||||
|
th.classList.add(sortState.dir === "asc" ? "sort-asc" : "sort-desc");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function repaint() {
|
function repaint() {
|
||||||
if (!lastBatteries) return;
|
if (!lastBatteries) return;
|
||||||
const visible = applyFilter(lastBatteries);
|
const visible = applySort(applyFilter(lastBatteries));
|
||||||
renderBest(visible[0] || null);
|
renderBest(visible[0] || null);
|
||||||
renderTable(visible);
|
renderTable(visible);
|
||||||
countEl.textContent = visible.length;
|
countEl.textContent = visible.length;
|
||||||
|
updateSortIndicators();
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistSort() { localStorage.setItem(SORT_KEY, JSON.stringify(sortState)); }
|
||||||
|
function restoreSort() {
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(localStorage.getItem(SORT_KEY) || "null");
|
||||||
|
if (s && s.key) sortState = s;
|
||||||
|
} catch (_) { /* keep default */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recalc() {
|
async function recalc() {
|
||||||
|
|
@ -167,14 +210,14 @@ async function recalc() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture the SSR-rendered table into lastBatteries so the filter works
|
// Capture the SSR-rendered table into lastBatteries so filter+sort work
|
||||||
// before the user clicks Recalculate.
|
// before the user clicks Recalculate.
|
||||||
function captureInitialFromDOM() {
|
function captureInitialFromDOM() {
|
||||||
const rows = Array.from(tbody.querySelectorAll("tr"));
|
const rows = Array.from(tbody.querySelectorAll("tr"));
|
||||||
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[7].textContent.trim();
|
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(),
|
||||||
|
|
@ -184,17 +227,31 @@ function captureInitialFromDOM() {
|
||||||
price_eur: parseFloat(cells[4].textContent.replace("€", "")),
|
price_eur: parseFloat(cells[4].textContent.replace("€", "")),
|
||||||
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("€", "")),
|
||||||
store_year1: storeText === "—" ? null : parseFloat(storeText.replace("€", "")),
|
store_year1: storeText === "—" ? null : parseFloat(storeText.replace("€", "")),
|
||||||
store_payback: cells[8].textContent.trim() === "—" ? null : parseFloat(cells[8].textContent),
|
store_payback: cells[9].textContent.trim() === "—" ? null : parseFloat(cells[9].textContent),
|
||||||
overstatement: cells[9].textContent.trim() === "—" ? null : parseFloat(cells[9].textContent),
|
overstatement: cells[10].textContent.trim() === "—" ? null : parseFloat(cells[10].textContent),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
restoreFilter();
|
restoreFilter();
|
||||||
|
restoreSort();
|
||||||
filterBoxes.forEach((c) => c.addEventListener("change", () => { persistFilter(); repaint(); }));
|
filterBoxes.forEach((c) => c.addEventListener("change", () => { persistFilter(); repaint(); }));
|
||||||
|
sortHeaders.forEach((th) => {
|
||||||
|
th.addEventListener("click", () => {
|
||||||
|
const key = th.dataset.sort;
|
||||||
|
if (sortState.key === key) {
|
||||||
|
sortState.dir = sortState.dir === "asc" ? "desc" : "asc";
|
||||||
|
} else {
|
||||||
|
sortState = { key, dir: th.dataset.defaultDir || "desc" };
|
||||||
|
}
|
||||||
|
persistSort();
|
||||||
|
repaint();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
captureInitialFromDOM();
|
captureInitialFromDOM();
|
||||||
repaint(); // honour saved filter on first paint
|
repaint(); // honour saved filter + sort on first paint
|
||||||
|
|
||||||
form.addEventListener("submit", (e) => { e.preventDefault(); recalc(); });
|
form.addEventListener("submit", (e) => { e.preventDefault(); recalc(); });
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,14 @@ table#leaderboard thead th {
|
||||||
border-bottom: 2px solid var(--border);
|
border-bottom: 2px solid var(--border);
|
||||||
cursor: help;
|
cursor: help;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
table#leaderboard thead th[data-sort] { cursor: pointer; }
|
||||||
|
table#leaderboard thead th[data-sort]:hover { color: var(--accent); }
|
||||||
|
table#leaderboard thead th.sort-asc::after { content: " ↑"; color: var(--accent); }
|
||||||
|
table#leaderboard thead th.sort-desc::after { content: " ↓"; color: var(--accent); }
|
||||||
|
table#leaderboard td.pos { color: var(--accent); }
|
||||||
|
table#leaderboard td.neg { color: var(--warn); }
|
||||||
table#leaderboard tbody tr:hover { background: var(--panel2); }
|
table#leaderboard tbody tr:hover { background: var(--panel2); }
|
||||||
table#leaderboard tbody tr:first-child { background: rgba(74, 222, 128, 0.08); }
|
table#leaderboard tbody tr:first-child { background: rgba(74, 222, 128, 0.08); }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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=6">
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=7">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
@ -91,15 +91,16 @@
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th>
|
<th>#</th>
|
||||||
<th>Battery</th>
|
<th data-sort="title" data-default-dir="asc">Battery</th>
|
||||||
<th>kWh</th>
|
<th data-sort="capacity_kwh" data-default-dir="desc">kWh</th>
|
||||||
<th>kW</th>
|
<th data-sort="power_kw" data-default-dir="desc">kW</th>
|
||||||
<th>€</th>
|
<th data-sort="price_eur" data-default-dir="asc">€</th>
|
||||||
<th 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 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 title="What the store advertises">Store €/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 title="Store-advertised payback">Store yr</th>
|
<th data-sort="store_year1" data-default-dir="desc" title="What the store advertises">Store €/yr</th>
|
||||||
<th title="Store / LP — how much they overstate">×over</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>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="leaderboard-body">
|
<tbody id="leaderboard-body">
|
||||||
|
|
@ -112,6 +113,7 @@
|
||||||
<td>€{{ '%.0f' % r.price_eur }}</td>
|
<td>€{{ '%.0f' % r.price_eur }}</td>
|
||||||
<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>{% if r.store_year1 is not none %}€{{ '%.0f' % r.store_year1 }}{% else %}—{% endif %}</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.store_payback %}{{ '%.1f' % r.store_payback }}{% else %}—{% endif %}</td>
|
||||||
<td>{% if r.overstatement %}{{ '%.2f' % r.overstatement }}×{% else %}—{% endif %}</td>
|
<td>{% if r.overstatement %}{{ '%.2f' % r.overstatement }}×{% else %}—{% endif %}</td>
|
||||||
|
|
@ -132,6 +134,6 @@
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='app.js') }}?v=6"></script>
|
<script src="{{ url_for('static', filename='app.js') }}?v=7"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,18 @@ def _category(power_kw: float, allows_export: bool) -> str:
|
||||||
return "hybrid"
|
return "hybrid"
|
||||||
return "small_plugin" if power_kw <= 0.8 + 1e-9 else "large_plugin"
|
return "small_plugin" if power_kw <= 0.8 + 1e-9 else "large_plugin"
|
||||||
|
|
||||||
|
|
||||||
|
def _lifetime_profit(year1: float, cost: float, years: int, inflation: float) -> float:
|
||||||
|
"""Cumulative savings over `years` minus the up-front battery cost.
|
||||||
|
|
||||||
|
Each successive year's savings are inflated by `inflation` (matches the
|
||||||
|
payback formula). Negative result = the battery doesn't break even
|
||||||
|
within the horizon.
|
||||||
|
"""
|
||||||
|
if inflation == 0:
|
||||||
|
return year1 * years - cost
|
||||||
|
return year1 * ((1 + inflation) ** years - 1) / inflation - cost
|
||||||
|
|
||||||
print(f" {len(_BASE_DF)} hourly rows, {len(_CATALOG)} batteries in catalog.", flush=True)
|
print(f" {len(_BASE_DF)} hourly rows, {len(_CATALOG)} batteries in catalog.", flush=True)
|
||||||
|
|
||||||
# scenario hash → result dict
|
# scenario hash → result dict
|
||||||
|
|
@ -231,6 +243,7 @@ def compute_leaderboard(
|
||||||
"allows_export": allows_export,
|
"allows_export": allows_export,
|
||||||
"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),
|
||||||
"store_year1": store_y1,
|
"store_year1": store_y1,
|
||||||
"store_payback": store_pay,
|
"store_payback": store_pay,
|
||||||
"overstatement": overstate,
|
"overstatement": overstate,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue