Add energy tracker (/projects/energy)

Public, read-only page over the ned-collector energy schema: latest-hour
mix and per-source utilization, generation vs consumption with CO2
intensity (day/week/month/12 months/all years), NED forecast, gas storage
by year and per site, and gas-fired power share vs storage fill.
Incomplete hours (sources not summing to the NED total) are left out and
partial days/months/years are marked.
This commit is contained in:
2026-10-23 23:11:27 +00:00
parent 88837af624
commit 0cf5d44062
11 changed files with 1393 additions and 1 deletions

195
src/lib/energy/data.ts Normal file
View File

@@ -0,0 +1,195 @@
// 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<string>(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<Record<SourceType, { value: number; pct: number | null }>>;
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<number, Bucket>();
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);
}