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.
196 lines
6.0 KiB
TypeScript
196 lines
6.0 KiB
TypeScript
// 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);
|
|
}
|