import { writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import rules from "./rules-v0.2.json"; type Component = | "energy" | "networkVariable" | "networkFixed" | "subscription" | "quality" | "oze" | "cogeneration" | "transition" | "capacity"; const components: Component[] = [ "energy", "networkVariable", "networkFixed", "subscription", "quality", "oze", "cogeneration", "transition", "capacity", ]; type Total = Record & { vat: number; totalGross: number }; type YearRule = (typeof rules.distributionYears)["2022"] & { protectedVariable?: number; protectedFixed?: number; protectedQuality?: number; thresholdKwh?: number; capacitySuspendedFrom?: string; }; type SaleRule = { start: string; end: string; approved: number; applied?: number; protected?: number; aboveThreshold?: number; thresholdKwh?: number; vat: number; }; const cents = (value: number) => Math.round((value + Number.EPSILON) * 100); const iso = (date: Date) => date.toISOString().slice(0, 10); const day = 86_400_000; function bandIndex(kwh: number): number { const [first, second, third] = rules.capacityBandBoundariesKwh; if (kwh < first) return 0; if (kwh <= second) return 1; if (kwh <= third) return 2; return 3; } function transitionRate(kwh: number): number { const [first, second, third] = rules.transitionBands; if (kwh < (first.lessThanKwh ?? 0)) return first.rate; if (kwh <= (second.throughKwh ?? 0)) return second.rate; return third.rate; } function add(target: Record, key: Component, value: number) { target[key] += value; } function empty(): Record { return Object.fromEntries(components.map((key) => [key, 0])) as Record< Component, number >; } export function simulate(year: 2022 | 2023 | 2024, annualKwh: number): Total { if (!Number.isFinite(annualKwh) || annualKwh < 0) throw new Error("annualKwh must be nonnegative and finite"); const yearRule = rules.distributionYears[String(year) as "2022"] as YearRule; const daysInYear = (Date.UTC(year + 1, 0, 1) - Date.UTC(year, 0, 1)) / day; const dailyKwh = annualKwh / daysInYear; const total = { ...empty(), vat: 0, totalGross: 0 }; let cumulativeYearKwh = 0; let cumulativeH1Kwh = 0; for (let month = 0; month < 12; month++) { const monthly = empty(); const start = Date.UTC(year, month, 1); const end = Date.UTC(year, month + 1, 1); const daysInMonth = (end - start) / day; let protectedDays = 0; let monthlyVatRate = 0; for (let current = start; current < end; current += day) { const date = iso(new Date(current)); const saleRule = (rules.saleIntervals as SaleRule[]).find( (rule) => date >= rule.start && date < rule.end, ); if (!saleRule) throw new Error(`No sale rule for ${date}`); monthlyVatRate = saleRule.vat; const h1 = year === 2024 && date < "2024-07-01"; const protection = saleRule.protected !== undefined; const cap = saleRule.thresholdKwh ?? 0; const prior = year === 2023 ? cumulativeYearKwh : cumulativeH1Kwh; const protectedKwh = protection ? Math.min(dailyKwh, Math.max(0, cap - prior)) : 0; const unprotectedKwh = dailyKwh - protectedKwh; if (protection) protectedDays += dailyKwh === 0 ? 1 : protectedKwh / dailyKwh; if (protection) { add( monthly, "energy", protectedKwh * (saleRule.protected ?? 0) + unprotectedKwh * (saleRule.aboveThreshold ?? 0), ); } else { add(monthly, "energy", dailyKwh * (saleRule.applied ?? 0)); } const protectedVariable = yearRule.protectedVariable ?? yearRule.approvedVariable; const protectedQuality = yearRule.protectedQuality ?? yearRule.approvedQuality; add( monthly, "networkVariable", protectedKwh * protectedVariable + unprotectedKwh * yearRule.approvedVariable, ); add( monthly, "quality", protectedKwh * protectedQuality + unprotectedKwh * yearRule.approvedQuality, ); add(monthly, "oze", dailyKwh * yearRule.oze); add(monthly, "cogeneration", dailyKwh * yearRule.cogeneration); cumulativeYearKwh += dailyKwh; if (h1) cumulativeH1Kwh += dailyKwh; } const protectedFixed = yearRule.protectedFixed ?? yearRule.approvedFixed; const protectedMonthShare = protectedDays / daysInMonth; add( monthly, "networkFixed", protectedMonthShare * protectedFixed + (1 - protectedMonthShare) * yearRule.approvedFixed, ); add(monthly, "subscription", yearRule.subscription); add(monthly, "transition", transitionRate(annualKwh)); const capacitySuspended = yearRule.capacitySuspendedFrom !== undefined && iso(new Date(start)) >= yearRule.capacitySuspendedFrom; if (!capacitySuspended) add(monthly, "capacity", yearRule.capacityBands[bandIndex(annualKwh)]); let monthlyNetCents = 0; for (const component of components) { const componentCents = cents(monthly[component]); total[component] += componentCents; monthlyNetCents += componentCents; } const monthlyVatCents = cents((monthlyNetCents / 100) * monthlyVatRate); total.vat += monthlyVatCents; total.totalGross += monthlyNetCents + monthlyVatCents; } return total; } const out = fileURLToPath(new URL("./benchmark-v0.2.csv", import.meta.url)); const header = ["year", "annual_kwh", ...components, "vat", "total_gross"].join( ",", ); const rows = [header]; for (const year of [2022, 2023, 2024] as const) { for (const annualKwh of rules.baseline.annualKwh) { const total = simulate(year, annualKwh); rows.push( [ year, annualKwh, ...components.map((component) => (total[component] / 100).toFixed(2)), (total.vat / 100).toFixed(2), (total.totalGross / 100).toFixed(2), ].join(","), ); } } if (import.meta.main) writeFileSync(out, `${rows.join("\n")}\n`);