Add Powerwall to leaderboard + 3-way category filter

- Synthesised one hybrid entry (Tesla Powerwall 3, installed price €9500)
  appended to the catalog. Tagged installation: hybrid; LP runs with
  allows_export=True for those.
- Replaced single 'plug-in only' checkbox with three category filters:
  ≤0.8 kW plug-in, ≤2.5 kW plug-in, Hybrid/DC-coupled. Default: all on.
  Filter selection persists in localStorage.
- API now tags every row with a category so the JS can filter without
  re-fetching. SSR rows carry data-category for first-paint filtering.
- Store quote left blank for hybrids since thuisbatterijgids.net only
  models plug-in arithmetic.
This commit is contained in:
Michiel Berger 2026-04-30 15:30:55 +02:00
parent 1878b0a383
commit 386a758517
5 changed files with 127 additions and 47 deletions

View file

@ -4,18 +4,25 @@ ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1 \
PORT=8080
PYTHONPATH=/app/src
WORKDIR /app
COPY pyproject.toml uv.lock README.md ./
COPY src ./src
# Install dependencies only (not the package itself), so the source tree at
# /app stays the runtime layout the app expects: web.py uses
# Path(__file__).resolve().parents[2] / "data" / "raw" to find CSVs, which
# only resolves correctly when the package is imported from /app/src.
COPY pyproject.toml uv.lock ./
RUN pip install --no-cache-dir uv \
&& uv pip install --system --no-cache .
&& uv pip install --system --no-cache \
"pandas>=2.2" "scipy>=1.13" "numpy>=2.0" "flask>=3.0" "gunicorn>=21.0"
COPY src ./src
COPY data ./data
EXPOSE 8080
CMD ["sh", "-c", "gunicorn pluginbattery.web:app --bind 0.0.0.0:${PORT:-8080} --workers 1 --timeout 120"]
CMD ["gunicorn", "pluginbattery.web:app", \
"--bind", "0.0.0.0:8080", \
"--workers", "1", \
"--timeout", "120"]

View file

