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`,
)