Berekening: gevoeligheidsanalyse

De uitvoerbare berekening die meet hoeveel van de uitkomst door onze eigen voorzichtige aannames wordt bepaald in plaats van door de markt.

Dit is de berekening zelf, ongewijzigd overgenomen uit scripts/calibration/sensitivity.ts. Je kunt hem uitvoeren met bun scripts/calibration/sensitivity.ts en de gepubliceerde getallen reproduceren.

/**
 * Sensitivity analysis — how much of the 98.8% NIET_KOPEN result is the market,
 * and how much is our own conservatism?
 *
 * `outcomes.ts` measures the verified engine as it ships. This script re-runs
 * the identical 162-household grid while relaxing each deliberately
 * conservative choice, one at a time and then all together, and reports how the
 * outcome distribution moves.
 *
 * Why this exists: the Workstream 0.6 independent technical review was removed
 * from the launch plan (roadmap §0, 30 July 2026) because a reviewer could not
 * be sourced. That review was meant to answer whether the accumulated
 * conservative choices leave the model honest or systematically pessimistic.
 * This converts that judgement into a measurement.
 *
 * IT DOES NOT CHANGE WHAT THE ENGINE SHIPS. Every override is applied in
 * process, to a cloned value, and restored afterwards. A relaxed number is a
 * hypothetical, never a proposal: moving a constant requires a source, not a
 * preferred outcome.
 *
 * Run: bun scripts/calibration/sensitivity.ts
 */

import { computeEngine } from "~/lib/engine"
import {
  captureFactorCurve,
  degradationFloorPctYr10,
  exportEconomicsAssumptions,
  policyAssumptions,
  scenarioAssumptions,
} from "~/lib/engine/assumptions"
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]): 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: spec.cents, mid: spec.cents, max: spec.cents },
})

// The identical grid to outcomes.ts — 3 × 3 × 3 × 2 × 3 = 162 households.
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 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)
}

type Measurement = {
  label: string
  note: string
  outcomes: Map<string, number>
  medianPayback: number
  p10Payback: number
  buyable: number
}

const measure = (label: string, note: string): Measurement => {
  const outcomes = new Map<string, number>()
  const paybacks: Array<number> = []

  for (const input of cases) {
    const result = computeEngine(input, fleet)
    const key = result.recommendation ?? "geen geschikte batterij"
    outcomes.set(key, (outcomes.get(key) ?? 0) + 1)

    const best = result.batteries[0]
    if (best?.paybackBaseYears != null) {
      paybacks.push(best.paybackBaseYears)
    }
  }

  // Anything that is not "don't buy" — the commercially reachable population.
  const notNoBuy = cases.length - (outcomes.get("NIET_KOPEN") ?? 0)

  return {
    label,
    note,
    outcomes,
    medianPayback: quantile(paybacks, 0.5),
    p10Payback: quantile(paybacks, 0.1),
    buyable: notNoBuy,
  }
}

/**
 * Each relaxation restores the pre-conservatism value of one constant. The
 * "why this is the honest counterfactual" note is what makes the number
 * interpretable — a relaxation with no defensible source is not evidence that
 * the shipped value is wrong.
 */
type Relaxation = {
  key: string
  label: string
  note: string
  /**
   * Whether relaxing this constant actually favours batteries. Not every
   * "less conservative" change does: removing terugleverkosten makes exporting
   * cheaper, which REDUCES the value of storing. Bundling an anti-battery
   * relaxation into the combined case would cancel out the favourable ones and
   * understate the true sensitivity, so the combined case uses only the
   * favourable subset.
   */
  favoursBattery: boolean
  apply: () => void
}

