From ae25ee6e88cf1a65e463ca245af929b939980174 Mon Sep 17 00:00:00 2001 From: Martien Date: Fri, 29 May 2026 14:47:52 +0200 Subject: [PATCH] Fix scenario comparator double-counting and explain it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- index.html | 61 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/index.html b/index.html index 8bae1a3..9b6df73 100644 --- a/index.html +++ b/index.html @@ -498,9 +498,23 @@
-

Scenario vergelijker

+

Scenario vergelijker + ? +

Bij ingestelde spread per kWh doorzet

+ +
+

Doel. 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.

+

Berekening. Per laad-/ontlaadcyclus reken je een bruto arbitrage-opbrengst — één keer goedkoop laden en duur ontladen:

+
+
opbrengst per cyclus = spread × rendement × doorzet
+
spread = hoge − lage marktprijs · doorzet = capaciteit × bruikbare capaciteit (kWh)
+
+

Daaruit volgt per scenario: bruto per jaar = opbrengst per cyclus × cycli per jaar · terugverdientijd = aanschafprijs ÷ bruto per jaar · netto over levensduur = opbrengst per cyclus × totale cycli − aanschafprijs. Je haalt break-even als je de aanschafprijs binnen de levensduur van de batterij terugverdient.

+

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.

+
+
@@ -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 @@ RTE ${sc.rte}% / ${sc.cycles} cycli
-
Jaarlijkse opbrengst
+
Bruto opbrengst per jaar
${fmtEur(yearly, 2)}
-
bij ${state.yearlyCycles} cycli per jaar
+
bij ${state.yearlyCycles} cycli/jaar · levensduur ≈ ${Number.isFinite(lifeYears) ? num2.format(lifeYears) + ' jaar' : '—'}
@@ -1329,8 +1364,8 @@
${tvtLabel}
-
Levensduur opbrengst
-
${fmtEur(lifetime, 2)}
+
Netto over levensduur
+
${fmtEur(net, 2)}
${verdict}