Files
thuisaccu-calculator/index.html
Martien ae25ee6e88 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>
2026-05-29 14:47:52 +02:00

1892 lines
95 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="nl" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Thuisbatterij arbitrage rekentool</title>
<script>
// Set theme before paint to avoid flicker.
(function () {
try {
var stored = localStorage.getItem('thuisbatterij_theme');
var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
var theme = stored || (prefersLight ? 'light' : 'dark');
if (theme === 'light') document.documentElement.classList.remove('dark');
else document.documentElement.classList.add('dark');
} catch (e) { /* ignore */ }
})();
</script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'Segoe UI', 'Roboto', 'sans-serif'],
mono: ['JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'monospace'],
},
},
},
};
</script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
html, body { -webkit-font-smoothing: antialiased; }
body { font-feature-settings: 'tnum' 1, 'cv11' 1, 'kern' 1; }
.num { font-variant-numeric: tabular-nums; }
/* Custom range slider styling */
input[type="range"] { -webkit-appearance: none; appearance: none; background: transparent; }
input[type="range"]::-webkit-slider-runnable-track {
height: 4px; border-radius: 9999px; background: rgb(228 228 231);
}
.dark input[type="range"]::-webkit-slider-runnable-track { background: rgb(63 63 70); }
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 16px; height: 16px; border-radius: 9999px;
background: rgb(16 185 129); margin-top: -6px;
border: 2px solid white; box-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.dark input[type="range"]::-webkit-slider-thumb { border-color: rgb(24 24 27); }
input[type="range"]::-moz-range-track {
height: 4px; border-radius: 9999px; background: rgb(228 228 231);
}
.dark input[type="range"]::-moz-range-track { background: rgb(63 63 70); }
input[type="range"]::-moz-range-thumb {
width: 16px; height: 16px; border-radius: 9999px;
background: rgb(16 185 129); border: 2px solid white;
}
/* Help indicator with hover popup tooltip */
.help-icon {
position: relative;
display: inline-flex; align-items: center; justify-content: center;
width: 15px; height: 15px; border-radius: 9999px;
font-size: 10px; font-weight: 600;
background: rgb(228 228 231); color: rgb(82 82 91);
cursor: help; margin-left: 4px;
flex-shrink: 0;
}
.dark .help-icon { background: rgb(39 39 42); color: rgb(161 161 170); }
.help-icon[data-tip]::before {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%) translateY(3px);
background: rgb(24 24 27); color: rgb(244 244 245);
padding: 8px 12px; border-radius: 8px;
font-size: 12px; font-weight: 400; line-height: 1.45;
width: 240px; white-space: normal; text-align: left;
opacity: 0; visibility: hidden;
transition: opacity 0.12s ease, transform 0.12s ease;
pointer-events: none;
z-index: 100;
box-shadow: 0 8px 24px rgba(0,0,0,0.25);
}
.dark .help-icon[data-tip]::before { background: rgb(63 63 70); color: rgb(244 244 245); }
.help-icon[data-tip]::after {
content: '';
position: absolute;
bottom: calc(100% + 2px);
left: 50%;
transform: translateX(-50%) translateY(3px);
border: 6px solid transparent;
border-top-color: rgb(24 24 27);
opacity: 0; visibility: hidden;
transition: opacity 0.12s ease, transform 0.12s ease;
pointer-events: none;
z-index: 100;
}
.dark .help-icon[data-tip]::after { border-top-color: rgb(63 63 70); }
.help-icon[data-tip]:hover::before,
.help-icon[data-tip]:hover::after {
opacity: 1; visibility: visible;
transform: translateX(-50%) translateY(0);
}
/* When tooltip would go off-screen at top of viewport, flip below */
.help-icon[data-tip].tip-below::before {
bottom: auto; top: calc(100% + 8px);
}
.help-icon[data-tip].tip-below::after {
bottom: auto; top: calc(100% + 2px);
border-top-color: transparent;
border-bottom-color: rgb(24 24 27);
}
.dark .help-icon[data-tip].tip-below::after { border-bottom-color: rgb(63 63 70); }
/* Pulse for fetch loading */
@keyframes pulse-soft { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.pulse-soft { animation: pulse-soft 1.5s ease-in-out infinite; }
/* Chart container sizing */
.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; }
.border, .border-zinc-200, .border-zinc-800 { border-color: #aaa !important; }
.shadow-sm { box-shadow: none !important; }
.chart-box, .chart-box-tall { height: 200px !important; }
section, .break-inside-avoid { break-inside: avoid; }
}
</style>
</head>
<body class="min-h-screen bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 font-sans">
<!-- Header -->
<header class="sticky top-0 z-20 border-b border-zinc-200 dark:border-zinc-800 bg-zinc-50/85 dark:bg-zinc-950/85 backdrop-blur">
<div class="max-w-7xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between gap-4">
<div class="flex items-center gap-3 min-w-0">
<div class="w-7 h-7 rounded-lg bg-emerald-500/15 border border-emerald-500/30 flex items-center justify-center text-emerald-500 font-bold text-sm shrink-0">B</div>
<div class="min-w-0">
<h1 class="text-base sm:text-lg font-semibold truncate">Thuisbatterij arbitrage rekentool</h1>
<p class="text-xs text-zinc-500 dark:text-zinc-400 hidden sm:block">Break-even analyse voor dynamische contracten</p>
</div>
</div>
<div class="flex items-center gap-2 no-print">
<button id="reset-btn" type="button"
class="rounded-lg px-3 py-1.5 text-sm border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-900 transition-colors"
title="Zet alle inputs terug naar defaults">
Reset
</button>
<button id="theme-toggle" type="button"
class="rounded-lg px-3 py-1.5 text-sm border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-900 transition-colors"
title="Wissel tussen donker en licht thema">
<span class="dark:hidden">Donker</span>
<span class="hidden dark:inline">Licht</span>
</button>
</div>
</div>
</header>
<main class="max-w-7xl mx-auto px-4 sm:px-6 py-6 sm:py-8 space-y-10">
<!-- INPUT + LIVE RESULTS -->
<section class="grid lg:grid-cols-[320px,1fr] gap-6">
<!-- Inputs -->
<aside class="lg:sticky lg:top-20 self-start space-y-4">
<div class="rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900/40 shadow-sm">
<header class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-800">
<h2 class="text-sm font-semibold">Parameters</h2>
<p class="text-xs text-zinc-500 dark:text-zinc-400">Alle uitkomsten updaten live</p>
</header>
<div class="p-4 space-y-4">
<div class="text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Batterij</div>
<div>
<label class="flex items-center justify-between text-xs font-medium text-zinc-600 dark:text-zinc-300">
<span class="flex items-center">Capaciteit
<span class="help-icon" data-tip="Hoeveel kWh de batterij in totaal kan opslaan. Een gemiddeld huis verbruikt 8 tot 15 kWh per dag.">?</span>
</span>
<span class="text-zinc-400">kWh</span>
</label>
<input id="in-capacity" type="number" min="1" max="100" step="0.5" value="8"
class="mt-1 w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950 px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/40" />
</div>
<div>
<label class="flex items-center justify-between text-xs font-medium text-zinc-600 dark:text-zinc-300">
<span class="flex items-center">Laad en ontlaad vermogen
<span class="help-icon" data-tip="Hoe snel de batterij kan laden of ontladen. Bij 3 kW duurt een lege batterij van 8 kWh ongeveer 3 uur om vol te laden.">?</span>
</span>
<span class="text-zinc-400">kW</span>
</label>
<input id="in-power" type="number" min="0.5" max="50" step="0.1" value="3"
class="mt-1 w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950 px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/40" />
</div>
<div>
<label class="flex items-center justify-between text-xs font-medium text-zinc-600 dark:text-zinc-300">
<span class="flex items-center">Rendement
<span class="help-icon" data-tip="Hoeveel energie er weer uitkomt als percentage van wat erin ging. Bij 87% verlies je 13% per laad-ontlaad cyclus aan warmte. AC-gekoppelde thuisbatterijen halen typisch 85 tot 90%.">?</span>
</span>
<span class="num text-zinc-400" id="rte-label">87%</span>
</label>
<input id="in-rte" type="range" min="70" max="95" step="1" value="87" class="mt-2 w-full" />
</div>
<div>
<label class="flex items-center justify-between text-xs font-medium text-zinc-600 dark:text-zinc-300">
<span class="flex items-center">Bruikbare capaciteit
<span class="help-icon" data-tip="Welk percentage van de totale capaciteit je echt gebruikt. Een batterij sparen door niet helemaal leeg of vol te laden verlengt de levensduur. 90% is normaal voor LFP.">?</span>
</span>
<span class="num text-zinc-400" id="dod-label">90%</span>
</label>
<input id="in-dod" type="range" min="50" max="100" step="1" value="90" class="mt-2 w-full" />
</div>
<div>
<label class="flex items-center justify-between text-xs font-medium text-zinc-600 dark:text-zinc-300">
<span class="flex items-center">Aanschafprijs
<span class="help-icon" data-tip="Wat de batterij je kost inclusief installatie. Dit deel je over de totale levensduur om de slijtkosten per kWh te bepalen.">?</span>
</span>
<span class="text-zinc-400">EUR</span>
</label>
<input id="in-price" type="number" min="0" step="50" value="4000"
class="mt-1 w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950 px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/40" />
</div>
<div>
<label class="flex items-center justify-between text-xs font-medium text-zinc-600 dark:text-zinc-300">
<span class="flex items-center">Levensduur in cycli
<span class="help-icon" data-tip="Hoeveel keer je de batterij volledig kunt laden en ontladen voordat de capaciteit naar 80% zakt. LFP-batterijen halen 6000 tot 8000 cycli.">?</span>
</span>
<span class="text-zinc-400">cycli</span>
</label>
<input id="in-cycles" type="number" min="100" step="100" value="6000"
class="mt-1 w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950 px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/40" />
</div>
<div class="text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide pt-3 border-t border-zinc-200 dark:border-zinc-800">Tarieven</div>
<div>
<label class="flex items-center justify-between text-xs font-medium text-zinc-600 dark:text-zinc-300">
<span class="flex items-center">Energiebelasting
<span class="help-icon" data-tip="Belasting per kWh die je betaalt aan de overheid bij afname uit het net. In 2024 ongeveer EUR 0,13. BTW komt hier nog overheen. Je betaalt dit niet voor stroom uit je eigen batterij of PV.">?</span>
</span>
<span class="text-zinc-400">EUR/kWh</span>
</label>
<input id="in-tax" type="number" min="0" step="0.001" value="0.130"
class="mt-1 w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950 px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/40" />
</div>
<div>
<label class="flex items-center justify-between text-xs font-medium text-zinc-600 dark:text-zinc-300">
<span class="flex items-center">BTW
<span class="help-icon" data-tip="BTW geldt op de kale stroomprijs plus de energiebelasting. In Nederland 21 procent. Bij teruglevering aan het net krijg je geen BTW.">?</span>
</span>
<span class="num text-zinc-400" id="btw-label">21%</span>
</label>
<input id="in-btw" type="number" min="0" max="30" step="0.5" value="21"
class="mt-1 w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950 px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/40" />
</div>
<div>
<label class="flex items-center justify-between text-xs font-medium text-zinc-600 dark:text-zinc-300">
<span class="flex items-center">Teruglever vergoeding
<span class="help-icon" data-tip="Wat je krijgt per kWh die je teruglevert. Onder saldering vrijwel hetzelfde als wat je betaalt, maar saldering vervalt in 2027 en daarna geldt vaak alleen de kale marktprijs of een lagere vaste vergoeding.">?</span>
</span>
<span class="text-zinc-400">EUR/kWh</span>
</label>
<input id="in-feedin" type="number" min="0" step="0.005" value="0.000"
class="mt-1 w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950 px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/40" />
</div>
<div class="text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide pt-3 border-t border-zinc-200 dark:border-zinc-800">Markt en gebruik</div>
<div>
<label class="flex items-center justify-between text-xs font-medium text-zinc-600 dark:text-zinc-300">
<span class="flex items-center">Lage marktprijs
<span class="help-icon" data-tip="Verwachte gemiddelde kale EPEX prijs tijdens dal-uren (typisch 02:00 tot 05:00 of midden op een zonnige dag). Bepaalt de inkoopkosten in de scenarios. Wordt automatisch ingevuld uit EPEX data zodra je vandaag of een historische periode ophaalt.">?</span>
</span>
<span class="num text-zinc-400" id="ref-label">EUR 0,10</span>
</label>
<input id="in-ref-price" type="range" min="-0.02" max="0.40" step="0.005" value="0.10" class="mt-2 w-full" />
</div>
<div>
<label class="flex items-center justify-between text-xs font-medium text-zinc-600 dark:text-zinc-300">
<span class="flex items-center">Hoge marktprijs
<span class="help-icon" data-tip="Verwachte gemiddelde kale EPEX prijs tijdens piek-uren (typisch 17:00 tot 20:00). Het verschil tussen hoge en lage prijs bepaalt of arbitrage loont. Wordt automatisch ingevuld uit EPEX data.">?</span>
</span>
<span class="num text-zinc-400" id="peak-label">EUR 0,25</span>
</label>
<input id="in-peak-price" type="range" min="0.05" max="0.60" step="0.005" value="0.25" class="mt-2 w-full" />
</div>
<div>
<label class="flex items-center justify-between text-xs font-medium text-zinc-600 dark:text-zinc-300">
<span class="flex items-center">Gebruik per jaar
<span class="help-icon" data-tip="Gemiddeld aantal volledige laad-ontlaad cycli per jaar. Voor dagelijks gebruik 300 tot 365. Hoger als je twee keer per dag laadt en ontlaadt.">?</span>
</span>
<span class="text-zinc-400">cycli/jaar</span>
</label>
<input id="in-yearly-cycles" type="number" min="1" max="730" step="10" value="300"
class="mt-1 w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950 px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/40" />
</div>
</div>
</div>
</aside>
<!-- 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">
<div class="text-xs text-zinc-500 dark:text-zinc-400 uppercase tracking-wide flex items-center">
Slijtkosten per kWh
<span class="help-icon" data-tip="De aanschafprijs gedeeld over alle kWh die ooit door de batterij gaat. Elke kWh die je laadt en ontlaadt 'kost' deze hoeveelheid aan batterij-slijtage. Aanschafprijs gedeeld door (capaciteit x bruikbare capaciteit x levensduur in cycli).">?</span>
</div>
<div class="mt-1 text-3xl font-mono font-semibold num" id="out-kdeg">EUR 0,0000</div>
<div class="mt-1 text-[11px] text-zinc-500 dark:text-zinc-400" id="out-kdeg-sub">per kWh die door de batterij gaat</div>
</div>
<div class="rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900/40 shadow-sm p-4">
<div class="text-xs text-zinc-500 dark:text-zinc-400 uppercase tracking-wide flex items-center">
Prijsverschil dag
<span class="help-icon" data-tip="Het verschil tussen de hoge en lage marktprijs (kale EPEX) zoals nu ingesteld. Hoe groter dit verschil, hoe meer kans dat arbitrage loont. Een spread van EUR 0,20 of meer is gunstig.">?</span>
</div>
<div class="mt-1 text-3xl font-mono font-semibold num" id="out-spread">EUR 0,000</div>
<div class="mt-1 text-[11px] text-zinc-500 dark:text-zinc-400" id="out-spread-sub">hoog - laag</div>
</div>
<div class="rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900/40 shadow-sm p-4">
<div class="text-xs text-zinc-500 dark:text-zinc-400 uppercase tracking-wide flex items-center">
Bruikbaar per cyclus
<span class="help-icon" data-tip="Hoeveel kWh er per volledige laad-ontlaad cyclus daadwerkelijk door de batterij gaat. Iets minder dan de bruto capaciteit omdat je niet alles wilt benutten (zie bruikbare capaciteit).">?</span>
</div>
<div class="mt-1 text-3xl font-mono font-semibold num" id="out-cycle-energy">0,00 kWh</div>
<div class="mt-1 text-[11px] text-zinc-500 dark:text-zinc-400" id="out-cycle-energy-sub">per laad-ontlaad cyclus</div>
</div>
</div>
<!-- Break-even table -->
<details class="rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900/40 shadow-sm overflow-hidden">
<summary class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-800 flex items-center justify-between cursor-pointer">
<div>
<h3 class="text-sm font-semibold">Detailtabel break-even prijzen</h3>
<p class="text-xs text-zinc-500 dark:text-zinc-400">Voor verschillende lage marktprijzen toont deze tabel wat de hoge prijs minimaal moet zijn om winst te maken</p>
</div>
<div class="flex items-center gap-3 text-[11px] text-zinc-500 dark:text-zinc-400">
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-emerald-500"></span>rendabel</span>
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-amber-500"></span>marginaal</span>
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-rose-500"></span>verlies</span>
</div>
</summary>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-zinc-50 dark:bg-zinc-900/60 text-xs uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
<tr>
<th class="text-left px-4 py-2 font-medium">Lage prijs (kaal)</th>
<th class="text-right px-4 py-2 font-medium">Min verkoop voor winst</th>
<th class="text-right px-4 py-2 font-medium">Verschil</th>
<th class="text-right px-4 py-2 font-medium">% van inkoop</th>
<th class="text-right px-4 py-2 font-medium">Min piek consumer</th>
<th class="text-right px-4 py-2 font-medium">PV opslag rendabel?</th>
</tr>
</thead>
<tbody id="table-body" class="divide-y divide-zinc-100 dark:divide-zinc-800 font-mono num"></tbody>
</table>
</div>
</details>
<!-- Strategie vergelijker -->
<div class="space-y-4">
<div>
<h2 class="text-lg font-semibold">Welke strategie loont nu?</h2>
<p class="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">Vijf manieren om met je batterij te handelen, op basis van je huidige parameters. Het saldo per kWh is wat je netto overhoudt na alle kosten en verliezen. Groen is winst, rood is verlies.</p>
</div>
<!-- PV overschot strategies -->
<div class="rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900/40 shadow-sm overflow-hidden break-inside-avoid">
<header class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-800 flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<span class="text-amber-500 text-base">&#9728;</span>
<h3 class="text-sm font-semibold">Heb ik PV-overschot, wat doe ik ermee?</h3>
</div>
<span class="text-[11px] text-zinc-500 dark:text-zinc-400">Beste keuze per kWh PV</span>
</header>
<div class="grid md:grid-cols-3 divide-y md:divide-y-0 md:divide-x divide-zinc-200 dark:divide-zinc-800" id="pv-strategies"></div>
</div>
<!-- Grid arbitrage strategies -->
<div class="rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900/40 shadow-sm overflow-hidden break-inside-avoid">
<header class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-800 flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<span class="text-sky-500 text-base">&#9889;</span>
<h3 class="text-sm font-semibold">Geen zon, loont het om in te kopen?</h3>
</div>
<span class="text-[11px] text-zinc-500 dark:text-zinc-400">Beste keuze per kWh ingekocht</span>
</header>
<div class="grid md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-zinc-200 dark:divide-zinc-800" id="grid-strategies"></div>
</div>
<details class="rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/20 px-4 py-3 break-inside-avoid">
<summary class="text-xs font-semibold cursor-pointer flex items-center gap-2">
<span>Hoe lees ik dit?</span>
</summary>
<div class="mt-3 text-xs text-zinc-600 dark:text-zinc-300 space-y-2 leading-relaxed">
<p><strong>Saldo per kWh</strong> is wat je netto overhoudt nadat je de inkoopkosten of gemiste teruglever vergoeding hebt afgetrokken, en de slijtkosten van de batterij hebt meegenomen. Positief saldo = winst, negatief = verlies.</p>
<p><strong>Bovenste kader</strong> gaat over PV-overschot: je hebt gratis zonne-energie en moet kiezen wat je ermee doet. Hier is "Direct terugleveren" je gratis alternatief, dus de andere keuzes moeten daar overheen.</p>
<p><strong>Onderste kader</strong> gaat over inkopen uit het net (geen zon). Dit is alleen rendabel als het prijsverschil groot genoeg is om belasting, BTW, rendementsverlies en slijtkosten te overwinnen.</p>
<p><strong>Tip:</strong> verschuif de Lage en Hoge marktprijs sliders om te zien wanneer een strategie omslaat van rood naar groen. Of haal echte EPEX data op via de knop onderaan, dan vullen die prijzen zich automatisch.</p>
</div>
</details>
</div>
<!-- Charts grid -->
<div class="grid lg:grid-cols-2 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 break-inside-avoid">
<div class="flex items-baseline justify-between mb-2">
<h3 class="text-sm font-semibold flex items-center">Minimaal benodigde verkoopprijs
<span class="help-icon" data-tip="Toont per lage inkoopprijs welke verkoop- of piekprijs minimaal nodig is om winst te maken. Hoe hoger de inkoop, hoe hoger de benodigde verkoop.">?</span>
</h3>
<span class="text-[11px] text-zinc-500 dark:text-zinc-400">EUR / kWh</span>
</div>
<div class="chart-box"><canvas id="chart-breakeven"></canvas></div>
</div>
<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">Opbouw van de kostprijs
<span class="help-icon" data-tip="Waaruit bestaat de effectieve kostprijs per kWh die je uit de batterij haalt? Kale prijs plus energiebelasting plus BTW plus verliezen plus slijtkosten.">?</span>
</h3>
<span class="text-[11px] text-zinc-500 dark:text-zinc-400">bij lage marktprijs</span>
</div>
<div class="chart-box"><canvas id="chart-breakdown"></canvas></div>
</div>
<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 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" id="payback-spread-label">over levensduur</span>
</div>
<div class="chart-box"><canvas id="chart-payback"></canvas></div>
</div>
<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">Van kale prijs naar werkelijke kostprijs
<span class="help-icon" data-tip="De kale marktprijs is wat de stroomprijs op de groothandelsmarkt is. Daar komt belasting, BTW, het rendementsverlies van de batterij en de slijtage per kWh nog overheen.">?</span>
</h3>
<span class="text-[11px] text-zinc-500 dark:text-zinc-400">opbouw stack</span>
</div>
<div class="chart-box"><canvas id="chart-waterfall"></canvas></div>
</div>
</div>
<!-- SCENARIO VERGELIJKER -->
<section class="space-y-3">
<div class="flex items-baseline justify-between">
<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">
<div>
<h2 class="text-lg font-semibold">EPEX dag-strategie</h2>
<p class="text-xs text-zinc-500 dark:text-zinc-400">Live prijzen via EnergyZero, optimale laad en ontlaad uren</p>
</div>
<div class="flex items-center gap-2">
<button id="fetch-day" type="button"
class="rounded-lg px-3 py-1.5 text-sm bg-emerald-500 text-white hover:bg-emerald-600 transition-colors disabled:opacity-50">
Haal vandaag en morgen op
</button>
</div>
</div>
<div id="day-status" class="hidden text-xs rounded-lg border px-3 py-2"></div>
<div id="day-results" class="hidden space-y-4">
<div class="grid md:grid-cols-4 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">
<div class="text-xs uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Aantal uren</div>
<div class="mt-1 text-2xl font-mono font-semibold num" id="day-hours">0</div>
</div>
<div class="rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900/40 shadow-sm p-4">
<div class="text-xs uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Laad uren (N)</div>
<div class="mt-1 text-2xl font-mono font-semibold num" id="day-charge-n">0</div>
<div class="text-[11px] text-zinc-500 dark:text-zinc-400">ceil(capaciteit / vermogen)</div>
</div>
<div class="rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900/40 shadow-sm p-4">
<div class="text-xs uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Beste spread</div>
<div class="mt-1 text-2xl font-mono font-semibold num" id="day-spread">EUR 0,000</div>
<div class="text-[11px] text-zinc-500 dark:text-zinc-400" id="day-spread-sub">consumer prijs</div>
</div>
<div class="rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900/40 shadow-sm p-4">
<div class="text-xs uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Verwachte dagopbrengst</div>
<div class="mt-1 text-2xl font-mono font-semibold num" id="day-revenue">EUR 0,00</div>
<div class="text-[11px]" id="day-verdict">.</div>
</div>
</div>
<div class="rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900/40 shadow-sm p-4">
<h3 class="text-sm font-semibold mb-2">Prijsverloop met strategie</h3>
<div class="chart-box-tall"><canvas id="chart-day"></canvas></div>
</div>
<div class="rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900/40 shadow-sm overflow-hidden">
<header class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-800">
<h3 class="text-sm font-semibold">Uurtabel</h3>
</header>
<div class="overflow-x-auto max-h-96 overflow-y-auto">
<table class="w-full text-sm">
<thead class="sticky top-0 bg-zinc-50 dark:bg-zinc-900/80 backdrop-blur text-xs uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
<tr>
<th class="text-left px-4 py-2 font-medium">Uur</th>
<th class="text-right px-4 py-2 font-medium">EPEX afname</th>
<th class="text-right px-4 py-2 font-medium">EPEX teruglever</th>
<th class="text-right px-4 py-2 font-medium">Consumer afname</th>
<th class="text-left px-4 py-2 font-medium">Actie</th>
</tr>
</thead>
<tbody id="day-table-body" class="divide-y divide-zinc-100 dark:divide-zinc-800 font-mono num"></tbody>
</table>
</div>
</div>
</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">
<div>
<h2 class="text-lg font-semibold">30-dagen historische analyse</h2>
<p class="text-xs text-zinc-500 dark:text-zinc-400">Hoe vaak was er voldoende spread in de afgelopen maand?</p>
</div>
<button id="fetch-history" type="button"
class="rounded-lg px-3 py-1.5 text-sm bg-emerald-500 text-white hover:bg-emerald-600 transition-colors disabled:opacity-50">
Analyseer afgelopen 30 dagen
</button>
</div>
<div id="history-status" class="hidden text-xs rounded-lg border px-3 py-2"></div>
<div id="history-progress" class="hidden">
<div class="text-xs text-zinc-500 dark:text-zinc-400 mb-1 flex items-center justify-between">
<span id="history-progress-label">Bezig...</span>
<span id="history-progress-count" class="font-mono num">0 / 30</span>
</div>
<div class="h-2 rounded-full bg-zinc-200 dark:bg-zinc-800 overflow-hidden">
<div id="history-progress-bar" class="h-full bg-emerald-500 transition-all" style="width: 0%"></div>
</div>
</div>
<div id="history-results" class="hidden space-y-4">
<div class="grid md: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">
<div class="text-xs uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Rendabele dagen (A)</div>
<div class="mt-1 text-2xl font-mono font-semibold num" id="hist-a-days">0 / 0</div>
<div class="text-[11px] text-zinc-500 dark:text-zinc-400" id="hist-a-rev">EUR 0,00 totaal</div>
</div>
<div class="rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900/40 shadow-sm p-4">
<div class="text-xs uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Gemiddelde spread</div>
<div class="mt-1 text-2xl font-mono font-semibold num" id="hist-avg-spread">EUR 0,000</div>
<div class="text-[11px] text-zinc-500 dark:text-zinc-400">max - min per dag</div>
</div>
<div class="rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900/40 shadow-sm p-4">
<div class="text-xs uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Cumulatieve opbrengst</div>
<div class="mt-1 text-2xl font-mono font-semibold num" id="hist-total-rev">EUR 0,00</div>
<div class="text-[11px] text-zinc-500 dark:text-zinc-400">alleen op rendabele dagen</div>
</div>
</div>
<div class="rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900/40 shadow-sm p-4">
<h3 class="text-sm font-semibold mb-2">Dagelijkse spread vs break-even drempel</h3>
<div class="chart-box-tall"><canvas id="chart-history"></canvas></div>
</div>
</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>
</main>
<script>
'use strict';
/* ============================================================
* Defaults & state
* ============================================================ */
const STORAGE_KEY = 'thuisbatterij_arbitrage_v1';
const DEFAULTS = {
capacity: 8, // kWh
power: 3, // kW
rte: 87, // %
dod: 90, // %
price: 4000, // EUR
cycles: 6000, // count to 80% SoH
tax: 0.13, // EUR/kWh energiebelasting + ODE
btw: 21, // %
feedin: 0.0, // EUR/kWh teruglever vergoeding
yearlyCycles: 300,
refPrice: 0.10, // EUR/kWh kale "lage" prijs (dal)
peakPrice: 0.25, // EUR/kWh kale "hoge" prijs (piek)
};
let state = Object.assign({}, DEFAULTS);
function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
// Merge but only keys we know
for (const k of Object.keys(DEFAULTS)) {
if (typeof parsed[k] === 'number' && Number.isFinite(parsed[k])) state[k] = parsed[k];
}
} catch (e) { /* ignore */ }
}
function saveState() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) { /* ignore */ }
}
/* ============================================================
* Pure calculation functions
* Each function quotes the formula from the spec inline.
* ============================================================ */
// K_deg = aanschafprijs / (capaciteit x DoD x cycli)
// Returns EUR per kWh doorzet (energie die door de batterij gaat).
function degradationPerKwh(s) {
const dod = s.dod / 100;
if (s.capacity <= 0 || dod <= 0 || s.cycles <= 0) return 0;
return s.price / (s.capacity * dod * s.cycles);
}
// Use-case A (net-naar-net arbitrage):
// breakeven_verkoop = (inkoop + belasting x BTW_factor) / RTE + K_deg
// Where BTW_factor = 1 + BTW% / 100.
// inkoop is the kale inkoopprijs in EUR/kWh.
function breakevenA(s, inkoopKaal) {
const rte = s.rte / 100;
const btwFactor = 1 + s.btw / 100;
const kDeg = degradationPerKwh(s);
return (inkoopKaal + s.tax * btwFactor) / rte + kDeg;
}
// Use-case B (PV-overschot opslag):
// Rendabel als: inkoopprijs_consument x RTE > terugleververgoeding + K_deg
// inkoopprijs_consument = (kale + belasting) x (1 + BTW)
function evaluateB(s, kaleAtVerbruik) {
const rte = s.rte / 100;
const btwFactor = 1 + s.btw / 100;
const kDeg = degradationPerKwh(s);
const consumer = (kaleAtVerbruik + s.tax) * btwFactor;
const benefit = consumer * rte;
const cost = s.feedin + kDeg;
return {
consumer,
benefit,
cost,
margin: benefit - cost,
profitable: benefit > cost,
};
}
// Use-case C (net-naar-eigen-verbruik):
// breakeven_piek = ((inkoop_dal_kaal + energiebelasting) x (1 + BTW)) / RTE + K_deg
// Compared with: piekprijs_kaal x (1 + BTW) + belasting x (1 + BTW)
// = (piekprijs_kaal + belasting) x (1 + BTW)
// Returned: break-even peak consumer price. To get back to kaal peak required:
// piek_kaal_required = breakeven_piek / (1 + BTW) - belasting
function breakevenC(s, inkoopDalKaal) {
const rte = s.rte / 100;
const btwFactor = 1 + s.btw / 100;
const kDeg = degradationPerKwh(s);
const piekConsumer = ((inkoopDalKaal + s.tax) * btwFactor) / rte + kDeg;
const piekKaal = piekConsumer / btwFactor - s.tax;
return { piekConsumer, piekKaal };
}
// Doorzet per cyclus (kWh die de batterij in en uit gaat per volledige cyclus)
function cycleEnergy(s) {
return s.capacity * (s.dod / 100);
}
// Net profit per cycle bij gegeven gross spread (EUR/kWh delivered) en doorzet.
// Approximatie: marge per kWh doorzet = spread x RTE - K_deg
function profitPerCycle(s, spread) {
const rte = s.rte / 100;
const kDeg = degradationPerKwh(s);
const margin = spread * rte - kDeg;
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)
//
// We rekenen per "kWh die in de batterij komt" (kWh stored). Voor PV-cases
// is dat ook gelijk aan kWh PV die je niet direct teruglevert. Voor inkoop-
// cases is dat de kWh die je uit het net haalt.
//
// Symbolen:
// dal = state.refPrice (kale EPEX prijs tijdens lage uren)
// piek = state.peakPrice (kale EPEX prijs tijdens hoge uren)
// tax = energiebelasting per kWh, btwF = (1 + BTW/100)
// rte = rendement, kDeg = slijtkosten per kWh doorzet
// feedin = teruglever vergoeding per kWh
//
// Consumer prijs (wat jij betaalt bij afname): (kale + tax) * btwF
// Teruglever opbrengst (wat jij krijgt): feedin (door gebruiker ingesteld)
// of kale prijs als dynamic contract zonder saldering. We gebruiken
// feedin als universele waarde; gebruiker stelt die in.
// ============================================================
function consumerPrice(kale, s) {
return (kale + s.tax) * (1 + s.btw / 100);
}
function evaluateStrategies(s) {
const rte = s.rte / 100;
const kDeg = degradationPerKwh(s);
const dalConsumer = consumerPrice(s.refPrice, s);
const piekConsumer = consumerPrice(s.peakPrice, s);
// Strategie 1: Solar direct terugleveren (baseline, geen batterij)
// Per kWh PV krijg je gewoon de teruglever vergoeding.
const s1 = {
key: 'pv-direct',
title: 'Solar direct terugleveren',
subtitle: 'Geen batterij, je levert PV-overschot direct terug aan het net.',
explain: 'Geen kosten, geen verliezen. Je krijgt per kWh PV de teruglever vergoeding.',
formula: 'Opbrengst per kWh = teruglever vergoeding',
revenue: s.feedin,
cost: 0,
wear: 0,
net: s.feedin,
context: 'pv',
baseline: true,
};
// Strategie 2: Solar opslaan voor eigen verbruik (was case B)
// Je slaat een kWh PV op en gebruikt die later. Je vermijdt zo de consumer
// piekprijs. Het alternatief was direct terugleveren voor feedin.
// Voordeel per kWh PV opgeslagen = piek_consumer * rte - kDeg - feedin
const s2 = {
key: 'pv-self',
title: 'Solar opslaan, zelf gebruiken',
subtitle: 'PV opslaan en in de avond zelf verbruiken in plaats van terugleveren.',
explain: 'Per kWh PV die je opslaat vermijd je later piek_consumer x rendement aan inkoop. Je geeft de teruglever vergoeding op en betaalt slijtkosten.',
formula: 'piek_consumer x rendement - teruglever - slijtkosten',
revenue: piekConsumer * rte,
cost: s.feedin,
wear: kDeg,
net: piekConsumer * rte - s.feedin - kDeg,
context: 'pv',
};
// Strategie 3: Solar opslaan voor latere verkoop
// Je slaat een kWh PV op en levert die later terug op piek-uren. Onder
// dynamische contracten varieert de teruglever prijs met de markt; we
// benaderen piek-teruglever met piek kale prijs. Je geeft de huidige
// teruglever vergoeding op.
const s3 = {
key: 'pv-resell',
title: 'Solar opslaan, later verkopen',
subtitle: 'PV opslaan en op piek-uren terugleveren voor de hogere uurprijs.',
explain: 'Per kWh PV die je opslaat krijg je piek_kale x rendement bij teruglevering op piek. Je geeft de huidige teruglever vergoeding op en betaalt slijtkosten.',
formula: 'piek_kale x rendement - teruglever - slijtkosten',
revenue: s.peakPrice * rte,
cost: s.feedin,
wear: kDeg,
net: s.peakPrice * rte - s.feedin - kDeg,
context: 'pv',
};
// Strategie 4: Inkopen voor eigen verbruik (was case C)
// Op laag tarief uit net laden, op piek zelf gebruiken om dure consumer
// inkoop te vermijden.
// Voordeel per kWh ingekocht = piek_consumer * rte - dal_consumer - kDeg
const s4 = {
key: 'grid-self',
title: 'Inkopen, zelf gebruiken',
subtitle: 'Goedkope nachtstroom kopen en op piek zelf verbruiken.',
explain: 'Per kWh die je uit het net haalt op dal-uur betaal je dal_consumer. Op piek vermijd je piek_consumer x rendement aan inkoop, minus slijtkosten.',
formula: 'piek_consumer x rendement - dal_consumer - slijtkosten',
revenue: piekConsumer * rte,
cost: dalConsumer,
wear: kDeg,
net: piekConsumer * rte - dalConsumer - kDeg,
context: 'grid',
};
// Strategie 5: Inkopen voor verkoop (was case A)
// Op laag tarief uit net laden, op piek verkopen. Bij teruglevering krijg
// je geen BTW; we gebruiken piek kale prijs als opbrengst per kWh teruglevert.
// Voordeel per kWh ingekocht = piek_kale * rte - dal_consumer - kDeg
const s5 = {
key: 'grid-resell',
title: 'Inkopen, verkopen',
subtitle: 'Goedkope nachtstroom kopen en op piek terugleveren aan het net.',
explain: 'Per kWh die je uit het net haalt op dal-uur betaal je dal_consumer. Op piek krijg je piek_kale x rendement bij teruglevering, minus slijtkosten.',
formula: 'piek_kale x rendement - dal_consumer - slijtkosten',
revenue: s.peakPrice * rte,
cost: dalConsumer,
wear: kDeg,
net: s.peakPrice * rte - dalConsumer - kDeg,
context: 'grid',
};
const all = [s1, s2, s3, s4, s5];
// Bepaal beste per context (alleen non-baseline meedoen vergelijken, maar baseline mag winnen als andere negatief zijn)
const pvAll = all.filter(x => x.context === 'pv');
const gridAll = all.filter(x => x.context === 'grid');
const pvBest = pvAll.reduce((a, b) => b.net > a.net ? b : a);
const gridBest = gridAll.reduce((a, b) => b.net > a.net ? b : a);
pvAll.forEach(x => { x.isBest = (x === pvBest); });
gridAll.forEach(x => { x.isBest = (x === gridBest); });
return { pv: pvAll, grid: gridAll, all };
}
// Effectieve consumer kostprijs per kWh delivered, bij gegeven kale inkoop.
// Levert componenten op voor de stacked bar.
function costBreakdown(s, kaleInkoop) {
const btwFactor = 1 + s.btw / 100;
const rte = s.rte / 100;
const kDeg = degradationPerKwh(s);
const consumerStored = (kaleInkoop + s.tax) * btwFactor; // wat je per kWh stored betaalt
const perDelivered = consumerStored / rte; // omdat je RTE verlies hebt
const total = perDelivered + kDeg;
// Decomposition per kWh delivered:
const kale = kaleInkoop / rte;
const belasting = s.tax / rte;
const btw = (kaleInkoop + s.tax) * (btwFactor - 1) / rte;
// (kale/rte + belasting/rte + btw) should equal perDelivered.
// Efficiency loss = perDelivered - consumerStored (extra needed to cover RTE)
const efficiencyLoss = perDelivered - consumerStored;
return {
kale: kaleInkoop,
belasting: s.tax,
btw: (kaleInkoop + s.tax) * (btwFactor - 1),
consumerStored,
efficiencyLoss,
kDeg,
total,
};
}
/* ============================================================
* Formatting
* ============================================================ */
const eurMaj = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR', minimumFractionDigits: 2, maximumFractionDigits: 2 });
const eurMin = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR', minimumFractionDigits: 3, maximumFractionDigits: 3 });
const eurMicro = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR', minimumFractionDigits: 4, maximumFractionDigits: 4 });
const pct1 = new Intl.NumberFormat('nl-NL', { style: 'percent', minimumFractionDigits: 1, maximumFractionDigits: 1 });
const num2 = new Intl.NumberFormat('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
function fmtEur(v, digits = 3) {
if (!Number.isFinite(v)) return 'EUR -';
if (digits === 2) return eurMaj.format(v);
if (digits === 4) return eurMicro.format(v);
return eurMin.format(v);
}
/* ============================================================
* Render
* ============================================================ */
function classifySpread(actualSpread, breakevenSpread) {
// Compares actual market spread to required spread.
// Returns 'good', 'marginal', or 'bad'.
if (actualSpread >= breakevenSpread) return 'good';
if (actualSpread >= breakevenSpread * 0.85) return 'marginal';
return 'bad';
}
function spreadColorClass(category) {
if (category === 'good') return 'text-emerald-600 dark:text-emerald-400';
if (category === 'marginal') return 'text-amber-600 dark:text-amber-400';
return 'text-rose-600 dark:text-rose-400';
}
function renderInputs() {
document.getElementById('in-capacity').value = state.capacity;
document.getElementById('in-power').value = state.power;
document.getElementById('in-rte').value = state.rte;
document.getElementById('in-dod').value = state.dod;
document.getElementById('in-price').value = state.price;
document.getElementById('in-cycles').value = state.cycles;
document.getElementById('in-tax').value = state.tax.toFixed(3);
document.getElementById('in-btw').value = state.btw;
document.getElementById('in-feedin').value = state.feedin.toFixed(3);
document.getElementById('in-yearly-cycles').value = state.yearlyCycles;
document.getElementById('in-ref-price').value = state.refPrice;
document.getElementById('in-peak-price').value = state.peakPrice;
document.getElementById('rte-label').textContent = state.rte + '%';
document.getElementById('dod-label').textContent = state.dod + '%';
document.getElementById('btw-label').textContent = state.btw + '%';
document.getElementById('ref-label').textContent = fmtEur(state.refPrice, 3);
document.getElementById('peak-label').textContent = fmtEur(state.peakPrice, 3);
}
function renderHeadline() {
const kDeg = degradationPerKwh(state);
document.getElementById('out-kdeg').textContent = fmtEur(kDeg, 4);
const spread = state.peakPrice - state.refPrice;
document.getElementById('out-spread').textContent = fmtEur(spread, 3);
document.getElementById('out-spread-sub').textContent = fmtEur(state.peakPrice, 3) + ' minus ' + fmtEur(state.refPrice, 3);
const cyc = cycleEnergy(state);
document.getElementById('out-cycle-energy').textContent = num2.format(cyc) + ' kWh';
document.getElementById('out-cycle-energy-sub').textContent = 'capaciteit ' + state.capacity + ' kWh, ' + state.dod + '% bruikbaar';
}
function renderTable() {
const body = document.getElementById('table-body');
const rows = [];
for (let inkoop = 0.05; inkoop <= 0.301; inkoop += 0.05) {
const i = Math.round(inkoop * 100) / 100;
const beA = breakevenA(state, i);
const spreadA = beA - i;
const pctA = i > 0 ? spreadA / i : Infinity;
const c = breakevenC(state, i);
const b = evaluateB(state, i);
const colorA = spreadA < 0.10 ? 'text-emerald-600 dark:text-emerald-400'
: spreadA < 0.18 ? 'text-amber-600 dark:text-amber-400'
: 'text-rose-600 dark:text-rose-400';
const bColor = b.profitable ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400';
const bLabel = b.profitable ? 'ja (marge ' + fmtEur(b.margin, 3) + ')' : 'nee (' + fmtEur(b.margin, 3) + ')';
rows.push(
'<tr>' +
'<td class="px-4 py-2 text-left text-zinc-500 dark:text-zinc-400">' + fmtEur(i, 3) + '</td>' +
'<td class="px-4 py-2 text-right">' + fmtEur(beA, 3) + '</td>' +
'<td class="px-4 py-2 text-right ' + colorA + '">' + fmtEur(spreadA, 3) + '</td>' +
'<td class="px-4 py-2 text-right ' + colorA + '">' + pct1.format(pctA) + '</td>' +
'<td class="px-4 py-2 text-right">' + fmtEur(c.piekConsumer, 3) + '</td>' +
'<td class="px-4 py-2 text-right ' + bColor + '">' + bLabel + '</td>' +
'</tr>'
);
}
body.innerHTML = rows.join('');
}
function strategyCardHtml(s, baselineNet) {
// Verdict classification:
// good if net > 0.02 EUR/kWh
// neutral if -0.02..0.02
// bad if < -0.02
let verdictClass, verdictLabel, verdictBg;
if (s.baseline) {
verdictClass = 'text-zinc-600 dark:text-zinc-300';
verdictLabel = 'referentie';
verdictBg = 'bg-zinc-100 dark:bg-zinc-800';
} else if (s.net > 0.02) {
verdictClass = 'text-emerald-600 dark:text-emerald-400';
verdictLabel = 'rendabel';
verdictBg = 'bg-emerald-500/10';
} else if (s.net > -0.02) {
verdictClass = 'text-amber-600 dark:text-amber-400';
verdictLabel = 'marginaal';
verdictBg = 'bg-amber-500/10';
} else {
verdictClass = 'text-rose-600 dark:text-rose-400';
verdictLabel = 'verlies';
verdictBg = 'bg-rose-500/10';
}
const bestBadge = s.isBest
? '<span class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-emerald-500 text-white">Beste</span>'
: '';
// For pv-context, show comparison to baseline (direct teruglever)
let comparison = '';
if (!s.baseline && s.context === 'pv') {
const diff = s.net - baselineNet;
const diffClass = diff > 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400';
const sign = diff > 0 ? '+' : '';
comparison = '<div class="text-[11px] mt-1 ' + diffClass + '">' + sign + fmtEur(diff, 3) + ' vs direct terugleveren</div>';
}
return `
<div class="p-4 space-y-3 ${s.isBest ? 'bg-emerald-500/5' : ''}">
<div class="flex items-start justify-between gap-2">
<h4 class="text-sm font-semibold leading-tight">${s.title}</h4>
${bestBadge}
</div>
<p class="text-xs text-zinc-500 dark:text-zinc-400 leading-relaxed">${s.subtitle}</p>
<div class="rounded-lg ${verdictBg} px-3 py-2">
<div class="flex items-baseline justify-between gap-2">
<span class="text-[11px] uppercase tracking-wide ${verdictClass}">Saldo per kWh</span>
<span class="text-[10px] font-semibold uppercase tracking-wide ${verdictClass}">${verdictLabel}</span>
</div>
<div class="font-mono num text-2xl font-semibold ${verdictClass}">${fmtEur(s.net, 3)}</div>
${comparison}
</div>
<details class="text-[11px] text-zinc-500 dark:text-zinc-400">
<summary class="cursor-pointer hover:text-zinc-700 dark:hover:text-zinc-300">Uitleg en getallen</summary>
<div class="mt-2 space-y-1 leading-relaxed">
<p>${s.explain}</p>
<div class="font-mono bg-zinc-100 dark:bg-zinc-900 rounded px-2 py-1 mt-1 text-zinc-700 dark:text-zinc-300">${s.formula}</div>
<table class="w-full mt-2 num font-mono">
<tr><td class="text-zinc-500 dark:text-zinc-400">Opbrengst</td><td class="text-right">${fmtEur(s.revenue, 3)}</td></tr>
<tr><td class="text-zinc-500 dark:text-zinc-400">Kosten / opgeoffert</td><td class="text-right">- ${fmtEur(s.cost, 3)}</td></tr>
<tr><td class="text-zinc-500 dark:text-zinc-400">Slijtkosten</td><td class="text-right">- ${fmtEur(s.wear, 3)}</td></tr>
<tr class="border-t border-zinc-200 dark:border-zinc-800"><td class="pt-1 font-semibold">Saldo</td><td class="pt-1 text-right font-semibold">${fmtEur(s.net, 3)}</td></tr>
</table>
</div>
</details>
</div>
`;
}
function renderStrategies() {
const result = evaluateStrategies(state);
const baselineNet = result.pv.find(x => x.baseline)?.net || 0;
// Render PV strategies: baseline first, then alternatives
const pvOrder = ['pv-direct', 'pv-self', 'pv-resell'];
const pvSorted = pvOrder.map(k => result.pv.find(x => x.key === k)).filter(Boolean);
document.getElementById('pv-strategies').innerHTML =
pvSorted.map(s => strategyCardHtml(s, baselineNet)).join('');
const gridOrder = ['grid-self', 'grid-resell'];
const gridSorted = gridOrder.map(k => result.grid.find(x => x.key === k)).filter(Boolean);
document.getElementById('grid-strategies').innerHTML =
gridSorted.map(s => strategyCardHtml(s, baselineNet)).join('');
}
/* ============================================================
* Charts
* ============================================================ */
const charts = {};
function chartColors() {
const dark = document.documentElement.classList.contains('dark');
return {
text: dark ? 'rgba(244,244,245,0.85)' : 'rgba(24,24,27,0.85)',
grid: dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)',
tick: dark ? 'rgba(244,244,245,0.55)' : 'rgba(24,24,27,0.55)',
};
}
function commonChartOpts() {
const c = chartColors();
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { labels: { color: c.text, font: { family: 'Inter, system-ui, sans-serif', size: 11 } } },
tooltip: {
backgroundColor: 'rgba(24,24,27,0.95)',
titleColor: '#fff', bodyColor: '#fff',
borderColor: 'rgba(255,255,255,0.1)', borderWidth: 1,
padding: 10, cornerRadius: 8,
callbacks: {
label: function (ctx) {
const v = ctx.parsed.y !== undefined ? ctx.parsed.y : ctx.parsed;
return ctx.dataset.label + ': ' + fmtEur(v, 3);
}
}
},
},
scales: {
x: { ticks: { color: c.tick, font: { size: 10 } }, grid: { color: c.grid } },
y: { ticks: { color: c.tick, font: { size: 10 } }, grid: { color: c.grid } },
},
};
}
function renderBreakevenChart() {
const labels = [];
const dataA = [], dataC = [];
for (let i = 0.02; i <= 0.32; i += 0.02) {
const v = Math.round(i * 100) / 100;
labels.push(fmtEur(v, 2));
dataA.push(Number((breakevenA(state, v)).toFixed(4)));
dataC.push(Number((breakevenC(state, v).piekConsumer).toFixed(4)));
}
if (charts.breakeven) {
charts.breakeven.data.labels = labels;
charts.breakeven.data.datasets[0].data = dataA;
charts.breakeven.data.datasets[1].data = dataC;
charts.breakeven.options = commonChartOpts();
charts.breakeven.update();
return;
}
const ctx = document.getElementById('chart-breakeven').getContext('2d');
charts.breakeven = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [
{ label: 'Min verkoop voor winst (inkopen-verkopen)', data: dataA, borderColor: 'rgb(16,185,129)', backgroundColor: 'rgba(16,185,129,0.1)', tension: 0.25, pointRadius: 0, borderWidth: 2 },
{ label: 'Min piek prijs (inkopen-zelf gebruiken)', data: dataC, borderColor: 'rgb(14,165,233)', backgroundColor: 'rgba(14,165,233,0.1)', tension: 0.25, pointRadius: 0, borderWidth: 2, borderDash: [4,4] },
],
},
options: commonChartOpts(),
});
}
function renderBreakdownChart() {
const cb = costBreakdown(state, state.refPrice);
const labels = ['Kale marktprijs', 'Energiebelasting', 'BTW', 'Rendementsverlies', 'Slijtkosten'];
const data = [cb.kale, cb.belasting, cb.btw, cb.efficiencyLoss, cb.kDeg];
const colors = ['rgb(82,82,91)', 'rgb(245,158,11)', 'rgb(244,114,182)', 'rgb(56,189,248)', 'rgb(239,68,68)'];
if (charts.breakdown) {
charts.breakdown.data.labels = labels;
charts.breakdown.data.datasets[0].data = data;
charts.breakdown.data.datasets[0].backgroundColor = colors;
charts.breakdown.options = commonChartOpts();
charts.breakdown.update();
return;
}
const ctx = document.getElementById('chart-breakdown').getContext('2d');
charts.breakdown = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [{ label: 'EUR / kWh delivered', data, backgroundColor: colors, borderWidth: 0 }],
},
options: Object.assign({}, commonChartOpts(), { indexAxis: 'y' }),
});
}
function renderPaybackChart() {
// 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);
const remaining = Math.max(0, aanschaf - recovered);
const surplus = Math.max(0, lifetimeRev - aanschaf);
const labels = ['Terugverdiend', 'Niet terugverdiend', 'Surplus winst'];
const data = [recovered, remaining, surplus];
const colors = ['rgb(16,185,129)', 'rgb(113,113,122)', 'rgb(5,150,105)'];
if (charts.payback) {
charts.payback.data.labels = labels;
charts.payback.data.datasets[0].data = data;
charts.payback.options.plugins.tooltip.callbacks.label = function (ctx) {
return ctx.label + ': ' + fmtEur(ctx.parsed, 2);
};
charts.payback.update();
return;
}
const ctx = document.getElementById('chart-payback').getContext('2d');
const opts = Object.assign({}, commonChartOpts(), {
cutout: '60%',
scales: {},
});
opts.plugins.tooltip.callbacks = { label: (ctx) => ctx.label + ': ' + fmtEur(ctx.parsed, 2) };
charts.payback = new Chart(ctx, {
type: 'doughnut',
data: { labels, datasets: [{ data, backgroundColor: colors, borderWidth: 0 }] },
options: opts,
});
}
function renderWaterfallChart() {
const cb = costBreakdown(state, state.refPrice);
// Build a stacked bar that shows the buildup from kale to total per delivered kWh.
// We slice components per delivered kWh:
const rte = state.rte / 100;
const kalePerDel = cb.kale / rte;
const belastingPerDel = cb.belasting / rte;
const btwPerDel = cb.btw / rte;
const rtePenalty = cb.efficiencyLoss - 0; // already calculated
// The "RTE verlies" segment is the difference between consumerStored and consumerStored/RTE.
// Already represented through dividing by RTE above. To avoid double-count, we display:
// Kale, Belasting, BTW (each divided by RTE), Degradatie.
// The implicit RTE loss is baked into the upscaled buy-side numbers.
const labels = ['per kWh delivered'];
const datasets = [
{ label: 'Kale marktprijs', data: [Number(kalePerDel.toFixed(4))], backgroundColor: 'rgb(82,82,91)' },
{ label: 'Energiebelasting', data: [Number(belastingPerDel.toFixed(4))], backgroundColor: 'rgb(245,158,11)' },
{ label: 'BTW', data: [Number(btwPerDel.toFixed(4))], backgroundColor: 'rgb(244,114,182)' },
{ label: 'Slijtkosten', data: [Number(cb.kDeg.toFixed(4))], backgroundColor: 'rgb(239,68,68)' },
];
if (charts.waterfall) {
charts.waterfall.data.labels = labels;
charts.waterfall.data.datasets = datasets;
charts.waterfall.options = waterfallOpts();
charts.waterfall.update();
return;
}
const ctx = document.getElementById('chart-waterfall').getContext('2d');
charts.waterfall = new Chart(ctx, {
type: 'bar',
data: { labels, datasets },
options: waterfallOpts(),
});
function waterfallOpts() {
const opts = Object.assign({}, commonChartOpts(), { indexAxis: 'y' });
opts.scales = {
x: Object.assign({ stacked: true }, opts.scales.x),
y: Object.assign({ stacked: true }, opts.scales.y),
};
return opts;
}
}
/* ============================================================
* Scenario comparator
* ============================================================ */
const SCENARIOS = [
{ key: 'conservatief', name: 'Conservatief', rte: 82, cycles: 4000, color: 'rose' },
{ key: 'realistisch', name: 'Realistisch', rte: 87, cycles: 6000, color: 'amber' },
{ key: 'optimistisch', name: 'Optimistisch', rte: 92, cycles: 8000, color: 'emerald' },
];
function renderScenarios() {
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 });
// 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 ? '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';
return `
<div class="rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900/40 shadow-sm p-4 space-y-3 break-inside-avoid">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold">${sc.name}</h4>
<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">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/jaar · levensduur ≈ ${Number.isFinite(lifeYears) ? num2.format(lifeYears) + ' jaar' : '—'}</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<div class="text-xs text-zinc-500 dark:text-zinc-400">Terugverdientijd</div>
<div class="font-mono num text-lg font-semibold">${tvtLabel}</div>
</div>
<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>
</div>
`;
});
parent.innerHTML = cards.join('');
}
/* ============================================================
* Render orchestrator + debounce
* ============================================================ */
// Use a microtask/timeout instead of requestAnimationFrame because rAF is
// paused when the tab is hidden, which would freeze the UI updates when
// the page is e.g. previewed in a background frame.
let renderHandle = null;
function scheduleRender() {
if (renderHandle) clearTimeout(renderHandle);
renderHandle = setTimeout(() => {
renderHandle = null;
renderAll();
saveState();
}, 16);
}
function renderAll() {
renderHeadline();
renderStrategies();
renderTable();
renderBreakevenChart();
renderBreakdownChart();
renderPaybackChart();
renderWaterfallChart();
renderScenarios();
// If we have day data loaded, also re-render strategy with updated state.
if (dayData) renderDayResults();
if (historyData) renderHistoryResults();
}
/* ============================================================
* Input wiring
* ============================================================ */
function bindInputs() {
const map = [
['in-capacity', 'capacity', 'number'],
['in-power', 'power', 'number'],
['in-rte', 'rte', 'number'],
['in-dod', 'dod', 'number'],
['in-price', 'price', 'number'],
['in-cycles', 'cycles', 'number'],
['in-tax', 'tax', 'number'],
['in-btw', 'btw', 'number'],
['in-feedin', 'feedin', 'number'],
['in-yearly-cycles', 'yearlyCycles', 'number'],
['in-ref-price', 'refPrice', 'number'],
['in-peak-price', 'peakPrice', 'number'],
];
for (const [id, key] of map) {
const el = document.getElementById(id);
el.addEventListener('input', () => {
const v = parseFloat(el.value);
if (Number.isFinite(v)) {
state[key] = v;
// Update inline labels for sliders
if (key === 'rte') document.getElementById('rte-label').textContent = state.rte + '%';
if (key === 'dod') document.getElementById('dod-label').textContent = state.dod + '%';
if (key === 'btw') document.getElementById('btw-label').textContent = state.btw + '%';
if (key === 'refPrice') document.getElementById('ref-label').textContent = fmtEur(state.refPrice, 3);
if (key === 'peakPrice') document.getElementById('peak-label').textContent = fmtEur(state.peakPrice, 3);
scheduleRender();
}
});
}
}
/* ============================================================
* Theme
* ============================================================ */
function bindTheme() {
document.getElementById('theme-toggle').addEventListener('click', () => {
const isDark = document.documentElement.classList.toggle('dark');
try { localStorage.setItem('thuisbatterij_theme', isDark ? 'dark' : 'light'); } catch (e) {}
// Charts need re-color
for (const k of Object.keys(charts)) {
if (charts[k]) { charts[k].destroy(); charts[k] = null; }
}
renderAll();
});
}
function bindReset() {
document.getElementById('reset-btn').addEventListener('click', () => {
if (!confirm('Alle parameters terug naar defaults?')) return;
state = Object.assign({}, DEFAULTS);
renderInputs();
scheduleRender();
});
}
/* ============================================================
* 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
* ============================================================ */
let dayData = null; // array of {ts: Date, usage, ret}
// Fetch via EnergyZero public API. Returns kale EPEX-prijs per uur in EUR/kWh,
// zonder belasting of BTW (interval=4 = uur, usageType=1 = stroom, inclBtw=false).
// EnergyZero geeft een enkele 'price' terug. We gebruiken die zowel voor afname
// als voor teruglevering. De gebruiker stelt eventueel een aparte teruglever
// vergoeding in voor use-case B.
async function fetchEpexPrices(startDate, endDate, timeoutMs = 8000) {
// EnergyZero behandelt tillDate inclusief. We willen alle hele uren tussen
// start (inclusief) en end (exclusief), dus tillDate = end - 1 uur.
const till = new Date(endDate.getTime() - 60 * 60 * 1000);
const url = 'https://api.energyzero.nl/v1/energyprices'
+ '?fromDate=' + encodeURIComponent(startDate.toISOString())
+ '&tillDate=' + encodeURIComponent(till.toISOString())
+ '&interval=4&usageType=1&inclBtw=false';
const ctrl = new AbortController();
const to = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: ctrl.signal, headers: { 'Accept': 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
const prices = data && Array.isArray(data.Prices) ? data.Prices : null;
if (!prices) throw new Error('Onverwacht antwoord');
return prices.map(d => ({
ts: new Date(d.readingDate),
usage: Number(d.price),
ret: Number(d.price),
})).filter(d => Number.isFinite(d.usage) && !isNaN(d.ts.getTime()));
} finally {
clearTimeout(to);
}
}
function setStatus(el, msg, kind) {
el.classList.remove('hidden', 'border-rose-500/40', 'text-rose-600', 'dark:text-rose-400',
'border-emerald-500/40', 'text-emerald-600', 'dark:text-emerald-400',
'border-amber-500/40', 'text-amber-600', 'dark:text-amber-400',
'bg-rose-500/5', 'bg-emerald-500/5', 'bg-amber-500/5');
const cls = {
error: ['border-rose-500/40', 'text-rose-600', 'dark:text-rose-400', 'bg-rose-500/5'],
success: ['border-emerald-500/40', 'text-emerald-600', 'dark:text-emerald-400', 'bg-emerald-500/5'],
info: ['border-amber-500/40', 'text-amber-600', 'dark:text-amber-400', 'bg-amber-500/5'],
}[kind] || [];
el.classList.add(...cls);
el.textContent = msg;
}
async function handleFetchDay() {
const btn = document.getElementById('fetch-day');
const statusEl = document.getElementById('day-status');
btn.disabled = true; btn.classList.add('pulse-soft');
try {
const today = new Date();
today.setHours(0, 0, 0, 0);
const end = new Date(today);
end.setDate(end.getDate() + 2);
setStatus(statusEl, 'Bezig met ophalen via EnergyZero...', 'info');
const data = await fetchEpexPrices(today, end);
if (!data || data.length === 0) {
setStatus(statusEl, 'Lege response. Probeer het later opnieuw.', 'error');
return;
}
dayData = data;
statusEl.classList.add('hidden');
document.getElementById('day-results').classList.remove('hidden');
// Auto-update lage/hoge marktprijs uit echte EPEX data zodat strategie
// vergelijker meteen reageert op deze cijfers.
autofillPricesFromDay(data);
renderDayResults();
// Re-render strategies en headlines met de nieuwe prijzen.
scheduleRender();
} catch (e) {
const msg = (e.name === 'AbortError')
? 'Time-out na 8 seconden. Controleer je netwerk en probeer opnieuw.'
: 'Fout bij ophalen: ' + (e.message || e);
setStatus(statusEl, msg, 'error');
} finally {
btn.disabled = false; btn.classList.remove('pulse-soft');
}
}
// Vul de lage en hoge marktprijs sliders automatisch met de gemiddelde N
// goedkoopste en N duurste uren uit de opgehaalde data. N = ceil(cap/power).
function autofillPricesFromDay(prices) {
if (!prices || prices.length === 0) return;
const n = Math.max(1, Math.ceil(state.capacity / state.power));
const sorted = [...prices].map(p => p.usage).sort((a, b) => a - b);
const lowAvg = sorted.slice(0, n).reduce((a, b) => a + b, 0) / n;
const highAvg = sorted.slice(-n).reduce((a, b) => a + b, 0) / n;
// Clamp to slider range
state.refPrice = Math.max(-0.02, Math.min(0.40, lowAvg));
state.peakPrice = Math.max(0.05, Math.min(0.60, highAvg));
// Reflect in inputs immediately
document.getElementById('in-ref-price').value = state.refPrice;
document.getElementById('in-peak-price').value = state.peakPrice;
document.getElementById('ref-label').textContent = fmtEur(state.refPrice, 3);
document.getElementById('peak-label').textContent = fmtEur(state.peakPrice, 3);
}
function computeDayStrategy(prices, s) {
// prices: array of {ts, usage, ret} in chronological order.
// Strategy: pick N cheapest hours to charge, N highest-priced hours to discharge,
// with N = ceil(capacity / power). Discharge hours must come after the last
// charge hour within the same day to be physically realistic. We simplify:
// pick N cheapest and N most expensive, mark accordingly. If they overlap,
// remove the overlapping hours from discharge set.
const n = Math.max(1, Math.ceil(s.capacity / s.power));
const sortedByUsage = [...prices].map((p, idx) => ({...p, idx})).sort((a, b) => a.usage - b.usage);
const chargeIdx = new Set(sortedByUsage.slice(0, n).map(p => p.idx));
const sortedByRet = [...prices].map((p, idx) => ({...p, idx})).sort((a, b) => b.ret - a.ret);
const dischargeIdx = new Set();
for (const p of sortedByRet) {
if (chargeIdx.has(p.idx)) continue;
dischargeIdx.add(p.idx);
if (dischargeIdx.size >= n) break;
}
// Compute revenue.
const btwFactor = 1 + s.btw / 100;
const energyPerHour = (s.capacity * (s.dod / 100)) / n; // kWh stored per charge hour
const energyOutPerHour = energyPerHour * (s.rte / 100); // kWh delivered per discharge hour
// Cost per kWh charged at hour h: (usage + tax) * btwFactor
let cost = 0, revenue = 0;
const chargeHours = [];
const dischargeHours = [];
for (const idx of chargeIdx) {
const p = prices[idx];
cost += (p.usage + s.tax) * btwFactor * energyPerHour;
chargeHours.push(p);
}
for (const idx of dischargeIdx) {
const p = prices[idx];
revenue += p.ret * energyOutPerHour;
dischargeHours.push(p);
}
const degradationCost = s.price / s.cycles; // per cyclus
const net = revenue - cost - degradationCost;
// Average kale (EPEX) charge / discharge prices voor het tonen van de
// marktspread. De gebruiker betaalt natuurlijk consumer prijs bij afname
// en krijgt typisch kale prijs terug bij teruglevering (zonder saldering).
const avgChargeKale = chargeHours.length
? chargeHours.reduce((a, p) => a + p.usage, 0) / chargeHours.length
: 0;
const avgDischargeKale = dischargeHours.length
? dischargeHours.reduce((a, p) => a + p.ret, 0) / dischargeHours.length
: 0;
const avgChargeConsumer = (avgChargeKale + s.tax) * btwFactor;
return {
n, chargeIdx, dischargeIdx, cost, revenue, net, degradationCost,
avgChargeKale, avgDischargeKale, avgChargeConsumer,
spread: avgDischargeKale - avgChargeKale,
energyPerHour, energyOutPerHour,
};
}
function renderDayResults() {
if (!dayData) return;
const strat = computeDayStrategy(dayData, state);
document.getElementById('day-hours').textContent = dayData.length;
document.getElementById('day-charge-n').textContent = strat.n;
document.getElementById('day-spread').textContent = fmtEur(strat.spread, 3);
document.getElementById('day-spread-sub').textContent = 'kale teruglever ' + fmtEur(strat.avgDischargeKale, 3) + ' - kale afname ' + fmtEur(strat.avgChargeKale, 3);
document.getElementById('day-revenue').textContent = fmtEur(strat.net, 2);
const verdict = document.getElementById('day-verdict');
if (strat.net > 0) {
verdict.textContent = 'Rendabele dag';
verdict.className = 'text-[11px] text-emerald-600 dark:text-emerald-400';
} else if (strat.net > -0.5) {
verdict.textContent = 'Marginaal, nauwelijks winst';
verdict.className = 'text-[11px] text-amber-600 dark:text-amber-400';
} else {
verdict.textContent = 'Verliesdag, beter niet handelen';
verdict.className = 'text-[11px] text-rose-600 dark:text-rose-400';
}
// Chart
const btwFactor = 1 + state.btw / 100;
const labels = dayData.map(d => {
const day = d.ts.getDate();
const hour = String(d.ts.getHours()).padStart(2, '0');
return day + '/' + (d.ts.getMonth() + 1) + ' ' + hour + 'h';
});
const usageData = dayData.map(d => Number(d.usage.toFixed(4)));
const consumerData = dayData.map(d => Number(((d.usage + state.tax) * btwFactor).toFixed(4)));
const bgColors = dayData.map((d, i) => {
if (strat.chargeIdx.has(i)) return 'rgb(16,185,129)';
if (strat.dischargeIdx.has(i)) return 'rgb(244,63,94)';
return 'rgba(113,113,122,0.5)';
});
if (charts.day) {
charts.day.data.labels = labels;
charts.day.data.datasets[0].data = usageData;
charts.day.data.datasets[0].backgroundColor = bgColors;
charts.day.data.datasets[1].data = consumerData;
charts.day.options = commonChartOpts();
charts.day.update();
} else {
const ctx = document.getElementById('chart-day').getContext('2d');
charts.day = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'EPEX afname (kaal)', data: usageData, backgroundColor: bgColors, borderWidth: 0, order: 2 },
{ label: 'Consumer afname (incl belasting+BTW)', data: consumerData, type: 'line', borderColor: 'rgb(168,162,158)', backgroundColor: 'transparent', borderWidth: 1.5, pointRadius: 0, tension: 0.2, order: 1 },
],
},
options: commonChartOpts(),
});
}
// Table
const body = document.getElementById('day-table-body');
body.innerHTML = dayData.map((d, i) => {
const consumer = (d.usage + state.tax) * btwFactor;
let action = '-';
let cls = 'text-zinc-500 dark:text-zinc-400';
if (strat.chargeIdx.has(i)) { action = 'laden'; cls = 'text-emerald-600 dark:text-emerald-400 font-medium'; }
else if (strat.dischargeIdx.has(i)) { action = 'ontladen'; cls = 'text-rose-600 dark:text-rose-400 font-medium'; }
const hourLabel = String(d.ts.getDate()).padStart(2, '0') + '/' + String(d.ts.getMonth() + 1).padStart(2, '0')
+ ' ' + String(d.ts.getHours()).padStart(2, '0') + ':00';
return '<tr>' +
'<td class="px-4 py-1.5 text-left">' + hourLabel + '</td>' +
'<td class="px-4 py-1.5 text-right">' + fmtEur(d.usage, 4) + '</td>' +
'<td class="px-4 py-1.5 text-right text-zinc-500 dark:text-zinc-400">' + fmtEur(d.ret, 4) + '</td>' +
'<td class="px-4 py-1.5 text-right">' + fmtEur(consumer, 4) + '</td>' +
'<td class="px-4 py-1.5 text-left ' + cls + '">' + action + '</td>' +
'</tr>';
}).join('');
}
/* ============================================================
* Historical analysis (last 30 days)
* ============================================================ */
let historyData = null; // array of per-day results
async function handleFetchHistory() {
const btn = document.getElementById('fetch-history');
const statusEl = document.getElementById('history-status');
const progress = document.getElementById('history-progress');
const progressBar = document.getElementById('history-progress-bar');
const progressCount = document.getElementById('history-progress-count');
const progressLabel = document.getElementById('history-progress-label');
btn.disabled = true; btn.classList.add('pulse-soft');
statusEl.classList.add('hidden');
progress.classList.remove('hidden');
progressBar.style.width = '0%';
const days = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
let failed = 0;
try {
for (let i = 30; i >= 1; i--) {
const dayStart = new Date(today);
dayStart.setDate(dayStart.getDate() - i);
const dayEnd = new Date(dayStart);
dayEnd.setDate(dayEnd.getDate() + 1);
progressLabel.textContent = 'Ophalen ' + dayStart.toLocaleDateString('nl-NL');
progressCount.textContent = (30 - i) + ' / 30';
progressBar.style.width = (((30 - i) / 30) * 100) + '%';
try {
const data = await fetchEpexPrices(dayStart, dayEnd, 8000);
if (data && data.length > 0) {
days.push({ date: new Date(dayStart), data });
} else {
failed++;
}
} catch (e) {
failed++;
}
// Small sleep to be kind to the API.
await new Promise(r => setTimeout(r, 120));
}
progressBar.style.width = '100%';
progressCount.textContent = '30 / 30';
if (days.length === 0) {
setStatus(statusEl, 'Geen data ontvangen. Mogelijk CORS of netwerkprobleem.', 'error');
progress.classList.add('hidden');
return;
}
if (failed > 0) {
setStatus(statusEl, days.length + ' dagen succesvol, ' + failed + ' overgeslagen.', 'info');
}
historyData = days;
progress.classList.add('hidden');
document.getElementById('history-results').classList.remove('hidden');
renderHistoryResults();
} finally {
btn.disabled = false; btn.classList.remove('pulse-soft');
}
}
function renderHistoryResults() {
if (!historyData) return;
const btwFactor = 1 + state.btw / 100;
const kDeg = degradationPerKwh(state);
const labels = [];
const spreads = [];
const breakevens = [];
let totalSpread = 0;
let profitableDaysA = 0;
let totalProfit = 0;
for (const day of historyData) {
const strat = computeDayStrategy(day.data, state);
const dailySpread = strat.spread;
spreads.push(Number(dailySpread.toFixed(4)));
// Required spread for break-even at average kale charge price.
const beA = breakevenA(state, strat.avgChargeKale);
const requiredSpread = beA - strat.avgChargeKale;
breakevens.push(Number(requiredSpread.toFixed(4)));
totalSpread += dailySpread;
if (strat.net > 0) {
profitableDaysA++;
totalProfit += strat.net;
}
labels.push(day.date.toLocaleDateString('nl-NL', { day: '2-digit', month: '2-digit' }));
}
const avgSpread = totalSpread / historyData.length;
document.getElementById('hist-a-days').textContent = profitableDaysA + ' / ' + historyData.length;
document.getElementById('hist-a-rev').textContent = fmtEur(totalProfit, 2) + ' totaal netto';
document.getElementById('hist-avg-spread').textContent = fmtEur(avgSpread, 3);
document.getElementById('hist-total-rev').textContent = fmtEur(totalProfit, 2);
// Chart
if (charts.history) {
charts.history.data.labels = labels;
charts.history.data.datasets[0].data = spreads;
charts.history.data.datasets[1].data = breakevens;
charts.history.options = commonChartOpts();
charts.history.update();
} else {
const ctx = document.getElementById('chart-history').getContext('2d');
charts.history = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [
{ label: 'Beschikbare spread (EPEX)', data: spreads, borderColor: 'rgb(16,185,129)', backgroundColor: 'rgba(16,185,129,0.1)', tension: 0.25, pointRadius: 2, borderWidth: 2, fill: true },
{ label: 'Vereiste spread voor break-even', data: breakevens, borderColor: 'rgb(244,63,94)', backgroundColor: 'transparent', tension: 0.25, pointRadius: 0, borderWidth: 2, borderDash: [4,4] },
],
},
options: commonChartOpts(),
});
}
}
/* ============================================================
* Bootstrap
* ============================================================ */
function init() {
loadState();
renderInputs();
bindInputs();
bindTheme();
bindReset();
bindTabs();
document.getElementById('fetch-day').addEventListener('click', handleFetchDay);
document.getElementById('fetch-history').addEventListener('click', handleFetchHistory);
renderAll();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
</script>
</body>
</html>