Compare commits
2 Commits
3f67c537a8
...
ae25ee6e88
| Author | SHA1 | Date | |
|---|---|---|---|
| ae25ee6e88 | |||
| 99f08d3f2c |
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "static",
|
||||
"runtimeExecutable": "python3",
|
||||
"runtimeArgs": ["-m", "http.server", "4567"],
|
||||
"port": 4567
|
||||
}
|
||||
]
|
||||
}
|
||||
151
index.html
151
index.html
@ -128,9 +128,19 @@
|
||||
.chart-box { position: relative; height: 260px; }
|
||||
.chart-box-tall { position: relative; height: 320px; }
|
||||
|
||||
/* Tab bar */
|
||||
.tab-btn { color: rgb(82 82 91); }
|
||||
.dark .tab-btn { color: rgb(161 161 170); }
|
||||
.tab-btn:not([aria-selected="true"]):hover { background: rgb(244 244 245); }
|
||||
.dark .tab-btn:not([aria-selected="true"]):hover { background: rgb(39 39 42); }
|
||||
.tab-btn[aria-selected="true"] { background: rgb(16 185 129); color: #fff; }
|
||||
|
||||
/* Print friendly */
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
/* Print the full report regardless of the active tab */
|
||||
[role="tablist"] { display: none !important; }
|
||||
[role="tabpanel"][hidden] { display: block !important; }
|
||||
html, body { background: #ffffff !important; color: #000000 !important; }
|
||||
.dark, .dark * { color: #000 !important; }
|
||||
.dark .bg-zinc-900, .dark .bg-zinc-950, .dark .bg-zinc-800\/40 { background: #fff !important; }
|
||||
@ -323,9 +333,21 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Live results -->
|
||||
<!-- Tab container -->
|
||||
<div class="space-y-6 min-w-0">
|
||||
|
||||
<nav role="tablist" aria-label="Weergaven" class="no-print flex flex-wrap gap-1 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900/40 p-1 shadow-sm">
|
||||
<button id="tab-btn-model" type="button" role="tab" aria-controls="tab-model" aria-selected="true" data-tab="model"
|
||||
class="tab-btn flex-1 min-w-max rounded-lg px-3 py-1.5 text-sm font-medium transition-colors">Rekenmodel</button>
|
||||
<button id="tab-btn-day" type="button" role="tab" aria-controls="tab-day" aria-selected="false" data-tab="day"
|
||||
class="tab-btn flex-1 min-w-max rounded-lg px-3 py-1.5 text-sm font-medium transition-colors">Live dag-strategie</button>
|
||||
<button id="tab-btn-history" type="button" role="tab" aria-controls="tab-history" aria-selected="false" data-tab="history"
|
||||
class="tab-btn flex-1 min-w-max rounded-lg px-3 py-1.5 text-sm font-medium transition-colors">Historie</button>
|
||||
</nav>
|
||||
|
||||
<!-- TAB: Rekenmodel -->
|
||||
<div id="tab-model" role="tabpanel" aria-labelledby="tab-btn-model" class="space-y-6">
|
||||
|
||||
<!-- Headline cards -->
|
||||
<div class="grid sm:grid-cols-3 gap-4">
|
||||
<div class="rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900/40 shadow-sm p-4">
|
||||
@ -455,9 +477,9 @@
|
||||
<div class="rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900/40 shadow-sm p-4 break-inside-avoid">
|
||||
<div class="flex items-baseline justify-between mb-2">
|
||||
<h3 class="text-sm font-semibold flex items-center">Terugverdiend van de aanschaf
|
||||
<span class="help-icon" data-tip="Hoeveel van de aanschafprijs verdien je terug over de hele levensduur bij een prijsverschil van EUR 0,15? Surplus is winst boven aanschafprijs.">?</span>
|
||||
<span class="help-icon" data-tip="Hoeveel van de aanschafprijs verdien je terug over de hele levensduur bij de ingestelde spread (hoge minus lage marktprijs)? Surplus is winst boven aanschafprijs.">?</span>
|
||||
</h3>
|
||||
<span class="text-[11px] text-zinc-500 dark:text-zinc-400">over levensduur, verschil EUR 0,15</span>
|
||||
<span class="text-[11px] text-zinc-500 dark:text-zinc-400" id="payback-spread-label">over levensduur</span>
|
||||
</div>
|
||||
<div class="chart-box"><canvas id="chart-payback"></canvas></div>
|
||||
</div>
|
||||
@ -472,18 +494,35 @@
|
||||
<div class="chart-box"><canvas id="chart-waterfall"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SCENARIO VERGELIJKER -->
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<h2 class="text-lg font-semibold">Scenario vergelijker</h2>
|
||||
<p class="text-xs text-zinc-500 dark:text-zinc-400">Bij spread EUR 0,15 per kWh doorzet</p>
|
||||
<h2 class="text-lg font-semibold flex items-center">Scenario vergelijker
|
||||
<span class="help-icon" data-tip="Vergelijkt drie aannames over de batterij zelf: rendement (RTE) en levensduur (cycli), van pessimistisch tot optimistisch. Alle andere waarden komen uit het Parameters-paneel en zijn gelijk.">?</span>
|
||||
</h2>
|
||||
<p class="text-xs text-zinc-500 dark:text-zinc-400" id="scenario-spread-label">Bij ingestelde spread per kWh doorzet</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900/40 shadow-sm p-4 text-sm text-zinc-600 dark:text-zinc-300 space-y-2">
|
||||
<p><span class="font-medium text-zinc-800 dark:text-zinc-100">Doel.</span> Het rendement (RTE) en de levensduur (cycli) van een batterij zijn vooraf onzeker. Deze drie scenario's — conservatief, realistisch en optimistisch — laten zien hoe gevoelig je terugverdientijd is voor die twee aannames. Alleen RTE en cycli verschillen; alle andere parameters (prijzen, capaciteit, aanschafprijs) zijn voor alle drie gelijk en komen uit het Parameters-paneel.</p>
|
||||
<p><span class="font-medium text-zinc-800 dark:text-zinc-100">Berekening.</span> Per laad-/ontlaadcyclus reken je een <span class="font-medium">bruto arbitrage-opbrengst</span> — één keer goedkoop laden en duur ontladen:</p>
|
||||
<div class="font-mono text-[12px] bg-zinc-100 dark:bg-zinc-800/60 rounded-lg px-3 py-2 space-y-1">
|
||||
<div>opbrengst per cyclus = spread × rendement × doorzet</div>
|
||||
<div class="text-zinc-500 dark:text-zinc-400">spread = hoge − lage marktprijs · doorzet = capaciteit × bruikbare capaciteit (kWh)</div>
|
||||
</div>
|
||||
<p>Daaruit volgt per scenario: <span class="font-medium">bruto per jaar</span> = opbrengst per cyclus × cycli per jaar · <span class="font-medium">terugverdientijd</span> = aanschafprijs ÷ bruto per jaar · <span class="font-medium">netto over levensduur</span> = opbrengst per cyclus × totale cycli − aanschafprijs. Je haalt break-even als je de aanschafprijs binnen de levensduur van de batterij terugverdient.</p>
|
||||
<p class="text-[12px] text-zinc-500 dark:text-zinc-400">Vereenvoudiging: dit is een ruwe arbitrage-marge (marktspread × rendement) vóór energiebelasting, BTW en teruglever-effecten, en zonder aparte slijtagepost — de aanschafprijs is juist het bedrag dat je terugverdient. Voor de volledige afweging per gebruiksvorm, zie de strategie-kaarten hierboven.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-4" id="scenarios"></div>
|
||||
</section>
|
||||
|
||||
</div><!-- /#tab-model -->
|
||||
|
||||
<!-- TAB: Live dag-strategie -->
|
||||
<div id="tab-day" role="tabpanel" aria-labelledby="tab-btn-day" class="space-y-6" hidden>
|
||||
|
||||
<!-- EPEX DAG STRATEGIE -->
|
||||
<section class="space-y-3 no-print">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
@ -551,6 +590,11 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div><!-- /#tab-day -->
|
||||
|
||||
<!-- TAB: Historie -->
|
||||
<div id="tab-history" role="tabpanel" aria-labelledby="tab-btn-history" class="space-y-6" hidden>
|
||||
|
||||
<!-- HISTORICAL ANALYSIS -->
|
||||
<section class="space-y-3 no-print">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
@ -602,6 +646,11 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div><!-- /#tab-history -->
|
||||
|
||||
</div><!-- /tab container -->
|
||||
</section>
|
||||
|
||||
<footer class="pt-8 pb-4 text-center text-xs text-zinc-500 dark:text-zinc-400 no-print">
|
||||
Gegevens worden lokaal opgeslagen in je browser. Geen server, geen tracking.
|
||||
</footer>
|
||||
@ -722,6 +771,16 @@
|
||||
return margin * cycleEnergy(s);
|
||||
}
|
||||
|
||||
// Bruto arbitrage-kasstroom per cyclus (EUR), VOORDAT de aanschafprijs wordt
|
||||
// terugverdiend. Dit is de operationele opbrengst van een keer laag laden en
|
||||
// hoog ontladen: spread x rendement x doorzet. We trekken hier bewust GEEN
|
||||
// slijtkosten af, want de aanschafprijs is juist het bedrag dat we met deze
|
||||
// kasstroom willen terugverdienen (anders zou je de aanschafprijs dubbel tellen).
|
||||
function grossPerCycle(s, spread) {
|
||||
const rte = s.rte / 100;
|
||||
return spread * rte * cycleEnergy(s);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Plain-language strategie evaluatie (5 strategieën)
|
||||
//
|
||||
@ -1166,9 +1225,13 @@
|
||||
}
|
||||
|
||||
function renderPaybackChart() {
|
||||
// Donut: lifetime revenue vs aanschafprijs.
|
||||
const spread = 0.15;
|
||||
const perCycle = profitPerCycle(state, spread);
|
||||
// Donut: bruto levensduur-opbrengst vs aanschafprijs. Bruto kasstroom-framing:
|
||||
// de aanschafprijs is het bedrag dat we terugverdienen, dus geen slijtkosten
|
||||
// aftrekken (anders dubbel geteld).
|
||||
const spread = state.peakPrice - state.refPrice;
|
||||
const paybackLabel = document.getElementById('payback-spread-label');
|
||||
if (paybackLabel) paybackLabel.textContent = 'over levensduur, spread ' + fmtEur(spread, 3);
|
||||
const perCycle = grossPerCycle(state, spread);
|
||||
const lifetimeRev = Math.max(0, perCycle * state.cycles);
|
||||
const aanschaf = state.price;
|
||||
const recovered = Math.min(lifetimeRev, aanschaf);
|
||||
@ -1257,18 +1320,29 @@
|
||||
];
|
||||
|
||||
function renderScenarios() {
|
||||
const spread = 0.15; // EUR / kWh doorzet, vast
|
||||
const spread = state.peakPrice - state.refPrice; // dynamische marktspread (hoog - laag)
|
||||
const spreadLabel = document.getElementById('scenario-spread-label');
|
||||
if (spreadLabel) spreadLabel.textContent = 'Bij spread ' + fmtEur(spread, 3) + ' per kWh doorzet (hoog - laag)';
|
||||
const parent = document.getElementById('scenarios');
|
||||
const cards = SCENARIOS.map(sc => {
|
||||
const s = Object.assign({}, state, { rte: sc.rte, cycles: sc.cycles });
|
||||
const perCycle = profitPerCycle(s, spread);
|
||||
const yearly = perCycle * state.yearlyCycles;
|
||||
const lifetime = perCycle * sc.cycles;
|
||||
// Bruto kasstroom-framing: de aanschafprijs telt 1x mee als het bedrag dat
|
||||
// we terugverdienen. Slijtkosten worden NIET apart afgetrokken.
|
||||
const perCycle = grossPerCycle(s, spread); // bruto opbrengst per cyclus
|
||||
const yearly = perCycle * state.yearlyCycles; // bruto opbrengst per jaar
|
||||
const lifetime = perCycle * sc.cycles; // bruto opbrengst over hele levensduur
|
||||
const net = lifetime - state.price; // netto resultaat na aanschafprijs
|
||||
const lifeYears = state.yearlyCycles > 0 ? sc.cycles / state.yearlyCycles : Infinity;
|
||||
const tvt = yearly > 0 ? state.price / yearly : Infinity;
|
||||
|
||||
const tvtLabel = yearly > 0 ? num2.format(tvt) + ' jaar' : 'nooit';
|
||||
const verdict = lifetime >= state.price ? 'break-even gehaald' : 'niet break-even';
|
||||
const verdictColor = lifetime >= state.price ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400';
|
||||
const tvtLabel = yearly <= 0 ? 'nooit'
|
||||
: (tvt <= lifeYears ? num2.format(tvt) + ' jaar' : '> levensduur');
|
||||
const breakeven = yearly > 0 && lifetime >= state.price; // break-even binnen levensduur
|
||||
const verdict = breakeven
|
||||
? 'Break-even na ' + num2.format(tvt) + ' jaar'
|
||||
: 'Verdient zich niet terug binnen levensduur';
|
||||
const verdictColor = breakeven ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400';
|
||||
const netColor = net >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400';
|
||||
const badge = sc.color === 'rose' ? 'bg-rose-500/15 text-rose-600 dark:text-rose-400'
|
||||
: sc.color === 'amber' ? 'bg-amber-500/15 text-amber-600 dark:text-amber-400'
|
||||
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400';
|
||||
@ -1280,9 +1354,9 @@
|
||||
<span class="text-[11px] font-mono px-1.5 py-0.5 rounded ${badge}">RTE ${sc.rte}% / ${sc.cycles} cycli</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">Jaarlijkse opbrengst</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">Bruto opbrengst per jaar</div>
|
||||
<div class="font-mono num text-2xl font-semibold">${fmtEur(yearly, 2)}</div>
|
||||
<div class="text-[11px] text-zinc-500 dark:text-zinc-400">bij ${state.yearlyCycles} cycli per jaar</div>
|
||||
<div class="text-[11px] text-zinc-500 dark:text-zinc-400">bij ${state.yearlyCycles} cycli/jaar · levensduur ≈ ${Number.isFinite(lifeYears) ? num2.format(lifeYears) + ' jaar' : '—'}</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
@ -1290,8 +1364,8 @@
|
||||
<div class="font-mono num text-lg font-semibold">${tvtLabel}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">Levensduur opbrengst</div>
|
||||
<div class="font-mono num text-lg font-semibold">${fmtEur(lifetime, 2)}</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">Netto over levensduur</div>
|
||||
<div class="font-mono num text-lg font-semibold ${netColor}">${fmtEur(net, 2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs font-medium ${verdictColor}">${verdict}</div>
|
||||
@ -1394,6 +1468,40 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Tabs
|
||||
* ============================================================ */
|
||||
|
||||
const TAB_KEY = 'thuisbatterij_tab';
|
||||
const TAB_NAMES = ['model', 'day', 'history'];
|
||||
|
||||
function activateTab(name) {
|
||||
if (!TAB_NAMES.includes(name)) name = 'model';
|
||||
for (const t of TAB_NAMES) {
|
||||
const panel = document.getElementById('tab-' + t);
|
||||
const btn = document.getElementById('tab-btn-' + t);
|
||||
const active = t === name;
|
||||
if (panel) panel.hidden = !active;
|
||||
if (btn) btn.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
}
|
||||
try { localStorage.setItem(TAB_KEY, name); } catch (e) { /* ignore */ }
|
||||
// Charts created inside a hidden panel render at 0px; resize once visible.
|
||||
requestAnimationFrame(() => {
|
||||
for (const k of Object.keys(charts)) {
|
||||
if (charts[k] && charts[k].resize) charts[k].resize();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindTabs() {
|
||||
document.querySelectorAll('[role="tab"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => activateTab(btn.dataset.tab));
|
||||
});
|
||||
let initial = 'model';
|
||||
try { initial = localStorage.getItem(TAB_KEY) || 'model'; } catch (e) { /* ignore */ }
|
||||
activateTab(initial);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* EPEX dag-strategie via EasyEnergy
|
||||
* ============================================================ */
|
||||
@ -1767,6 +1875,7 @@
|
||||
bindInputs();
|
||||
bindTheme();
|
||||
bindReset();
|
||||
bindTabs();
|
||||
document.getElementById('fetch-day').addEventListener('click', handleFetchDay);
|
||||
document.getElementById('fetch-history').addEventListener('click', handleFetchHistory);
|
||||
renderAll();
|
||||
|
||||
Reference in New Issue
Block a user