All checks were successful
Deploy production / deploy (push) Successful in 1m18s
747 lines
22 KiB
Svelte
747 lines
22 KiB
Svelte
<script lang="ts">
|
||
import * as Tabs from '#lib/components/ui/tabs/index.js';
|
||
import 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 StatChart from '#lib/stats/StatChart.svelte';
|
||
import Kpi from '#lib/stats/Kpi.svelte';
|
||
import type { EnergyData } from '#lib/server/energy.js';
|
||
|
||
// The live NED.nl grid and AGSI+ gas storage section of /stats/energy (formerly the
|
||
// /projects/energy tracker). Reads the ned-collector's `energy` schema, never writes.
|
||
let { energy, attributions }: { energy: EnergyData; attributions: Record<string, string> } =
|
||
$props();
|
||
const attr = $derived({
|
||
...attributions,
|
||
ned: 'NED.nl (Nationaal Energie Dashboard), hourly',
|
||
agsi: 'GIE AGSI+ gas storage'
|
||
});
|
||
|
||
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>
|
||
|
||
<h2 class="stats-section-title" id="live">Live grid and gas storage</h2>
|
||
<p class="stats-note">
|
||
Where Dutch electricity comes from hour by hour, how hard each source is running, and how full the
|
||
gas storages are. Collected hourly from NED.nl; the yearly CBS figures further down remain the
|
||
official totals.
|
||
</p>
|
||
<div class="kpis">
|
||
{#if now}
|
||
<Kpi
|
||
label="Renewable now"
|
||
value={percent.format(now.renewableShare)}
|
||
sub={formatHour.format(now.t)}
|
||
color="#10b981"
|
||
/>
|
||
{#if now.ef !== null}
|
||
<Kpi
|
||
label="Grid CO₂ intensity"
|
||
value={whole.format(now.ef * 1000)}
|
||
unit="g/kWh"
|
||
sub="latest hour"
|
||
color="#64748b"
|
||
/>
|
||
{/if}
|
||
<Kpi
|
||
label="Low-carbon now"
|
||
value={percent.format(now.lowCarbonShare)}
|
||
sub={`fossil ${percent.format(now.fossilShare)}`}
|
||
color="#06b6d4"
|
||
/>
|
||
{/if}
|
||
{#if latestGas}
|
||
<Kpi
|
||
label="Gas storage"
|
||
value={one.format(latestGas.fill)}
|
||
unit="% full"
|
||
sub={`${one.format(latestGas.twh)} TWh · ${formatDate.format(new Date(latestGas.day))}`}
|
||
color="#3b82f6"
|
||
/>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if !now}
|
||
<p class="empty">No electricity data has been collected yet.</p>
|
||
{:else}
|
||
<div class="stats-grid">
|
||
<StatChart
|
||
title="Current mix"
|
||
subtitle={`${formatHour.format(now.t)} · ${whole.format(now.total)} MWh`}
|
||
option={mixOption}
|
||
sources={['ned']}
|
||
attributions={attr}
|
||
>
|
||
{#if now.load !== null}
|
||
<p class="facts">
|
||
{now.load > now.total ? 'Net import' : 'Net export'}
|
||
<strong>{whole.format(Math.abs(now.total - now.load))} MWh</strong>
|
||
</p>
|
||
{/if}
|
||
</StatChart>
|
||
<StatChart
|
||
title="Utilization"
|
||
subtitle="share of installed capacity in use"
|
||
option={utilizationOption}
|
||
sources={['ned']}
|
||
attributions={attr}
|
||
note="NED's load factor per source. Nuclear and "other" are reported near 100% almost constantly, so treat those as estimates."
|
||
/>
|
||
<StatChart
|
||
title="Generation and consumption"
|
||
subtitle={rangeLabels[range]}
|
||
option={trendOption}
|
||
sources={['ned']}
|
||
attributions={attr}
|
||
height="380px"
|
||
wide
|
||
note={`Stacked: generation by source. Dashed: consumption (gap above it is export). Red: grid CO₂ intensity. * = incomplete (still running or hours missing).${range === 'month' && daily.length < 28 ? ` Hourly collection started ${formatDate.format(energy.coverage.firstHour ?? now.t)}, so this fills up over the coming weeks.` : ''}`}
|
||
>
|
||
<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>
|
||
</StatChart>
|
||
{#if forecast.length}
|
||
<StatChart
|
||
title="Next days"
|
||
subtitle="NED forecast for wind, solar, generation and consumption"
|
||
option={forecastOption}
|
||
sources={['ned']}
|
||
attributions={attr}
|
||
wide
|
||
/>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="stats-grid">
|
||
<StatChart
|
||
title="Gas storage by year"
|
||
subtitle={latestGas
|
||
? `${one.format(latestGas.twh)} TWh on ${formatDate.format(new Date(latestGas.day))}`
|
||
: ''}
|
||
option={seasonOption}
|
||
sources={['agsi']}
|
||
attributions={attr}
|
||
height="340px"
|
||
/>
|
||
<StatChart
|
||
title="Gas vs electricity"
|
||
subtitle="monthly"
|
||
option={gasPowerOption}
|
||
sources={['ned', 'agsi']}
|
||
attributions={attr}
|
||
height="340px"
|
||
note="NED has no gas consumption series here, so this compares how much electricity is made from gas with how full storage is."
|
||
/>
|
||
{#if energy.gasSites.length}
|
||
<StatChart
|
||
title="Storage sites"
|
||
subtitle="latest day per site"
|
||
option={sitesOption}
|
||
sources={['agsi']}
|
||
attributions={attr}
|
||
height="240px"
|
||
wide
|
||
/>
|
||
{/if}
|
||
</div>
|
||
|
||
<p class="stats-note">
|
||
{#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}
|
||
{#if energy.collector.lastOk}
|
||
Last successful collection {relativeTime(new Date(energy.collector.lastOk), clock)}{energy
|
||
.collector.failedWeek
|
||
? ` · ${energy.collector.failedWeek} failed runs this week`
|
||
: ''}.
|
||
{/if}
|
||
</p>
|
||
|
||
<style>
|
||
.facts {
|
||
color: var(--site-muted);
|
||
font-size: 0.8rem;
|
||
}
|
||
.facts strong {
|
||
color: var(--site-text);
|
||
font-weight: 600;
|
||
}
|
||
#live {
|
||
scroll-margin-top: 5rem;
|
||
}
|
||
</style>
|