// Pure helpers for the energy tracker. Inputs come from the ned-collector tables in the // `energy` schema (NED.nl electricity data and AGSI/GIE gas storage). export type SourceType = | 'Nuclear' | 'WindOffshoreC' | 'Wind' | 'Solar' | 'BiomassPower' | 'WastePower' | 'WKK Total' | 'FossilGasPower' | 'FossilHardCoal' | 'OtherPower'; export type Source = { type: SourceType; label: string; color: string; renewable: boolean; fossil: boolean; }; // Stacking order, bottom to top: steady baseload first, weather-driven next, fossil on top. export const SOURCES: readonly Source[] = [ { type: 'Nuclear', label: 'Nuclear', color: '#a78bfa', renewable: false, fossil: false }, { type: 'WindOffshoreC', label: 'Wind offshore', color: '#0284c7', renewable: true, fossil: false }, { type: 'Wind', label: 'Wind onshore', color: '#38bdf8', renewable: true, fossil: false }, { type: 'Solar', label: 'Solar', color: '#facc15', renewable: true, fossil: false }, { type: 'BiomassPower', label: 'Biomass', color: '#65a30d', renewable: true, fossil: false }, { type: 'WastePower', label: 'Waste', color: '#a3a3a3', renewable: false, fossil: false }, { type: 'WKK Total', label: 'CHP (WKK)', color: '#fdba74', renewable: false, fossil: true }, { type: 'FossilGasPower', label: 'Fossil gas', color: '#f97316', renewable: false, fossil: true }, { type: 'FossilHardCoal', label: 'Hard coal', color: '#57534e', renewable: false, fossil: true }, { type: 'OtherPower', label: 'Other', color: '#94a3b8', renewable: false, fossil: false } ]; export const MIX = 'ElectricityMix'; export const LOAD = 'ElectricityLoad'; const sourceTypes = new Set(SOURCES.map((source) => source.type)); /** One row per (time bucket, type). `value` is MWh for hours/days and GWh for months/years. */ export type EnergyRow = { t: number; type: string; value: number; pct?: number | null; ef?: number | null; hours?: number; expected?: number; }; export type Bucket = { t: number; mix: number | null; load: number | null; ef: number | null; sources: Partial>; sum: number; hours?: number; /** Hours in the period (23 or 25 on DST change days). */ expected?: number; /** The source rows don't add up to the NED total, so the bucket is still being filled in. */ incomplete: boolean; }; /** Group rows into buckets ordered by time. A bucket is incomplete when its sources sum to * less than 98% (or more than 102%) of the published total. */ export function toBuckets(rows: readonly EnergyRow[]): Bucket[] { const buckets = new Map(); for (const row of rows) { let bucket = buckets.get(row.t); if (!bucket) { bucket = { t: row.t, mix: null, load: null, ef: null, sources: {}, sum: 0, incomplete: false }; buckets.set(row.t, bucket); } if (row.hours !== undefined) bucket.hours = Math.max(bucket.hours ?? 0, row.hours); if (row.expected !== undefined) bucket.expected = row.expected; if (row.type === MIX) { bucket.mix = row.value; bucket.ef = row.ef ?? null; } else if (row.type === LOAD) bucket.load = row.value; else if (sourceTypes.has(row.type)) { bucket.sources[row.type as SourceType] = { value: row.value, pct: row.pct ?? null }; bucket.sum += row.value; } } const list = [...buckets.values()].sort((a, b) => a.t - b.t); for (const bucket of list) { bucket.incomplete = bucket.mix === null ? bucket.sum === 0 : Math.abs(bucket.sum / bucket.mix - 1) > 0.02; } return list; } export type Snapshot = { t: number; total: number; load: number | null; ef: number | null; renewableShare: number; lowCarbonShare: number; fossilShare: number; sources: { type: SourceType; label: string; color: string; value: number; share: number; utilization: number | null; }[]; }; /** The latest complete bucket, broken down by source. */ export function latestSnapshot(buckets: readonly Bucket[]): Snapshot | null { const bucket = [...buckets] .reverse() .find((candidate) => !candidate.incomplete && candidate.sum > 0); if (!bucket) return null; const total = bucket.sum; const share = (filter: (source: Source) => boolean) => SOURCES.filter(filter).reduce( (sum, source) => sum + (bucket.sources[source.type]?.value ?? 0), 0 ) / total; return { t: bucket.t, total, load: bucket.load, ef: bucket.ef, renewableShare: share((source) => source.renewable), lowCarbonShare: share((source) => source.renewable || source.type === 'Nuclear'), fossilShare: share((source) => source.fossil), sources: SOURCES.map((source) => { const entry = bucket.sources[source.type]; return { type: source.type, label: source.label, color: source.color, value: entry?.value ?? 0, share: (entry?.value ?? 0) / total, utilization: entry?.pct ?? null }; }).sort((a, b) => b.value - a.value) }; } /** Insert empty buckets so charts show gaps where the collector missed data. */ export function fillGaps( buckets: readonly Bucket[], step: number ): (Bucket | { t: number; missing: true })[] { const out: (Bucket | { t: number; missing: true })[] = []; for (const bucket of buckets) { const previous = out.at(-1); if (previous) for (let t = previous.t + step; t < bucket.t; t += step) out.push({ t, missing: true }); out.push(bucket); } return out; } /** Hours missing between consecutive buckets, as [from, to) ranges in epoch ms. */ export function gaps( times: readonly number[], step = 3_600_000 ): { from: number; to: number; hours: number }[] { const result = []; for (let index = 1; index < times.length; index++) { const missing = Math.round((times[index] - times[index - 1]) / step) - 1; if (missing > 0) result.push({ from: times[index - 1] + step, to: times[index], hours: missing }); } return result; } /** Map a date onto leap year 2000 so different years can share one seasonal x axis. */ export function seasonalTime(day: string) { const [, month, date] = day.split('-').map(Number); return Date.UTC(2000, month - 1, date); }