const RELAXATIONS: Array<Relaxation> = [
  {
    key: "capture",
    favoursBattery: true,
    label: "Capture curve flattened to 0.88",
    note: "The pre-Phase-C flat value, replacing the calibrated curve — what the calibration costs.",
    apply: () => {
      // Flatten the calibrated curve back to the pre-calibration 0.88, so the
      // comparison is against what the engine assumed before Phase C rather
      // than against a differently-shaped curve.
      captureFactorCurve.value = { high: 0.88, low: 0.88, ratioStart: 0.35, ratioEnd: 0.8 }
    },
  },
  {
    key: "degradation",
    favoursBattery: true,
    label: "Degradation floor 0.70 → expected 0.80",
    note: "The expected-curve base value that decision D2 replaced with the warranted floor.",
    apply: () => {
      degradationFloorPctYr10.value = 0.8
    },
  },
  {
    key: "import",
    favoursBattery: true,
    label: "Import price 25 → 30 ct",
    note: "A higher electricity price makes stored kWh more valuable. 30 ct was the earlier working figure.",
    apply: () => {
      scenarioAssumptions.importPriceCt.value = { conservative: 26, base: 30, favourable: 36 }
    },
  },
  {
    key: "terugleverkosten",
    favoursBattery: false,
    label: "Terugleverkosten excluded",
    note: "Removes the per-kWh feed-in charge from export economics in every era.",
    apply: () => {
      const eras = exportEconomicsAssumptions.value
      for (const era of Object.values(eras)) {
        era.feedInChargeCt = { conservative: 0, base: 0, favourable: 0 }
      }
    },
  },
]

/** Snapshot every value this script can touch, so each run starts clean. */
const snapshot = () => ({
  captureCurve: structuredClone(captureFactorCurve.value),
  importPriceCt: structuredClone(scenarioAssumptions.importPriceCt.value),
  degradation: degradationFloorPctYr10.value,
  exportEconomics: structuredClone(exportEconomicsAssumptions.value),
})

const restore = (original: ReturnType<typeof snapshot>) => {
  captureFactorCurve.value = structuredClone(original.captureCurve)
  scenarioAssumptions.importPriceCt.value = structuredClone(original.importPriceCt)
  degradationFloorPctYr10.value = original.degradation
  exportEconomicsAssumptions.value = structuredClone(original.exportEconomics)
}

/** The label of the row every published figure is drawn from. */
export const HEADLINE_VARIANT = "Battery-favourable subset only"

export type SensitivityReport = {
  /** How many households the grid runs, so a published "van de N" agrees. */
  householdCount: number
  baseline: Measurement
  /** The relaxations, the combined case, and the headline counterfactual. */
  variants: Array<Measurement>
  headline: Measurement
}

/**
 * Runs the whole analysis and returns it, rather than printing it.
 *
 * Returning the measurements is what lets `published-figures.test.ts` assert
 * that the percentages on the methodology page and in the guides still match
 * what the engine produces. They did not: the page claimed a 56.8% headline
 * that the analysis had long since moved off, and nothing failed.
 */
export const runSensitivityAnalysis = (): SensitivityReport => {
  const original = snapshot()
  const results: Array<Measurement> = []

  results.push(measure("Shipped engine (baseline)", "0.6.0-scoped / 1.0.0-verified-v1, unchanged"))

  for (const relaxation of RELAXATIONS) {
    restore(original)
    relaxation.apply()
    results.push(measure(relaxation.label, relaxation.note))
  }

  restore(original)
  for (const relaxation of RELAXATIONS) {
    relaxation.apply()
  }
  results.push(
    measure(
      "All four relaxed together",
      "Includes the terugleverkosten change, which is anti-battery — see the favourable-only row",
    ),
  )

  restore(original)
  for (const relaxation of RELAXATIONS.filter(entry => entry.favoursBattery)) {
    relaxation.apply()
  }
  results.push(
    measure(
      HEADLINE_VARIANT,
      "Capture + degradation + import price. THE headline counterfactual: the most battery-favourable defensible configuration",
    ),
  )

  restore(original)

  const baseline = results[0]!
  const headline = results.find(entry => entry.label === HEADLINE_VARIANT)!

  return { householdCount: cases.length, baseline, variants: results.slice(1), headline }
}

/** Share of the grid, as the one-decimal percentage the site publishes. */
export const share = (count: number, total: number): number => {
  return Math.round((count / total) * 1000) / 10
}

/**
 * Printing is the CLI's job, not the module's: `published-figures.test.ts`
 * imports the analysis, and a module that logs on import turns every test run
 * into a wall of report output.
 */
