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:
104
src/lib/energy/EnergyChart.svelte
Normal file
104
src/lib/energy/EnergyChart.svelte
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
export type ChartTheme = {
|
||||||
|
text: string;
|
||||||
|
muted: string;
|
||||||
|
border: string;
|
||||||
|
surface: string;
|
||||||
|
dark: boolean;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { ECharts, EChartsCoreOption } from 'echarts/core';
|
||||||
|
|
||||||
|
// Generic ECharts wrapper for the energy tracker: same lazy-loading and theme handling as
|
||||||
|
// the CS price chart, but the caller builds the option from the current theme colours.
|
||||||
|
let {
|
||||||
|
option,
|
||||||
|
label,
|
||||||
|
height = 'min(52vh, 440px)'
|
||||||
|
}: {
|
||||||
|
option: (theme: ChartTheme) => EChartsCoreOption;
|
||||||
|
label: string;
|
||||||
|
height?: string;
|
||||||
|
} = $props();
|
||||||
|
let element: HTMLDivElement;
|
||||||
|
let chart: ECharts | undefined;
|
||||||
|
let ready = $state(false);
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
if (!chart) return;
|
||||||
|
const style = getComputedStyle(document.documentElement);
|
||||||
|
const read = (name: string) => style.getPropertyValue(name).trim();
|
||||||
|
chart.setOption(
|
||||||
|
option({
|
||||||
|
text: read('--site-text'),
|
||||||
|
muted: read('--site-muted'),
|
||||||
|
border: read('--site-border'),
|
||||||
|
surface: read('--site-surface'),
|
||||||
|
dark: read('--site-bg') === '#000'
|
||||||
|
}),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let disposed = false;
|
||||||
|
let resize: ResizeObserver | undefined;
|
||||||
|
let theme: MutationObserver | undefined;
|
||||||
|
import('echarts/core').then(async (echarts) => {
|
||||||
|
const [
|
||||||
|
{ LineChart, BarChart, PieChart },
|
||||||
|
{ GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, MarkAreaComponent },
|
||||||
|
{ CanvasRenderer }
|
||||||
|
] = await Promise.all([
|
||||||
|
import('echarts/charts'),
|
||||||
|
import('echarts/components'),
|
||||||
|
import('echarts/renderers')
|
||||||
|
]);
|
||||||
|
if (disposed) return;
|
||||||
|
echarts.use([
|
||||||
|
LineChart,
|
||||||
|
BarChart,
|
||||||
|
PieChart,
|
||||||
|
GridComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
LegendComponent,
|
||||||
|
DataZoomComponent,
|
||||||
|
MarkAreaComponent,
|
||||||
|
CanvasRenderer
|
||||||
|
]);
|
||||||
|
chart = echarts.init(element, undefined, { renderer: 'canvas' });
|
||||||
|
ready = true;
|
||||||
|
update();
|
||||||
|
resize = new ResizeObserver(() => chart?.resize());
|
||||||
|
resize.observe(element);
|
||||||
|
theme = new MutationObserver(update);
|
||||||
|
theme.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['data-theme']
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
resize?.disconnect();
|
||||||
|
theme?.disconnect();
|
||||||
|
chart?.dispose();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
// Re-render when the option builder (and therefore its inputs) changes.
|
||||||
|
void option;
|
||||||
|
if (ready) update();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="chart" style:height bind:this={element} role="img" aria-label={label}></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chart {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 260px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
42
src/lib/energy/data.test.ts
Normal file
42
src/lib/energy/data.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { fillGaps, gaps, latestSnapshot, seasonalTime, toBuckets, type EnergyRow } from './data';
|
||||||
|
|
||||||
|
const hour = 3_600_000;
|
||||||
|
const rows: EnergyRow[] = [
|
||||||
|
{ t: 0, type: 'ElectricityMix', value: 100, ef: 0.2 },
|
||||||
|
{ t: 0, type: 'ElectricityLoad', value: 90 },
|
||||||
|
{ t: 0, type: 'Wind', value: 60, pct: 0.5 },
|
||||||
|
{ t: 0, type: 'FossilGasPower', value: 30, pct: 0.1 },
|
||||||
|
{ t: 0, type: 'Nuclear', value: 10, pct: 1 },
|
||||||
|
{ t: hour, type: 'ElectricityMix', value: 100 },
|
||||||
|
{ t: hour, type: 'Wind', value: 20 }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('energy buckets', () => {
|
||||||
|
it('flags buckets whose sources do not add up to the total', () => {
|
||||||
|
const buckets = toBuckets(rows);
|
||||||
|
expect(buckets.map((bucket) => bucket.incomplete)).toEqual([false, true]);
|
||||||
|
expect(buckets[0].sum).toBe(100);
|
||||||
|
expect(buckets[0].load).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summarises the latest complete bucket', () => {
|
||||||
|
const snapshot = latestSnapshot(toBuckets(rows))!;
|
||||||
|
expect(snapshot.t).toBe(0);
|
||||||
|
expect(snapshot.renewableShare).toBeCloseTo(0.6);
|
||||||
|
expect(snapshot.lowCarbonShare).toBeCloseTo(0.7);
|
||||||
|
expect(snapshot.fossilShare).toBeCloseTo(0.3);
|
||||||
|
expect(snapshot.sources[0]).toMatchObject({ type: 'Wind', share: 0.6, utilization: 0.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds and fills missing hours', () => {
|
||||||
|
expect(gaps([0, hour, 4 * hour])).toEqual([{ from: 2 * hour, to: 4 * hour, hours: 2 }]);
|
||||||
|
const filled = fillGaps(toBuckets([...rows, { t: 3 * hour, type: 'Wind', value: 1 }]), hour);
|
||||||
|
expect(filled.map((bucket) => 'missing' in bucket)).toEqual([false, false, true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps dates onto one seasonal year', () => {
|
||||||
|
expect(seasonalTime('2023-02-28')).toBe(Date.UTC(2000, 1, 28));
|
||||||
|
expect(seasonalTime('2024-12-31')).toBe(Date.UTC(2000, 11, 31));
|
||||||
|
});
|
||||||
|
});
|
||||||
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);
|
||||||
|
}
|
||||||
@@ -58,6 +58,12 @@ export const trackers = [
|
|||||||
href: '/projects/cs-prices',
|
href: '/projects/cs-prices',
|
||||||
external: false
|
external: false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'energy',
|
||||||
|
subtitle: 'Dutch electricity mix, utilization and gas storage',
|
||||||
|
href: '/projects/energy',
|
||||||
|
external: false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'animaldex',
|
name: 'animaldex',
|
||||||
subtitle: 'add neighborhood pets to the world map',
|
subtitle: 'add neighborhood pets to the world map',
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const publicPages: Record<string, { title: string; description: string }> = {
|
|||||||
'/trackers': {
|
'/trackers': {
|
||||||
title: 'Trackers',
|
title: 'Trackers',
|
||||||
description:
|
description:
|
||||||
'Stan Runge’s trackers: games, money, houses, Counter-Strike prices, Animaldex and lists.'
|
'Stan Runge’s trackers: games, money, houses, Counter-Strike prices, Dutch energy, Animaldex and lists.'
|
||||||
},
|
},
|
||||||
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
|
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
|
||||||
'/projects/game-tracker': {
|
'/projects/game-tracker': {
|
||||||
@@ -69,6 +69,11 @@ const publicPages: Record<string, { title: string; description: string }> = {
|
|||||||
description:
|
description:
|
||||||
'Compare historical Counter-Strike market prices by listing, weapon, skin, wear, and other item dimensions.'
|
'Compare historical Counter-Strike market prices by listing, weapon, skin, wear, and other item dimensions.'
|
||||||
},
|
},
|
||||||
|
'/projects/energy': {
|
||||||
|
title: 'Energy',
|
||||||
|
description:
|
||||||
|
'Dutch electricity generation mix, source utilization, consumption trends, CO₂ intensity and gas storage, from NED.nl data.'
|
||||||
|
},
|
||||||
'/projects/derby': {
|
'/projects/derby': {
|
||||||
title: 'Derby Lens',
|
title: 'Derby Lens',
|
||||||
description: 'Estimate horse racing odds and compare them with market prices.'
|
description: 'Estimate horse racing odds and compare them with market prices.'
|
||||||
|
|||||||
121
src/lib/server/energy.ts
Normal file
121
src/lib/server/energy.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { db } from '#lib/server/db/index.js';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import type { EnergyRow } from '#lib/energy/data.js';
|
||||||
|
|
||||||
|
// Read-only queries over the ned-collector tables in the `energy` schema. The app role
|
||||||
|
// already has USAGE + SELECT there; nothing on the site writes to these tables.
|
||||||
|
|
||||||
|
type Raw = Record<string, unknown>;
|
||||||
|
const num = (value: unknown) => (value === null || value === undefined ? null : Number(value));
|
||||||
|
const time = (value: unknown) => new Date(value as string | Date).getTime();
|
||||||
|
const rows = (result: unknown) => result as Raw[];
|
||||||
|
|
||||||
|
function energyRows(result: unknown): EnergyRow[] {
|
||||||
|
return rows(result).map((row) => ({
|
||||||
|
t: time(row.t),
|
||||||
|
type: String(row.type),
|
||||||
|
value: Number(row.value),
|
||||||
|
pct: num(row.pct),
|
||||||
|
ef: num(row.ef),
|
||||||
|
...(row.hours === undefined ? {} : { hours: Number(row.hours) }),
|
||||||
|
...(row.expected === undefined ? {} : { expected: Number(row.expected) })
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadEnergy() {
|
||||||
|
const [hourly, daily, forecast, monthly, yearly, gasNl, gasSites, runs, coverage] =
|
||||||
|
await Promise.all([
|
||||||
|
// Last 7 days of hourly measurements, counted back from the newest hour NED published.
|
||||||
|
db.execute(sql`WITH latest AS (
|
||||||
|
SELECT max(valid_from) AS t FROM energy.ned_utilization
|
||||||
|
WHERE granularity = 'Hour' AND classification = 'Current')
|
||||||
|
SELECT valid_from AS t, type, volume_kwh::float8 / 1000 AS value,
|
||||||
|
percentage::float8 AS pct, emission_factor::float8 AS ef
|
||||||
|
FROM energy.ned_utilization, latest
|
||||||
|
WHERE granularity = 'Hour' AND classification = 'Current'
|
||||||
|
AND valid_from > latest.t - interval '7 days'
|
||||||
|
ORDER BY 1, 2`),
|
||||||
|
// Daily totals (Amsterdam days) from hourly data, last 31 days. `expected` is 23 or 25
|
||||||
|
// on DST change days so partial days can be flagged.
|
||||||
|
db.execute(sql`WITH latest AS (
|
||||||
|
SELECT max(valid_from) AS t FROM energy.ned_utilization
|
||||||
|
WHERE granularity = 'Hour' AND classification = 'Current'),
|
||||||
|
days AS (
|
||||||
|
SELECT date_trunc('day', valid_from AT TIME ZONE 'Europe/Amsterdam') AS day, type,
|
||||||
|
sum(volume_kwh) AS kwh, sum(emission_kg) AS kg, count(*)::int AS hours
|
||||||
|
FROM energy.ned_utilization, latest
|
||||||
|
WHERE granularity = 'Hour' AND classification = 'Current'
|
||||||
|
AND valid_from > latest.t - interval '31 days'
|
||||||
|
GROUP BY 1, 2)
|
||||||
|
SELECT day AT TIME ZONE 'Europe/Amsterdam' AS t, type, kwh::float8 / 1000 AS value, hours,
|
||||||
|
(extract(epoch FROM (day + interval '1 day') AT TIME ZONE 'Europe/Amsterdam'
|
||||||
|
- day AT TIME ZONE 'Europe/Amsterdam') / 3600)::int AS expected,
|
||||||
|
(kg / nullif(kwh, 0))::float8 AS ef
|
||||||
|
FROM days ORDER BY 1, 2`),
|
||||||
|
db.execute(sql`WITH latest AS (
|
||||||
|
SELECT max(valid_from) AS t FROM energy.ned_utilization
|
||||||
|
WHERE granularity = 'Hour' AND classification = 'Current')
|
||||||
|
SELECT valid_from AS t, type, volume_kwh::float8 / 1000 AS value
|
||||||
|
FROM energy.ned_utilization, latest
|
||||||
|
WHERE granularity = 'Hour' AND classification = 'Forecast'
|
||||||
|
AND valid_from > latest.t AND valid_from <= latest.t + interval '8 days'
|
||||||
|
AND type IN ('Wind', 'WindOffshoreC', 'Solar', 'ElectricityLoad', 'ElectricityMix')
|
||||||
|
ORDER BY 1, 2`),
|
||||||
|
db.execute(sql`SELECT valid_from AS t, type, volume_kwh::float8 / 1e6 AS value,
|
||||||
|
percentage::float8 AS pct, emission_factor::float8 AS ef
|
||||||
|
FROM energy.ned_utilization
|
||||||
|
WHERE granularity = 'Month' AND classification = 'Current'
|
||||||
|
ORDER BY 1, 2`),
|
||||||
|
db.execute(sql`SELECT valid_from AS t, type, volume_kwh::float8 / 1e6 AS value,
|
||||||
|
emission_factor::float8 AS ef
|
||||||
|
FROM energy.ned_utilization
|
||||||
|
WHERE granularity = 'Year' AND classification = 'Current'
|
||||||
|
ORDER BY 1, 2`),
|
||||||
|
db.execute(sql`SELECT gas_day::text AS day, fill_pct::float8 AS fill, volume_twh::float8 AS twh
|
||||||
|
FROM energy.gas_storage_daily WHERE site = 'NL' ORDER BY gas_day`),
|
||||||
|
db.execute(sql`SELECT site, gas_day::text AS day, fill_pct::float8 AS fill,
|
||||||
|
volume_twh::float8 AS twh
|
||||||
|
FROM energy.gas_storage_latest WHERE site <> 'NL' ORDER BY volume_twh DESC`),
|
||||||
|
db.execute(sql`SELECT
|
||||||
|
(SELECT max(started_at) FROM energy.collector_runs) AS last_run,
|
||||||
|
(SELECT max(finished_at) FROM energy.collector_runs WHERE ok) AS last_ok,
|
||||||
|
(SELECT count(*)::int FROM energy.collector_runs
|
||||||
|
WHERE NOT ok AND started_at > now() - interval '7 days') AS failed_week`),
|
||||||
|
db.execute(sql`SELECT min(valid_from) AS first_hour, max(valid_from) AS last_hour,
|
||||||
|
count(DISTINCT valid_from)::int AS hours
|
||||||
|
FROM energy.ned_utilization WHERE granularity = 'Hour' AND classification = 'Current'`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const run = rows(runs)[0] ?? {};
|
||||||
|
const cover = rows(coverage)[0] ?? {};
|
||||||
|
return {
|
||||||
|
hourly: energyRows(hourly),
|
||||||
|
daily: energyRows(daily),
|
||||||
|
forecast: energyRows(forecast),
|
||||||
|
monthly: energyRows(monthly),
|
||||||
|
yearly: energyRows(yearly),
|
||||||
|
gas: rows(gasNl).map((row) => ({
|
||||||
|
day: String(row.day),
|
||||||
|
fill: Number(row.fill),
|
||||||
|
twh: Number(row.twh)
|
||||||
|
})),
|
||||||
|
gasSites: rows(gasSites).map((row) => ({
|
||||||
|
site: String(row.site),
|
||||||
|
day: String(row.day),
|
||||||
|
fill: Number(row.fill),
|
||||||
|
twh: Number(row.twh)
|
||||||
|
})),
|
||||||
|
collector: {
|
||||||
|
lastRun: run.last_run ? time(run.last_run) : null,
|
||||||
|
lastOk: run.last_ok ? time(run.last_ok) : null,
|
||||||
|
failedWeek: Number(run.failed_week ?? 0)
|
||||||
|
},
|
||||||
|
coverage: {
|
||||||
|
firstHour: cover.first_hour ? time(cover.first_hour) : null,
|
||||||
|
lastHour: cover.last_hour ? time(cover.last_hour) : null,
|
||||||
|
hours: Number(cover.hours ?? 0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnergyData = Awaited<ReturnType<typeof loadEnergy>>;
|
||||||
@@ -30,6 +30,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
|
|||||||
'/projects/animaldex/admin': 'Animaldex admin',
|
'/projects/animaldex/admin': 'Animaldex admin',
|
||||||
'/projects/derby': 'Derby Lens',
|
'/projects/derby': 'Derby Lens',
|
||||||
'/projects/cs-prices': 'CS Prices',
|
'/projects/cs-prices': 'CS Prices',
|
||||||
|
'/projects/energy': 'Energy',
|
||||||
'/projects/game-tracker': 'Game Tracker',
|
'/projects/game-tracker': 'Game Tracker',
|
||||||
'/projects/language-learner': 'Language Learner',
|
'/projects/language-learner': 'Language Learner',
|
||||||
'/projects/money-tracker': 'Money Tracker',
|
'/projects/money-tracker': 'Money Tracker',
|
||||||
|
|||||||
4
src/routes/projects/energy/+page.server.ts
Normal file
4
src/routes/projects/energy/+page.server.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { loadEnergy } from '#lib/server/energy.js';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => ({ energy: await loadEnergy() });
|
||||||
896
src/routes/projects/energy/+page.svelte
Normal file
896
src/routes/projects/energy/+page.svelte
Normal file
@@ -0,0 +1,896 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Tabs from '#lib/components/ui/tabs/index.js';
|
||||||
|
import EnergyChart, { type ChartTheme } from '#lib/energy/EnergyChart.svelte';
|
||||||
|
import {
|
||||||
|
SOURCES,
|
||||||
|
fillGaps,
|
||||||
|
gaps,
|
||||||
|
latestSnapshot,
|
||||||
|
seasonalTime,
|
||||||
|
toBuckets,
|
||||||
|
type Bucket
|
||||||
|
} from '#lib/energy/data.js';
|
||||||
|
import { relativeTime } from '#lib/relative-time.js';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
let energy = $derived(data.energy);
|
||||||
|
|
||||||
|
const HOUR = 3_600_000;
|
||||||
|
const zone = 'Europe/Amsterdam';
|
||||||
|
const formatHour = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: zone,
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
const formatTick = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: zone,
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
const formatDay = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: zone,
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short'
|
||||||
|
});
|
||||||
|
const formatDate = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium', timeZone: zone });
|
||||||
|
const formatMonth = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: zone,
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
const formatYear = new Intl.DateTimeFormat('en-GB', { timeZone: zone, year: 'numeric' });
|
||||||
|
const formatSeason = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: 'UTC',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short'
|
||||||
|
});
|
||||||
|
const whole = new Intl.NumberFormat('en-GB', { maximumFractionDigits: 0 });
|
||||||
|
const one = new Intl.NumberFormat('en-GB', { maximumFractionDigits: 1 });
|
||||||
|
const percent = new Intl.NumberFormat('en-GB', { style: 'percent', maximumFractionDigits: 0 });
|
||||||
|
const escapeHtml = (value: string) =>
|
||||||
|
value.replace(
|
||||||
|
/[&<>"']/g,
|
||||||
|
(character) =>
|
||||||
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!
|
||||||
|
);
|
||||||
|
|
||||||
|
let hourly = $derived(toBuckets(energy.hourly));
|
||||||
|
let daily = $derived(toBuckets(energy.daily));
|
||||||
|
let monthly = $derived(toBuckets(energy.monthly));
|
||||||
|
let yearly = $derived(toBuckets(energy.yearly));
|
||||||
|
let forecast = $derived(toBuckets(energy.forecast));
|
||||||
|
let now = $derived(latestSnapshot(hourly));
|
||||||
|
let incompleteHours = $derived(hourly.filter((bucket) => bucket.incomplete));
|
||||||
|
let hourGaps = $derived(gaps(hourly.map((bucket) => bucket.t)));
|
||||||
|
let latestGas = $derived(energy.gas.at(-1));
|
||||||
|
let latestPeriod = $derived(energy.coverage.lastHour ?? Date.now());
|
||||||
|
|
||||||
|
type Range = 'day' | 'week' | 'month' | 'year' | 'years';
|
||||||
|
let range = $state<Range>('week');
|
||||||
|
const rangeLabels: Record<Range, string> = {
|
||||||
|
day: 'day',
|
||||||
|
week: 'week',
|
||||||
|
month: 'month',
|
||||||
|
year: '12 months',
|
||||||
|
years: 'all years'
|
||||||
|
};
|
||||||
|
|
||||||
|
function axis(theme: ChartTheme) {
|
||||||
|
return {
|
||||||
|
axisLine: { lineStyle: { color: theme.border } },
|
||||||
|
axisLabel: { color: theme.muted },
|
||||||
|
splitLine: { lineStyle: { color: theme.border } }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function tooltip(theme: ChartTheme, extra: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
backgroundColor: theme.surface,
|
||||||
|
borderColor: theme.border,
|
||||||
|
confine: true,
|
||||||
|
textStyle: { color: theme.text },
|
||||||
|
...extra
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function legend(theme: ChartTheme) {
|
||||||
|
return { top: 0, type: 'scroll', textStyle: { color: theme.muted }, itemWidth: 14 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// YYYY-MM-DD of an instant in Amsterdam, used to compare calendar periods.
|
||||||
|
const localDay = new Intl.DateTimeFormat('en-CA', { timeZone: zone });
|
||||||
|
const monthKey = (t: number) => localDay.format(t).slice(0, 7);
|
||||||
|
const yearKey = (t: number) => localDay.format(t).slice(0, 4);
|
||||||
|
/** Is the bucket's calendar period still running (current month/year)? */
|
||||||
|
const isPartial = (bucket: Partial<Bucket>, key: (t: number) => string) =>
|
||||||
|
key(bucket.t!) === key(latestPeriod);
|
||||||
|
|
||||||
|
let trend = $derived.by(() => {
|
||||||
|
if (range === 'day' || range === 'week') {
|
||||||
|
const from = latestPeriod - (range === 'day' ? 24 : 24 * 7) * HOUR;
|
||||||
|
const points = fillGaps(
|
||||||
|
hourly.filter((bucket) => bucket.t > from),
|
||||||
|
HOUR
|
||||||
|
).map((bucket) => ('missing' in bucket || bucket.incomplete ? { t: bucket.t } : bucket));
|
||||||
|
return { kind: 'hour' as const, unit: 'MWh', scale: 1, points: points as Partial<Bucket>[] };
|
||||||
|
}
|
||||||
|
if (range === 'month')
|
||||||
|
return { kind: 'day' as const, unit: 'GWh', scale: 1000, points: daily as Partial<Bucket>[] };
|
||||||
|
if (range === 'year')
|
||||||
|
return {
|
||||||
|
kind: 'month' as const,
|
||||||
|
unit: 'GWh',
|
||||||
|
scale: 1,
|
||||||
|
points: monthly as Partial<Bucket>[]
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
kind: 'year' as const,
|
||||||
|
unit: 'TWh',
|
||||||
|
scale: 1000,
|
||||||
|
// Before 2021 NED only publishes wind and solar per year, so those totals would mislead.
|
||||||
|
points: yearly.filter((bucket) => bucket.mix !== null) as Partial<Bucket>[]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function trendLabel(t: number) {
|
||||||
|
const kind = trend.kind;
|
||||||
|
if (kind === 'day') return formatDay.format(t);
|
||||||
|
if (kind === 'month') return formatMonth.format(t);
|
||||||
|
return formatYear.format(t);
|
||||||
|
}
|
||||||
|
function partialNote(bucket: Partial<Bucket>) {
|
||||||
|
if (trend.kind === 'day' && bucket.hours !== undefined && bucket.expected !== undefined)
|
||||||
|
if (bucket.hours < bucket.expected) return `${bucket.hours}/${bucket.expected} h`;
|
||||||
|
if (trend.kind === 'month' && isPartial(bucket, monthKey)) return 'so far';
|
||||||
|
if (trend.kind === 'year' && isPartial(bucket, yearKey)) return 'so far';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let trendOption = $derived((theme: ChartTheme) => {
|
||||||
|
const { kind, unit, scale, points } = trend;
|
||||||
|
const hourlyAxis = kind === 'hour';
|
||||||
|
const value = (raw: number | null | undefined) =>
|
||||||
|
raw === null || raw === undefined ? null : raw / scale;
|
||||||
|
const x = (bucket: Partial<Bucket>) => (hourlyAxis ? bucket.t! : trendLabel(bucket.t!));
|
||||||
|
const categories = hourlyAxis
|
||||||
|
? undefined
|
||||||
|
: points.map((bucket) => {
|
||||||
|
const note = partialNote(bucket);
|
||||||
|
return note ? `${trendLabel(bucket.t!)}*` : trendLabel(bucket.t!);
|
||||||
|
});
|
||||||
|
const series: Record<string, unknown>[] = SOURCES.map((source) => ({
|
||||||
|
name: source.label,
|
||||||
|
type: hourlyAxis ? 'line' : 'bar',
|
||||||
|
stack: 'mix',
|
||||||
|
areaStyle: hourlyAxis ? { opacity: 0.85 } : undefined,
|
||||||
|
showSymbol: false,
|
||||||
|
lineStyle: { width: 0 },
|
||||||
|
itemStyle: { color: source.color },
|
||||||
|
emphasis: { focus: 'series' },
|
||||||
|
data: points.map((bucket, index) =>
|
||||||
|
hourlyAxis
|
||||||
|
? [x(bucket), value(bucket.sources?.[source.type]?.value)]
|
||||||
|
: [categories![index], value(bucket.sources?.[source.type]?.value)]
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
series.push({
|
||||||
|
name: 'Consumption',
|
||||||
|
type: 'line',
|
||||||
|
showSymbol: !hourlyAxis,
|
||||||
|
symbolSize: 5,
|
||||||
|
lineStyle: { width: 2, type: 'dashed', color: theme.text },
|
||||||
|
itemStyle: { color: theme.text },
|
||||||
|
data: points.map((bucket, index) => [
|
||||||
|
hourlyAxis ? x(bucket) : categories![index],
|
||||||
|
value(bucket.load)
|
||||||
|
])
|
||||||
|
});
|
||||||
|
const showCo2 = kind !== 'year';
|
||||||
|
if (showCo2)
|
||||||
|
series.push({
|
||||||
|
name: 'CO₂ intensity',
|
||||||
|
type: 'line',
|
||||||
|
yAxisIndex: 1,
|
||||||
|
showSymbol: false,
|
||||||
|
lineStyle: { width: 1.5, color: '#ef4444' },
|
||||||
|
itemStyle: { color: '#ef4444' },
|
||||||
|
data: points.map((bucket, index) => [
|
||||||
|
hourlyAxis ? x(bucket) : categories![index],
|
||||||
|
bucket.ef === null || bucket.ef === undefined ? null : Math.round(bucket.ef * 1000)
|
||||||
|
])
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
animation: false,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
legend: legend(theme),
|
||||||
|
grid: { left: 64, right: showCo2 ? 64 : 20, top: 48, bottom: hourlyAxis ? 64 : 36 },
|
||||||
|
xAxis: hourlyAxis
|
||||||
|
? {
|
||||||
|
type: 'time',
|
||||||
|
...axis(theme),
|
||||||
|
splitLine: { show: false },
|
||||||
|
axisLabel: {
|
||||||
|
color: theme.muted,
|
||||||
|
hideOverlap: true,
|
||||||
|
formatter: (t: number) => formatTick.format(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: { type: 'category', data: categories, ...axis(theme), splitLine: { show: false } },
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: unit,
|
||||||
|
nameTextStyle: { color: theme.muted },
|
||||||
|
...axis(theme),
|
||||||
|
axisLabel: { color: theme.muted, formatter: (v: number) => whole.format(v) }
|
||||||
|
},
|
||||||
|
...(showCo2
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: 'g CO₂/kWh',
|
||||||
|
nameTextStyle: { color: theme.muted },
|
||||||
|
...axis(theme),
|
||||||
|
splitLine: { show: false },
|
||||||
|
min: 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
],
|
||||||
|
dataZoom: hourlyAxis
|
||||||
|
? [
|
||||||
|
{ type: 'inside' },
|
||||||
|
{
|
||||||
|
type: 'slider',
|
||||||
|
bottom: 10,
|
||||||
|
height: 18,
|
||||||
|
borderColor: theme.border,
|
||||||
|
textStyle: { color: theme.muted },
|
||||||
|
fillerColor: 'rgba(127,127,127,.15)'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
tooltip: tooltip(theme, {
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter: (
|
||||||
|
params: { seriesName: string; marker: string; value: [number | string, number | null] }[]
|
||||||
|
) => {
|
||||||
|
if (!params.length) return '';
|
||||||
|
const head = params[0].value[0];
|
||||||
|
const index = hourlyAxis ? -1 : categories!.indexOf(String(head));
|
||||||
|
const bucket = hourlyAxis ? points.find((point) => point.t === head) : points[index];
|
||||||
|
const title = hourlyAxis ? formatHour.format(Number(head)) : trendLabel(bucket!.t!);
|
||||||
|
const note = bucket ? partialNote(bucket) : '';
|
||||||
|
const lines = params
|
||||||
|
.filter((param) => param.value[1] !== null && param.value[1] !== undefined)
|
||||||
|
.map((param) => {
|
||||||
|
const suffix = param.seriesName === 'CO₂ intensity' ? ' g/kWh' : ` ${unit}`;
|
||||||
|
return `${param.marker}${escapeHtml(param.seriesName)}: <b>${one.format(param.value[1]!)}${suffix}</b>`;
|
||||||
|
});
|
||||||
|
if (!lines.length) return `${title}<br>no data for this hour`;
|
||||||
|
const total = bucket?.sum
|
||||||
|
? `<br>Generation: <b>${one.format(bucket.sum / scale)} ${unit}</b>`
|
||||||
|
: '';
|
||||||
|
return `${title}${note ? ` (${note})` : ''}${total}<br>${lines.join('<br>')}`;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
series
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let mixOption = $derived((theme: ChartTheme) => ({
|
||||||
|
animation: false,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
tooltip: tooltip(theme, {
|
||||||
|
trigger: 'item',
|
||||||
|
formatter: (param: { name: string; value: number; percent: number }) =>
|
||||||
|
`${escapeHtml(param.name)}<br><b>${whole.format(param.value)} MWh</b> · ${one.format(param.percent)}%`
|
||||||
|
}),
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['48%', '78%'],
|
||||||
|
itemStyle: { borderColor: theme.surface, borderWidth: 2 },
|
||||||
|
label: { color: theme.muted, formatter: '{b}\n{d}%' },
|
||||||
|
data: (now?.sources ?? [])
|
||||||
|
.filter((source) => source.value > 0)
|
||||||
|
.map((source) => ({
|
||||||
|
name: source.label,
|
||||||
|
value: Math.round(source.value),
|
||||||
|
itemStyle: { color: source.color }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
|
let utilizationOption = $derived((theme: ChartTheme) => {
|
||||||
|
const sources = [...(now?.sources ?? [])].reverse();
|
||||||
|
return {
|
||||||
|
animation: false,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
grid: { left: 96, right: 48, top: 8, bottom: 28 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'value',
|
||||||
|
max: 100,
|
||||||
|
...axis(theme),
|
||||||
|
axisLabel: { color: theme.muted, formatter: '{value}%' }
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: sources.map((source) => source.label),
|
||||||
|
...axis(theme),
|
||||||
|
splitLine: { show: false }
|
||||||
|
},
|
||||||
|
tooltip: tooltip(theme, {
|
||||||
|
trigger: 'item',
|
||||||
|
formatter: (param: { name: string; value: number }) =>
|
||||||
|
`${escapeHtml(param.name)}<br>running at <b>${one.format(param.value)}%</b> of capacity`
|
||||||
|
}),
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
barMaxWidth: 16,
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'right',
|
||||||
|
color: theme.muted,
|
||||||
|
formatter: (param: { value: number }) => `${whole.format(param.value)}%`
|
||||||
|
},
|
||||||
|
data: sources.map((source) => ({
|
||||||
|
value: source.utilization === null ? 0 : Math.round(source.utilization * 1000) / 10,
|
||||||
|
itemStyle: { color: source.color }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let forecastOption = $derived((theme: ChartTheme) => {
|
||||||
|
const weather = SOURCES.filter((source) =>
|
||||||
|
['WindOffshoreC', 'Wind', 'Solar'].includes(source.type)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
animation: false,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
legend: legend(theme),
|
||||||
|
grid: { left: 64, right: 20, top: 48, bottom: 32 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'time',
|
||||||
|
...axis(theme),
|
||||||
|
splitLine: { show: false },
|
||||||
|
axisLabel: {
|
||||||
|
color: theme.muted,
|
||||||
|
hideOverlap: true,
|
||||||
|
formatter: (t: number) => formatDay.format(t)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value', name: 'MWh', nameTextStyle: { color: theme.muted }, ...axis(theme) },
|
||||||
|
tooltip: tooltip(theme, {
|
||||||
|
trigger: 'axis',
|
||||||
|
valueFormatter: (v: number | null) => (v === null ? '–' : `${whole.format(v)} MWh`)
|
||||||
|
}),
|
||||||
|
series: [
|
||||||
|
...weather.map((source) => ({
|
||||||
|
name: source.label,
|
||||||
|
type: 'line',
|
||||||
|
stack: 'weather',
|
||||||
|
areaStyle: { opacity: 0.85 },
|
||||||
|
showSymbol: false,
|
||||||
|
lineStyle: { width: 0 },
|
||||||
|
itemStyle: { color: source.color },
|
||||||
|
data: forecast.map((bucket) => [bucket.t, bucket.sources[source.type]?.value ?? null])
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
name: 'Total generation',
|
||||||
|
type: 'line',
|
||||||
|
showSymbol: false,
|
||||||
|
lineStyle: { width: 1.5, color: theme.muted },
|
||||||
|
itemStyle: { color: theme.muted },
|
||||||
|
data: forecast.map((bucket) => [bucket.t, bucket.mix])
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Consumption',
|
||||||
|
type: 'line',
|
||||||
|
showSymbol: false,
|
||||||
|
lineStyle: { width: 2, type: 'dashed', color: theme.text },
|
||||||
|
itemStyle: { color: theme.text },
|
||||||
|
data: forecast.map((bucket) => [bucket.t, bucket.load])
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let seasonOption = $derived((theme: ChartTheme) => {
|
||||||
|
const years: Record<string, [number, number][]> = {};
|
||||||
|
for (const row of energy.gas)
|
||||||
|
(years[row.day.slice(0, 4)] ??= []).push([seasonalTime(row.day), row.fill]);
|
||||||
|
const list = Object.entries(years).filter(([, points]) => points.length > 30);
|
||||||
|
const current = list.at(-1)?.[0];
|
||||||
|
return {
|
||||||
|
animation: false,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
legend: legend(theme),
|
||||||
|
grid: { left: 48, right: 20, top: 48, bottom: 32 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'time',
|
||||||
|
min: Date.UTC(2000, 0, 1),
|
||||||
|
max: Date.UTC(2000, 11, 31),
|
||||||
|
...axis(theme),
|
||||||
|
splitLine: { show: false },
|
||||||
|
axisLabel: {
|
||||||
|
color: theme.muted,
|
||||||
|
formatter: (t: number) => formatSeason.format(t).split(' ')[1]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
...axis(theme),
|
||||||
|
axisLabel: { color: theme.muted, formatter: '{value}%' }
|
||||||
|
},
|
||||||
|
tooltip: tooltip(theme, {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: {
|
||||||
|
label: { formatter: ({ value }: { value: number }) => formatSeason.format(value) }
|
||||||
|
},
|
||||||
|
valueFormatter: (v: number) => `${one.format(v)}%`
|
||||||
|
}),
|
||||||
|
series: list.map(([year, points], index) => ({
|
||||||
|
name: year,
|
||||||
|
type: 'line',
|
||||||
|
showSymbol: false,
|
||||||
|
data: points,
|
||||||
|
lineStyle: { width: year === current ? 3 : 1.3, opacity: year === current ? 1 : 0.7 },
|
||||||
|
itemStyle: {
|
||||||
|
color:
|
||||||
|
year === current
|
||||||
|
? '#f97316'
|
||||||
|
: `hsl(${200 + index * 22} 55% ${theme.dark ? '68%' : '42%'})`
|
||||||
|
},
|
||||||
|
z: year === current ? 3 : 2
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let gasPowerOption = $derived((theme: ChartTheme) => {
|
||||||
|
const months = monthly.filter((bucket) => bucket.sum > 0);
|
||||||
|
// Fill level on the last reported day of each month.
|
||||||
|
const fillAt = (t: number) =>
|
||||||
|
energy.gas.findLast((row) => row.day.startsWith(monthKey(t)))?.fill ?? null;
|
||||||
|
const labels = months.map((bucket) =>
|
||||||
|
isPartial(bucket, monthKey)
|
||||||
|
? `${formatMonth.format(bucket.t)}*`
|
||||||
|
: formatMonth.format(bucket.t)
|
||||||
|
);
|
||||||
|
const share = (bucket: Bucket, type: 'FossilGasPower' | 'WKK Total') =>
|
||||||
|
Math.round(((bucket.sources[type]?.value ?? 0) / bucket.sum) * 1000) / 10;
|
||||||
|
return {
|
||||||
|
animation: false,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
legend: legend(theme),
|
||||||
|
grid: { left: 48, right: 20, top: 48, bottom: 32 },
|
||||||
|
xAxis: { type: 'category', data: labels, ...axis(theme), splitLine: { show: false } },
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
...axis(theme),
|
||||||
|
axisLabel: { color: theme.muted, formatter: '{value}%' }
|
||||||
|
},
|
||||||
|
tooltip: tooltip(theme, {
|
||||||
|
trigger: 'axis',
|
||||||
|
valueFormatter: (v: number | null) => (v === null ? '–' : `${one.format(v)}%`)
|
||||||
|
}),
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'Fossil gas power (share of generation)',
|
||||||
|
type: 'bar',
|
||||||
|
stack: 'gas',
|
||||||
|
itemStyle: { color: '#f97316' },
|
||||||
|
data: months.map((bucket) => share(bucket, 'FossilGasPower'))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CHP (WKK, mostly gas)',
|
||||||
|
type: 'bar',
|
||||||
|
stack: 'gas',
|
||||||
|
itemStyle: { color: '#fdba74' },
|
||||||
|
data: months.map((bucket) => share(bucket, 'WKK Total'))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Gas storage fill (month end)',
|
||||||
|
type: 'line',
|
||||||
|
symbolSize: 6,
|
||||||
|
lineStyle: { width: 2.5, color: '#0ea5e9' },
|
||||||
|
itemStyle: { color: '#0ea5e9' },
|
||||||
|
data: months.map((bucket) => fillAt(bucket.t))
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let sitesOption = $derived((theme: ChartTheme) => {
|
||||||
|
const sites = [...energy.gasSites].reverse();
|
||||||
|
return {
|
||||||
|
animation: false,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
grid: { left: 96, right: 96, top: 8, bottom: 28 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'value',
|
||||||
|
max: 100,
|
||||||
|
...axis(theme),
|
||||||
|
axisLabel: { color: theme.muted, formatter: '{value}%' }
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: sites.map((site) => site.site),
|
||||||
|
...axis(theme),
|
||||||
|
splitLine: { show: false }
|
||||||
|
},
|
||||||
|
tooltip: tooltip(theme, {
|
||||||
|
trigger: 'item',
|
||||||
|
formatter: (param: { dataIndex: number }) => {
|
||||||
|
const site = sites[param.dataIndex];
|
||||||
|
return `${escapeHtml(site.site)} · ${site.day}<br><b>${one.format(site.fill)}%</b> full · ${one.format(site.twh)} TWh`;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
barMaxWidth: 16,
|
||||||
|
itemStyle: { color: '#0ea5e9' },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'right',
|
||||||
|
color: theme.muted,
|
||||||
|
formatter: (param: { dataIndex: number }) =>
|
||||||
|
`${whole.format(sites[param.dataIndex].fill)}% · ${one.format(sites[param.dataIndex].twh)} TWh`
|
||||||
|
},
|
||||||
|
data: sites.map((site) => site.fill)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let missingHours = $derived(hourGaps.reduce((sum, gap) => sum + gap.hours, 0));
|
||||||
|
let clock = $state(Date.now());
|
||||||
|
$effect(() => {
|
||||||
|
const timer = setInterval(() => (clock = Date.now()), 60_000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Energy · Stan Runge</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Dutch electricity mix, utilization, trends and gas storage from NED.nl data, updated hourly."
|
||||||
|
/>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="energy-page">
|
||||||
|
<header class="intro">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">grid archive / netherlands</p>
|
||||||
|
<h1>Energy</h1>
|
||||||
|
<p class="lede">
|
||||||
|
Where Dutch electricity comes from hour by hour, how hard each source is running, and how
|
||||||
|
full the gas storages are. Data from NED.nl, collected hourly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="stats">
|
||||||
|
{#if now}
|
||||||
|
<div><strong>{percent.format(now.renewableShare)}</strong><span>renewable now</span></div>
|
||||||
|
{#if now.ef !== null}
|
||||||
|
<div><strong>{whole.format(now.ef * 1000)}</strong><span>g CO₂ / kWh</span></div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#if latestGas}
|
||||||
|
<div><strong>{one.format(latestGas.fill)}%</strong><span>gas storage full</span></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if !now}
|
||||||
|
<section class="panel empty">No electricity data has been collected yet.</section>
|
||||||
|
{:else}
|
||||||
|
<section class="grid two">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>Current mix</h2>
|
||||||
|
<span class="muted">{formatHour.format(now.t)} · {whole.format(now.total)} MWh</span>
|
||||||
|
</div>
|
||||||
|
<EnergyChart
|
||||||
|
option={mixOption}
|
||||||
|
label="Electricity generation mix for the latest hour"
|
||||||
|
height="320px"
|
||||||
|
/>
|
||||||
|
<dl class="facts">
|
||||||
|
<div>
|
||||||
|
<dt>Low-carbon</dt>
|
||||||
|
<dd>{percent.format(now.lowCarbonShare)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Fossil</dt>
|
||||||
|
<dd>{percent.format(now.fossilShare)}</dd>
|
||||||
|
</div>
|
||||||
|
{#if now.load !== null}
|
||||||
|
<div>
|
||||||
|
<dt>{now.load > now.total ? 'Net import' : 'Net export'}</dt>
|
||||||
|
<dd>{whole.format(Math.abs(now.total - now.load))} MWh</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>Utilization</h2>
|
||||||
|
<span class="muted">share of installed capacity in use</span>
|
||||||
|
</div>
|
||||||
|
<EnergyChart
|
||||||
|
option={utilizationOption}
|
||||||
|
label="Utilization of each electricity source for the latest hour"
|
||||||
|
height="320px"
|
||||||
|
/>
|
||||||
|
<p class="note">
|
||||||
|
NED's load factor per source. Nuclear and "other" are reported near 100% almost
|
||||||
|
constantly, so treat those as estimates.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>Generation and consumption</h2>
|
||||||
|
<Tabs.Root bind:value={range}>
|
||||||
|
<Tabs.List>
|
||||||
|
{#each Object.entries(rangeLabels) as [value, label] (value)}
|
||||||
|
<Tabs.Trigger {value}>{label}</Tabs.Trigger>
|
||||||
|
{/each}
|
||||||
|
</Tabs.List>
|
||||||
|
</Tabs.Root>
|
||||||
|
</div>
|
||||||
|
<EnergyChart
|
||||||
|
option={trendOption}
|
||||||
|
label={`Electricity generation by source, ${rangeLabels[range]}`}
|
||||||
|
/>
|
||||||
|
<p class="note">
|
||||||
|
Stacked: generation by source. Dashed: consumption (gap above it is export). Red: grid CO₂
|
||||||
|
intensity. * = incomplete (still running or hours missing).
|
||||||
|
{#if range === 'month' && daily.length < 28}
|
||||||
|
Hourly collection started {formatDate.format(energy.coverage.firstHour ?? now.t)}, so this
|
||||||
|
fills up over the coming weeks.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if forecast.length}
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>Next days</h2>
|
||||||
|
<span class="muted">NED forecast for wind, solar, generation and consumption</span>
|
||||||
|
</div>
|
||||||
|
<EnergyChart
|
||||||
|
option={forecastOption}
|
||||||
|
label="Forecast of wind and solar generation and consumption"
|
||||||
|
height="320px"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="grid two">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>Gas storage by year</h2>
|
||||||
|
{#if latestGas}
|
||||||
|
<span class="muted"
|
||||||
|
>{one.format(latestGas.twh)} TWh on {formatDate.format(new Date(latestGas.day))}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<EnergyChart
|
||||||
|
option={seasonOption}
|
||||||
|
label="Dutch gas storage fill level per year"
|
||||||
|
height="340px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>Gas vs electricity</h2>
|
||||||
|
<span class="muted">monthly</span>
|
||||||
|
</div>
|
||||||
|
<EnergyChart
|
||||||
|
option={gasPowerOption}
|
||||||
|
label="Share of electricity from gas compared with gas storage fill"
|
||||||
|
height="340px"
|
||||||
|
/>
|
||||||
|
<p class="note">
|
||||||
|
NED has no gas consumption series here, so this compares how much electricity is made from
|
||||||
|
gas with how full storage is.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if energy.gasSites.length}
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>Storage sites</h2>
|
||||||
|
<span class="muted">latest day per site</span>
|
||||||
|
</div>
|
||||||
|
<EnergyChart
|
||||||
|
option={sitesOption}
|
||||||
|
label="Fill level per Dutch gas storage site"
|
||||||
|
height="240px"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<footer class="coverage">
|
||||||
|
<p>
|
||||||
|
{#if energy.coverage.firstHour && energy.coverage.lastHour}
|
||||||
|
Hourly data {formatHour.format(energy.coverage.firstHour)} – {formatHour.format(
|
||||||
|
energy.coverage.lastHour
|
||||||
|
)} ({whole.format(energy.coverage.hours)} hours{missingHours
|
||||||
|
? `, ${missingHours} missing in the last week`
|
||||||
|
: ''}{incompleteHours.length
|
||||||
|
? `, ${incompleteHours.length} still incomplete and left out`
|
||||||
|
: ''}).
|
||||||
|
{/if}
|
||||||
|
{#if monthly.length}
|
||||||
|
Monthly from {formatMonth.format(monthly[0].t)}, yearly from {formatYear.format(
|
||||||
|
yearly[0]?.t ?? monthly[0].t
|
||||||
|
)}.
|
||||||
|
{/if}
|
||||||
|
{#if energy.gas.length}
|
||||||
|
Gas storage from {formatDate.format(new Date(energy.gas[0].day))}.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Source: <a href="https://ned.nl" rel="external noopener">NED.nl</a>.
|
||||||
|
{#if energy.collector.lastOk}
|
||||||
|
Last successful collection {relativeTime(energy.collector.lastOk, clock)}{energy.collector
|
||||||
|
.failedWeek
|
||||||
|
? ` · ${energy.collector.failedWeek} failed runs this week`
|
||||||
|
: ''}.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.energy-page {
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 3rem clamp(1rem, 4vw, 4rem) 5rem;
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
.intro {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: end;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--site-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
font-size: 0.67rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(2.8rem, 6vw, 5.2rem);
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.075em;
|
||||||
|
margin: 0.7rem 0 1rem;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
.lede {
|
||||||
|
color: var(--site-muted);
|
||||||
|
max-width: 40rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 2.5rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.stats div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
.stats strong {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.06em;
|
||||||
|
}
|
||||||
|
.stats span,
|
||||||
|
.facts dt {
|
||||||
|
color: var(--site-muted);
|
||||||
|
font-size: 0.69rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.grid.two {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.grid > .panel {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
background: var(--site-surface);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.panel-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.muted,
|
||||||
|
.note,
|
||||||
|
.coverage {
|
||||||
|
color: var(--site-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.facts {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.facts div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.facts dd {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.coverage {
|
||||||
|
margin-top: 2rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.coverage a {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.intro {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.stats div {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.grid.two {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -18,6 +18,7 @@ const staticPaths = [
|
|||||||
'/projects/game-tracker/osu',
|
'/projects/game-tracker/osu',
|
||||||
'/projects/game-tracker/pokemon/locations',
|
'/projects/game-tracker/pokemon/locations',
|
||||||
'/projects/animaldex',
|
'/projects/animaldex',
|
||||||
|
'/projects/energy',
|
||||||
'/projects/derby',
|
'/projects/derby',
|
||||||
'/projects/spotify-beatmap-finder',
|
'/projects/spotify-beatmap-finder',
|
||||||
'/projects/world-opinions',
|
'/projects/world-opinions',
|
||||||
|
|||||||
17
tests/energy.test.ts
Normal file
17
tests/energy.test.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test('energy tracker renders its charts and is listed under trackers', async ({ page }) => {
|
||||||
|
await page.goto('/projects/energy');
|
||||||
|
await expect(page.getByRole('heading', { level: 1, name: 'Energy' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Generation and consumption' })).toBeVisible();
|
||||||
|
await expect(page.locator('canvas').first()).toBeVisible();
|
||||||
|
for (const range of ['day', 'month', '12 months', 'all years']) {
|
||||||
|
await page.getByRole('tab', { name: range, exact: true }).click();
|
||||||
|
await expect(page.getByRole('tab', { name: range, exact: true })).toHaveAttribute(
|
||||||
|
'data-state',
|
||||||
|
'active'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await page.goto('/trackers');
|
||||||
|
await expect(page.locator('a[href="/projects/energy"]').first()).toBeVisible();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user