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:
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>>;
|
||||
Reference in New Issue
Block a user