Fix scenario comparator double-counting and explain it

The scenario comparator and payback donut subtracted full battery wear
(which amortizes to the entire purchase price over the rated life) in the
per-cycle margin, then compared that net figure against the purchase price
again for break-even — double-counting the cost and labelling profitable
cases "niet break-even".

Switch both to a gross cash-flow framing via a new grossPerCycle() helper
(spread x rendement x doorzet, no wear term). Cards now show gross yearly
revenue, payback time (price / gross yearly), net result over lifetime, and
break-even when the price is earned back within the battery's rated life.

Add an on-screen explanation of the comparator's purpose, the formula, and
the simplification (gross arbitrage margin before tax/BTW/feed-in), plus a
heading tooltip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 14:47:52 +02:00
parent 99f08d3f2c
commit ae25ee6e88

View File

@ -498,9 +498,23 @@
<!-- SCENARIO VERGELIJKER -->
<section class="space-y-3">
<div class="flex items-baseline justify-between">
<h2 class="text-lg font-semibold">Scenario vergelijker</h2>
<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>
@ -757,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)
//
@ -1201,11 +1225,13 @@
}
function renderPaybackChart() {
// Donut: lifetime revenue vs aanschafprijs.
// 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 = profitPerCycle(state, spread);
const perCycle = grossPerCycle(state, spread);
const lifetimeRev = Math.max(0, perCycle * state.cycles);
const aanschaf = state.price;
const recovered = Math.min(lifetimeRev, aanschaf);
@ -1300,14 +1326,23 @@
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';
@ -1319,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>
@ -1329,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>