Berekening: verdeling van de uitkomsten
De uitvoerbare berekening achter de verdeling van kopen, wachten en niet kopen over 162 doorgerekende huishoudens.
Dit is de berekening zelf, ongewijzigd overgenomen uit scripts/calibration/outcomes.ts. Je kunt hem uitvoeren met bun scripts/calibration/outcomes.ts en de gepubliceerde getallen reproduceren.
/**
* Outcome distribution of the verified engine (assumption set 1.0.0-verified-v1).
*
* With the gate open, every recommendation-changing constant is approved — but
* six of them were each resolved in the conservative direction (capture factor
* calibrated down, import price down, terugleverkosten included, trading cut,
* self-use bonuses cut, degradation at the warranted floor). Individually
* defensible choices can stack into a systematically pessimistic model.
*
* This script measures the result rather than arguing about it: it runs the
* engine across a household grid and reports which outcomes actually occur, and
* at what installed price a battery would cross into WACHT or KOOP.
*
* Run: bun scripts/calibration/outcomes.ts
*/
import { computeEngine } from "~/lib/engine"
import { policyAssumptions } from "~/lib/engine/assumptions"
import { catalogueFleet } from "./catalogue-fleet"
import type { EngineBattery, EngineInput, RoofOrientation } from "~/lib/engine/types"
/** The four engine-eligible catalogue records, at observed installed prices. */
const CATALOGUE: ReadonlyArray<{ slug: string; usable: number; rte: number; cents: number }> = [
{ slug: "sessy-5", usable: 5.2, rte: 0.82, cents: 355_000 },
{ slug: "sessy-10", usable: 10.4, rte: 0.82, cents: 600_000 },
{ slug: "enphase-iq-5p", usable: 5.0, rte: 0.9, cents: 400_000 },
{ slug: "solaredge-400v", usable: 9.7, rte: 0.945, cents: 650_000 },
]
const battery = (spec: (typeof CATALOGUE)[number], cents = spec.cents): EngineBattery => ({
slug: spec.slug,
name: spec.slug,
track: "Vast",
phase: "OnePhase",
usableKwh: spec.usable,
maxChargeKw: 2.2,
roundTripEfficiency: spec.rte,
degradationToPctYr10: null,
warrantyYears: 10,
warrantyCycles: null,
warrantyThroughputKwh: null,
backupCapable: false,
emsSubscriptionCentsPerYear: null,
verified: true,
netCostCents: { min: cents, mid: cents, max: cents },
})
const CONSUMPTION = [2500, 3500, 5000] as const
const PV_RATIO = [0.5, 1.0, 1.5] as const
const ORIENTATION: ReadonlyArray<RoofOrientation> = ["zuid", "oost_west", "noord"]
const CONTRACT = ["vast", "dynamisch"] as const
const PROFILE = ["thuis_overdag", "gemengd", "werkend_buitenshuis"] as const
const cases: Array<EngineInput> = []
for (const annualConsumptionKwh of CONSUMPTION) {
for (const ratio of PV_RATIO) {
for (const roofOrientation of ORIENTATION) {
for (const contractType of CONTRACT) {
for (const householdProfile of PROFILE) {
cases.push({
annualConsumptionKwh,
pvProductionKwh: annualConsumptionKwh * ratio,
roofOrientation,
contractType,
householdProfile,
phase: "OnePhase",
goal: "besparen",
ownership: "koop",
})
}
}
}
}
}
const fleet = CATALOGUE.map(spec => battery(spec))
const outcomes = new Map<string, number>()
const paybacks: Array<number> = []
let noPayback = 0
for (const input of cases) {
const result = computeEngine(input, fleet)
const key = result.recommendation ?? "null"
outcomes.set(key, (outcomes.get(key) ?? 0) + 1)
const best = result.batteries[0]
if (best?.paybackBaseYears != null) {
paybacks.push(best.paybackBaseYears)
} else if (best) {
noPayback++
}
}
const quantile = (values: Array<number>, q: number): number => {
const sorted = [...values].sort((a, b) => a - b)
const index = (sorted.length - 1) * q
const low = Math.floor(index)
const high = Math.ceil(index)
return low === high
? (sorted[low] ?? 0)
: (sorted[low] ?? 0) * (high - index) + (sorted[high] ?? 0) * (index - low)
}
console.log(`households simulated: ${cases.length}\n`)
console.log("outcome distribution")
for (const [outcome, count] of [...outcomes].sort((a, b) => b[1] - a[1])) {
const pct = ((count / cases.length) * 100).toFixed(1)
console.log(` ${outcome.padEnd(26)} ${String(count).padStart(4)} ${pct.padStart(5)}%`)
}
console.log("\nbase payback of the best battery (years)")
console.log(
` p10 ${quantile(paybacks, 0.1).toFixed(1)} p25 ${quantile(paybacks, 0.25).toFixed(1)} ` +
`median ${quantile(paybacks, 0.5).toFixed(1)} p75 ${quantile(paybacks, 0.75).toFixed(1)} ` +
`p90 ${quantile(paybacks, 0.9).toFixed(1)}`,
)
console.log(` households with no payback at all: ${noPayback}`)
// The decision-relevant question: what would have to change for the outcome to
// move? Sweep installed price for the best-case household in the grid.
console.log("\nprice sweep — best-case household (5000 kWh, PV ratio 1.0, zuid, thuis_overdag)")
console.log(
`thresholds: KOOP <= ${policyAssumptions.paybackBuyMaxYears.value}y · WACHT <= ${policyAssumptions.paybackNoBuyMinYears.value}y`,
)
const bestCase: EngineInput = {
annualConsumptionKwh: 5000,
pvProductionKwh: 5000,
roofOrientation: "zuid",
contractType: "dynamisch",
householdProfile: "thuis_overdag",
phase: "OnePhase",
goal: "besparen",
ownership: "koop",
}
const cheapest = CATALOGUE[0]!
console.log(" €/kWh usable installed price payback outcome")
for (const perKwh of [1000, 800, 600, 500, 400, 300, 200, 150, 100]) {
const cents = Math.round(perKwh * cheapest.usable * 100)
const result = computeEngine(bestCase, [battery(cheapest, cents)])
const payback = result.batteries[0]?.paybackBaseYears
console.log(
` ${String(perKwh).padStart(6)} ${`€${(cents / 100).toFixed(0)}`.padStart(8)} ` +
`${(payback == null ? "geen" : payback.toFixed(1)).padStart(6)} ${result.recommendation ?? "—"}`,
)
}
// ---------------------------------------------------------------------------
// Plug-in track (added 2026-07-30)
//
// The grid above is fixed batteries for owner-occupiers, which is what the
// engine could rank before plug-ins were enabled. It is no longer the whole
// product: a renter routes to the plug-in track by rule R0, and about a third
// of Dutch households rent. Reporting only the fixed-battery number would keep
// quoting 98.8% for a site that now ranks the affordable category too.
// ---------------------------------------------------------------------------
// Read from catalogue.json rather than a second hardcoded list: the two drifted
// on 2026-07-31 and the grid reported buy recommendations production could not
// produce. See scripts/calibration/catalogue-fleet.ts.
const pluginFleet = catalogueFleet("PlugIn")
const pluginOutcomes = new Map<string, number>()
const pluginPaybacks: Array<number> = []
for (const input of cases) {
// Same households, routed to the plug-in track as a renter would be (R0).
const result = computeEngine({ ...input, ownership: "huur" }, pluginFleet)
const key = result.recommendation ?? "geen geschikte batterij"
pluginOutcomes.set(key, (pluginOutcomes.get(key) ?? 0) + 1)
const best = result.batteries[0]
if (best?.paybackBaseYears != null) {
pluginPaybacks.push(best.paybackBaseYears)
}
}
console.log(`\nplug-in track — same ${cases.length} households as renters`)
for (const [outcome, count] of [...pluginOutcomes].sort((a, b) => b[1] - a[1])) {
const pct = ((count / cases.length) * 100).toFixed(1)
console.log(` ${outcome.padEnd(26)} ${String(count).padStart(4)} ${pct.padStart(5)}%`)
}
console.log(
` median payback ${quantile(pluginPaybacks, 0.5).toFixed(1)}j ` +
`p10 ${quantile(pluginPaybacks, 0.1).toFixed(1)}j`,
)