From 386a75851762f98d34de22d86e13b11f33ac6c87 Mon Sep 17 00:00:00 2001 From: Michiel Berger Date: Thu, 30 Apr 2026 15:30:55 +0200 Subject: [PATCH] Add Powerwall to leaderboard + 3-way category filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- Dockerfile | 19 +++++-- src/pluginbattery/static/app.js | 56 +++++++++++++------ src/pluginbattery/static/style.css | 9 ++- src/pluginbattery/templates/index.html | 14 +++-- src/pluginbattery/web.py | 76 ++++++++++++++++++++------ 5 files changed, 127 insertions(+), 47 deletions(-) diff --git a/Dockerfile b/Dockerfile index f1b1347..1396a7e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/src/pluginbattery/static/app.js b/src/pluginbattery/static/app.js index 99cfb96..f3938e8 100644 --- a/src/pluginbattery/static/app.js +++ b/src/pluginbattery/static/app.js @@ -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(); }); diff --git a/src/pluginbattery/static/style.css b/src/pluginbattery/static/style.css index 897771f..454c4f3 100644 --- a/src/pluginbattery/static/style.css +++ b/src/pluginbattery/static/style.css @@ -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; diff --git a/src/pluginbattery/templates/index.html b/src/pluginbattery/templates/index.html index 93e0796..3861725 100644 --- a/src/pluginbattery/templates/index.html +++ b/src/pluginbattery/templates/index.html @@ -4,7 +4,7 @@ Honest battery payback — vs thuisbatterijgids.nl - + @@ -81,8 +81,12 @@

Full leaderboard ({{ initial.batteries|length }} batteries · {{ initial.elapsed_seconds }}s) -

+
+ + + +
@@ -100,7 +104,7 @@ {% for r in initial.batteries %} - + @@ -108,7 +112,7 @@ - + @@ -128,6 +132,6 @@

- + diff --git a/src/pluginbattery/web.py b/src/pluginbattery/web.py index e4cdca1..4cc7fd4 100644 --- a/src/pluginbattery/web.py +++ b/src/pluginbattery/web.py @@ -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", ""), })
{{ loop.index }} {{ r.title }} {{ '%.2f' % r.capacity_kwh }}€{{ '%.0f' % r.price_eur }} €{{ '%.0f' % r.lp_year1 }} {% if r.lp_payback %}{{ '%.1f' % r.lp_payback }}{% else %}—{% endif %}€{{ '%.0f' % r.store_year1 }}{% 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 %}