@ -9,14 +9,37 @@ const tbody = document.getElementById("leaderboard-body");
const countEl = document.getElementById("count");
const elapsed = document.getElementById("elapsed");
const bestEl = document.getElementById("best-content");
const pluginOnly = document.getElementById("plugin-only");
const filterBoxes = Array.from(document.querySelectorAll('.filter-toggle input[data-cat]'));
const FILTER_KEY = "pluginbattery_categories";
const PLUGIN_POWER_LIMIT_KW = 0.8;
const FILTER_KEY = "pluginbattery_plugin_only";
// Latest fetched dataset; the filter toggle re-paints from this.
// Latest fetched dataset; the filter toggles re-paint from this.
let lastBatteries = null;
function categorize(b) {
// Use the category provided by the API, or fall back to power-class for the
// SSR-captured rows which only have power_kw.
if (b.category) return b.category;
if (b.allows_export) return "hybrid";
return b.power_kw <= 0.8 + 1e-9 ? "small_plugin" : "large_plugin";
}
function selectedCategories() {
return new Set(filterBoxes.filter((c) => c.checked).map((c) => c.dataset.cat));
}
function persistFilter() {
localStorage.setItem(FILTER_KEY, JSON.stringify([...selectedCategories()]));
}
function restoreFilter() {
try {
const saved = JSON.parse(localStorage.getItem(FILTER_KEY) || "null");
if (Array.isArray(saved)) {
filterBoxes.forEach((c) => { c.checked = saved.includes(c.dataset.cat); });
}
} catch (_) { /* keep defaults */ }
}
function fmtNum(x, decimals = 2) {
return x === null || x === undefined ? "—" : Number(x).toFixed(decimals);
}
@ -104,8 +127,9 @@ function renderTable(rows) {
}
function applyFilter(batteries) {
if (!pluginOnly.checked) return batteries;
return batteries.filter((b) => b.power_kw <= PLUGIN_POWER_LIMIT_KW + 1e-9);
const cats = selectedCategories();
if (cats.size === 0) return []; // nothing checked → show nothing
return batteries.filter((b) => cats.has(categorize(b)));
}
function repaint() {
@ -150,29 +174,27 @@ function captureInitialFromDOM() {
lastBatteries = rows.map((tr) => {
const cells = tr.children;
const link = cells[1].querySelector("a");
const storeText = cells[7].textContent.trim();
return {
title: (link ? link.textContent : cells[1].textContent).trim(),
url: link ? link.href : "",
category: tr.dataset.category || null,
title: (link ? link.textContent : cells[1].textContent).trim(),
url: link ? link.href : "",
capacity_kwh: parseFloat(cells[2].textContent),
power_kw: parseFloat(cells[3].textContent),
price_eur: parseFloat(cells[4].textContent.replace("€", "")),
lp_year1: parseFloat(cells[5].textContent.replace("€", "")),
lp_payback: cells[6].textContent.trim() === "—" ? null : parseFloat(cells[6].textContent),
store_year1: parseFloat(cells[7].textContent.replace("€", "")),
store_year1: storeText === "—" ? null : parseFloat(storeText.replace("€", "")),
store_payback: cells[8].textContent.trim() === "—" ? null : parseFloat(cells[8].textContent),
overstatement: cells[9].textContent.trim() === "—" ? null : parseFloat(cells[9].textContent),
};
});
}
// Restore filter state from localStorage and wire up listeners.
pluginOnly.checked = localStorage.getItem(FILTER_KEY) === "true";
pluginOnly.addEventListener("change", () => {
localStorage.setItem(FILTER_KEY, pluginOnly.checked);
repaint();
});
restoreFilter();
filterBoxes.forEach((c) => c.addEventListener("change", () => { persistFilter(); repaint(); }));
captureInitialFromDOM();
if (pluginOnly.checked) repaint(); // honour saved filter on load
repaint(); // honour saved filter on first paint
form.addEventListener("submit", (e) => { e.preventDefault(); recalc(); });

View file

@ -125,12 +125,17 @@ section#results {
.card h2 { margin: 0 0 1rem; font-size: 1rem; font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase; color: var(--muted); }
.card h2 small { font-weight: 400; text-transform: none; opacity: 0.8; margin-left: 0.5rem; }
.filters {
display: flex;
gap: 1.25rem;
flex-wrap: wrap;
margin-bottom: 0.85rem;
font-size: 0.85rem;
}
.filter-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-left: 1rem;
font-size: 0.8rem;
font-weight: 400;
text-transform: none;
letter-spacing: 0;

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=5">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=6">
</head>
<body>
@ -81,8 +81,12 @@
<h2>
Full leaderboard
<small id="meta">(<span id="count">{{ initial.batteries|length }}</span> batteries · <span id="elapsed">{{ initial.elapsed_seconds }}</span>s)</small>
<label class="filter-toggle"><input type="checkbox" id="plugin-only"> plug-in only (≤ 0.8 kW, no electrician)</label>
</h2>
<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="large_plugin" checked> ≤ 2.5 kW plug-in (own circuit)</label>
<label class="filter-toggle"><input type="checkbox" data-cat="hybrid" checked> Hybrid / DC-coupled</label>
</div>
<table id="leaderboard">
<thead>
<tr>
@ -100,7 +104,7 @@
</thead>
<tbody id="leaderboard-body">
{% for r in initial.batteries %}
<tr>
<tr data-category="{{ r.category }}">
<td>{{ loop.index }}</td>
<td><a href="{{ r.url }}" target="_blank" rel="noopener">{{ r.title }}</a></td>
<td>{{ '%.2f' % r.capacity_kwh }}</td>
@ -108,7 +112,7 @@
<td>€{{ '%.0f' % r.price_eur }}</td>
<td>€{{ '%.0f' % r.lp_year1 }}</td>
<td>{% if r.lp_payback %}{{ '%.1f' % r.lp_payback }}{% else %}—{% endif %}</td>
<td>€{{ '%.0f' % r.store_year1 }}</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>
</tr>
@ -128,6 +132,6 @@
</p>
</footer>
<script src="{{ url_for('static', filename='app.js') }}?v=5"></script>
<script src="{{ url_for('static', filename='app.js') }}?v=6"></script>
</body>
</html>

View file

@ -49,6 +49,35 @@ _AVG_CONSUMER_BASE = float(_BASE_DF["eur_per_kwh"].mean())
with open(DATA_DIR / "thuisbatterijgids_catalog.json") as _f:
_CATALOG = json.load(_f)
# Hybrid / DC-coupled units — not on thuisbatterijgids.net (they only list
# plug-in), but useful as a reference point. Prices are NL street price incl.
# a typical install (since these can't be self-installed). Mark with
# installation == "hybrid" so the LP runs with allows_export=True.
HYBRID_EXTRAS = [
{
"id": -1,
"title": "Tesla Powerwall 3 (installed)",
"url": "https://www.tesla.com/powerwall",
"brand": "Tesla",
"brand_slug": "tesla",
"capacity": 13.5,
"power_continuous": 11500,
"price": 9500,
"price_per_kwh": 704,
"review_score": None,
"recommended": False,
"installation": "hybrid",
"image_url": "",
},
]
_CATALOG = _CATALOG + HYBRID_EXTRAS
def _category(power_kw: float, allows_export: bool) -> str:
if allows_export:
return "hybrid"
return "small_plugin" if power_kw <= 0.8 + 1e-9 else "large_plugin"
print(f" {len(_BASE_DF)} hourly rows, {len(_CATALOG)} batteries in catalog.", flush=True)
# scenario hash → result dict
@ -71,10 +100,10 @@ def _worker_init(df, eta):
def _worker_run_lp(spec):
cap, pw = spec
cap, pw, allows_export = spec
bat = Battery(
capacity_kwh=cap, max_charge_kw=pw, max_discharge_kw=pw,
round_trip_eff=_WORKER_ETA, allows_export=False,
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())
@ -123,7 +152,7 @@ def compute_leaderboard(
# the per-spec LPs in parallel. linprog/HiGHS release the GIL so threads
# give a near-linear speedup on this VM (4 cores).
valid_rows = []
specs: set[tuple[float, float]] = set()
specs: set[tuple[float, float, bool]] = set()
for b in _CATALOG:
try:
cap = float(b["capacity"])
@ -133,8 +162,9 @@ def compute_leaderboard(
continue
if cap <= 0 or pw <= 0 or price <= 0:
continue
valid_rows.append((b, cap, pw, price))
specs.add((round(cap, 3), round(pw, 3)))
allows_export = b.get("installation") == "hybrid"
valid_rows.append((b, cap, pw, price, allows_export))
specs.add((round(cap, 3), round(pw, 3), allows_export))
# Cache key for "what does this LP depend on?" — everything that goes
# into the dataframe construction plus eta. Inflation isn't in here, so
@ -169,17 +199,27 @@ def compute_leaderboard(
lp_cache = cached_results
rows = []
for b, cap, pw, price in valid_rows:
lp_year1 = lp_cache[(round(cap, 3), round(pw, 3))]
for b, cap, pw, price, allows_export in valid_rows:
lp_year1 = lp_cache[(round(cap, 3), round(pw, 3), allows_export)]
lp_payback = payback_years(lp_year1, price, inflation)
scn = Scenario(
capacity_kwh=cap, max_charge_kw=pw, battery_cost_eur=price,
avg_retail_eur_per_kwh=retail,
has_pv=pv_kwp > 0, has_saldering=saldering,
dynamic_rate=not fixed_rate,
)
sq = store_quote(scn, store_params, inflation=inflation)
# The store calculator on thuisbatterijgids.net only models plug-in;
# quoting it for a hybrid would invent numbers, so leave blank.
if allows_export:
store_y1 = None
store_pay = None
overstate = None
else:
scn = Scenario(
capacity_kwh=cap, max_charge_kw=pw, battery_cost_eur=price,
avg_retail_eur_per_kwh=retail,
has_pv=pv_kwp > 0, has_saldering=saldering,
dynamic_rate=not fixed_rate,
)
sq = store_quote(scn, store_params, inflation=inflation)
store_y1 = round(sq["year1_total"], 2)
store_pay = round(sq["payback_years"], 2) if np.isfinite(sq["payback_years"]) else None
overstate = round(sq["year1_total"] / lp_year1, 2) if lp_year1 > 0 else None
rows.append({
"title": b["title"],
@ -187,11 +227,13 @@ def compute_leaderboard(
"capacity_kwh": cap,
"power_kw": pw,
"price_eur": price,
"category": _category(pw, allows_export),
"allows_export": allows_export,
"lp_year1": round(lp_year1, 2),
"lp_payback": round(lp_payback, 2) if np.isfinite(lp_payback) else None,
"store_year1": round(sq["year1_total"], 2),
"store_payback": round(sq["payback_years"], 2) if np.isfinite(sq["payback_years"]) else None,
"overstatement": round(sq["year1_total"] / lp_year1, 2) if lp_year1 > 0 else None,
"store_year1": store_y1,
"store_payback": store_pay,
"overstatement": overstate,
"url": b.get("url", ""),
"image_url": b.get("image_url", ""),
})