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:
195
src/lib/energy/data.ts
Normal file
195
src/lib/energy/data.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user