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:
parent
1878b0a383
commit
386a758517
5 changed files with 127 additions and 47 deletions
19
Dockerfile
19
Dockerfile
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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(); });
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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", ""),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue