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 \ PYTHONDONTWRITEBYTECODE=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1 \ PIP_NO_CACHE_DIR=1 \
PORT=8080 PYTHONPATH=/app/src
WORKDIR /app WORKDIR /app
COPY pyproject.toml uv.lock README.md ./ # Install dependencies only (not the package itself), so the source tree at
COPY src ./src # /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 \ 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 COPY data ./data
EXPOSE 8080 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 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 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; // Latest fetched dataset; the filter toggles re-paint from this.
const FILTER_KEY = "pluginbattery_plugin_only";
// Latest fetched dataset; the filter toggle re-paints from this.
let lastBatteries = null; 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) { function fmtNum(x, decimals = 2) {
return x === null || x === undefined ? "—" : Number(x).toFixed(decimals); return x === null || x === undefined ? "—" : Number(x).toFixed(decimals);
} }
@ -104,8 +127,9 @@ function renderTable(rows) {
} }
function applyFilter(batteries) { function applyFilter(batteries) {
if (!pluginOnly.checked) return batteries; const cats = selectedCategories();
return batteries.filter((b) => b.power_kw <= PLUGIN_POWER_LIMIT_KW + 1e-9); if (cats.size === 0) return []; // nothing checked → show nothing
return batteries.filter((b) => cats.has(categorize(b)));
} }
function repaint() { function repaint() {
@ -150,7 +174,9 @@ 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[7].textContent.trim();
return { return {
category: tr.dataset.category || null,
title: (link ? link.textContent : cells[1].textContent).trim(), title: (link ? link.textContent : cells[1].textContent).trim(),
url: link ? link.href : "", url: link ? link.href : "",
capacity_kwh: parseFloat(cells[2].textContent), capacity_kwh: parseFloat(cells[2].textContent),
@ -158,21 +184,17 @@ 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),
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), store_payback: cells[8].textContent.trim() === "—" ? null : parseFloat(cells[8].textContent),
overstatement: cells[9].textContent.trim() === "—" ? null : parseFloat(cells[9].textContent), overstatement: cells[9].textContent.trim() === "—" ? null : parseFloat(cells[9].textContent),
}; };
}); });
} }
// Restore filter state from localStorage and wire up listeners. restoreFilter();
pluginOnly.checked = localStorage.getItem(FILTER_KEY) === "true"; filterBoxes.forEach((c) => c.addEventListener("change", () => { persistFilter(); repaint(); }));
pluginOnly.addEventListener("change", () => {
localStorage.setItem(FILTER_KEY, pluginOnly.checked);
repaint();
});
captureInitialFromDOM(); captureInitialFromDOM();
if (pluginOnly.checked) repaint(); // honour saved filter on load repaint(); // honour saved filter on first paint
form.addEventListener("submit", (e) => { e.preventDefault(); recalc(); }); 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 { 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; } .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 { .filter-toggle {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
margin-left: 1rem;
font-size: 0.8rem;
font-weight: 400; font-weight: 400;
text-transform: none; text-transform: none;
letter-spacing: 0; letter-spacing: 0;

View file

@ -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=5"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=6">
</head> </head>
<body> <body>
@ -81,8 +81,12 @@
<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)</small>
<label class="filter-toggle"><input type="checkbox" id="plugin-only"> plug-in only (≤ 0.8 kW, no electrician)</label>
</h2> </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"> <table id="leaderboard">
<thead> <thead>
<tr> <tr>
@ -100,7 +104,7 @@
</thead> </thead>
<tbody id="leaderboard-body"> <tbody id="leaderboard-body">
{% for r in initial.batteries %} {% for r in initial.batteries %}
<tr> <tr data-category="{{ r.category }}">
<td>{{ loop.index }}</td> <td>{{ loop.index }}</td>
<td><a href="{{ r.url }}" target="_blank" rel="noopener">{{ r.title }}</a></td> <td><a href="{{ r.url }}" target="_blank" rel="noopener">{{ r.title }}</a></td>
<td>{{ '%.2f' % r.capacity_kwh }}</td> <td>{{ '%.2f' % r.capacity_kwh }}</td>
@ -108,7 +112,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>€{{ '%.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.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>
</tr> </tr>
@ -128,6 +132,6 @@
</p> </p>
</footer> </footer>
<script src="{{ url_for('static', filename='app.js') }}?v=5"></script> <script src="{{ url_for('static', filename='app.js') }}?v=6"></script>
</body> </body>
</html> </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: with open(DATA_DIR / "thuisbatterijgids_catalog.json") as _f:
_CATALOG = json.load(_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) print(f" {len(_BASE_DF)} hourly rows, {len(_CATALOG)} batteries in catalog.", flush=True)
# scenario hash → result dict # scenario hash → result dict
@ -71,10 +100,10 @@ def _worker_init(df, eta):
def _worker_run_lp(spec): def _worker_run_lp(spec):
cap, pw = spec cap, pw, allows_export = spec
bat = Battery( bat = Battery(
capacity_kwh=cap, max_charge_kw=pw, max_discharge_kw=pw, 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)) out = simulate(_WORKER_DF, bat, oracle_daily_schedule(_WORKER_DF, bat))
return spec, float(out["savings"].sum()) 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 # 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).
valid_rows = [] valid_rows = []
specs: set[tuple[float, float]] = set() specs: set[tuple[float, float, bool]] = set()
for b in _CATALOG: for b in _CATALOG:
try: try:
cap = float(b["capacity"]) cap = float(b["capacity"])
@ -133,8 +162,9 @@ def compute_leaderboard(
continue continue
if cap <= 0 or pw <= 0 or price <= 0: if cap <= 0 or pw <= 0 or price <= 0:
continue continue
valid_rows.append((b, cap, pw, price)) allows_export = b.get("installation") == "hybrid"
specs.add((round(cap, 3), round(pw, 3))) 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 # Cache key for "what does this LP depend on?" — everything that goes
# into the dataframe construction plus eta. Inflation isn't in here, so # into the dataframe construction plus eta. Inflation isn't in here, so
@ -169,10 +199,17 @@ def compute_leaderboard(
lp_cache = cached_results lp_cache = cached_results
rows = [] rows = []
for b, cap, pw, price in valid_rows: for b, cap, pw, price, allows_export in valid_rows:
lp_year1 = lp_cache[(round(cap, 3), round(pw, 3))] lp_year1 = lp_cache[(round(cap, 3), round(pw, 3), allows_export)]
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;
# 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( scn = Scenario(
capacity_kwh=cap, max_charge_kw=pw, battery_cost_eur=price, capacity_kwh=cap, max_charge_kw=pw, battery_cost_eur=price,
avg_retail_eur_per_kwh=retail, avg_retail_eur_per_kwh=retail,
@ -180,6 +217,9 @@ def compute_leaderboard(
dynamic_rate=not fixed_rate, dynamic_rate=not fixed_rate,
) )
sq = store_quote(scn, store_params, inflation=inflation) 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({ rows.append({
"title": b["title"], "title": b["title"],
@ -187,11 +227,13 @@ def compute_leaderboard(
"capacity_kwh": cap, "capacity_kwh": cap,
"power_kw": pw, "power_kw": pw,
"price_eur": price, "price_eur": price,
"category": _category(pw, 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,
"store_year1": round(sq["year1_total"], 2), "store_year1": store_y1,
"store_payback": round(sq["payback_years"], 2) if np.isfinite(sq["payback_years"]) else None, "store_payback": store_pay,
"overstatement": round(sq["year1_total"] / lp_year1, 2) if lp_year1 > 0 else None, "overstatement": overstate,
"url": b.get("url", ""), "url": b.get("url", ""),
"image_url": b.get("image_url", ""), "image_url": b.get("image_url", ""),
}) })