if (import.meta.main) {
  const report = runSensitivityAnalysis()
  const results = [report.baseline, ...report.variants]

  // ---------------------------------------------------------------------------
  // Trading is handled separately and deliberately does NOT re-enable the module.
  // Its constants throw on read by design (memo S1) — circumventing that guard is
  // exactly the failure mode the guard exists to prevent. Instead the uplift is
  // applied analytically to the shipped baseline, using DNV's day-ahead-only
  // figure rather than the imbalance figures DNV calls unsustainable.
  // ---------------------------------------------------------------------------
  const TRADING_EUR_PER_KWH_YEAR = 6
  const buyMax = policyAssumptions.paybackBuyMaxYears.value
  const noBuyMin = policyAssumptions.paybackNoBuyMinYears.value

  let tradingKoop = 0
  let tradingWacht = 0
  let tradingNoBuy = 0

  for (const input of cases) {
    const result = computeEngine(input, fleet)
    const best = result.batteries[0]
    const baseScenario = best?.scenarios.find(entry => entry.scenario === "base")

    if (!best || !baseScenario || baseScenario.netCostCents == null) {
      continue
    }

    const upliftCents = best.battery.usableKwh * TRADING_EUR_PER_KWH_YEAR * 100
    const annualValue = baseScenario.valueYear1Cents + upliftCents

    if (annualValue <= 0) {
      tradingNoBuy++
      continue
    }

    const payback = baseScenario.netCostCents / annualValue
    if (payback <= buyMax) {
      tradingKoop++
    } else if (payback <= noBuyMin) {
      tradingWacht++
    } else {
      tradingNoBuy++
    }
  }

  // ---------------------------------------------------------------------------
  // Report
  // ---------------------------------------------------------------------------
  const pct = (count: number): string => `${((count / cases.length) * 100).toFixed(1)}%`

  console.log(`sensitivity analysis — ${cases.length} households, ${fleet.length} batteries\n`)
  console.log(
    "variant".padEnd(36) +
      "NIET_KOPEN".padStart(12) +
      "not-no-buy".padStart(12) +
      "median pb".padStart(11) +
      "p10 pb".padStart(9),
  )
  console.log("-".repeat(80))

  for (const result of results) {
    const noBuy = result.outcomes.get("NIET_KOPEN") ?? 0
    console.log(
      result.label.padEnd(36) +
        `${noBuy} (${pct(noBuy)})`.padStart(12) +
        `${result.buyable} (${pct(result.buyable)})`.padStart(12) +
        `${result.medianPayback.toFixed(1)}j`.padStart(11) +
        `${result.p10Payback.toFixed(1)}j`.padStart(9),
    )
  }

  console.log("\nfull outcome distribution per variant")
  for (const result of results) {
    const entries = [...result.outcomes].sort((a, b) => b[1] - a[1])
    console.log(`\n  ${result.label}`)
    console.log(`    ${result.note}`)
    for (const [outcome, count] of entries) {
      console.log(
        `      ${outcome.padEnd(26)} ${String(count).padStart(4)}  ${pct(count).padStart(6)}`,
      )
    }
  }

  console.log(
    `\ntrading uplift (indicative, module stays disabled) — €${TRADING_EUR_PER_KWH_YEAR}/kWh/yr day-ahead-only`,
  )
  console.log("  applied analytically to the shipped baseline; payback thresholds only, so it")
  console.log("  ignores the warranty-ratio rules the real decision tree also applies.")
  console.log(
    `      KOOP_NU (indicative)       ${String(tradingKoop).padStart(4)}  ${pct(tradingKoop).padStart(6)}`,
  )
  console.log(
    `      WACHT (indicative)         ${String(tradingWacht).padStart(4)}  ${pct(tradingWacht).padStart(6)}`,
  )
  console.log(
    `      NIET_KOPEN (indicative)    ${String(tradingNoBuy).padStart(4)}  ${pct(tradingNoBuy).padStart(6)}`,
  )

  console.log(
    "\nEvery relaxation above is a hypothetical. Changing a shipped constant requires a source.",
  )
}