/** * Aggregator route for the Strommix-Live-Widget. * * Production + load come from **SMARD** (smard.de) — the canonical * German grid feed. Cross-border saldo + day-ahead prices come from * Energy-Charts (api.energy-charts.info) where SMARD has no convenient * single-call endpoint. * * Why split sources: * - SMARD's `Erdgas` (filter 4069) is just natural gas. Energy-Charts' * `Fossil gas` bundles natural gas + GuD + similar — values diverge * by 2–3 GW. The audit user pointed at SMARD as ground truth. * - SMARD has 15-min slot data with explicit timestamps; we align all * filters to the same slot so every number on screen comes from one * point in time. * - Energy-Charts wins for cross-border + prices since SMARD's * flow + price endpoints need extra plumbing we don't need here. * * Every value on the response carries its measurement timestamp so the * widget can render "Stand HH:MM" + "verzögert" hints when an upstream * lags. */ import type { RequestHandler } from "./$types"; import { env } from "$env/dynamic/public"; import type { StrommixResponse } from "$lib/strommix"; import { fetchSmardSnapshot } from "$lib/smard"; const CMS_BASE = ( env.PUBLIC_CMS_URL || import.meta.env.PUBLIC_CMS_URL || "http://localhost:3000" ).replace(/\/$/, ""); type StrommixRaw = { wind_onshore_mw?: number | null; wind_offshore_mw?: number | null; solar_mw?: number | null; biomass_mw?: number | null; hydro_mw?: number | null; lignite_mw?: number | null; hard_coal_mw?: number | null; gas_mw?: number | null; nuclear_mw?: number | null; load_mw?: number | null; measured_at_unix?: number | null; }; type CrossborderRaw = { flow_at_gw?: number | null; flow_be_gw?: number | null; flow_ch_gw?: number | null; flow_cz_gw?: number | null; flow_dk_gw?: number | null; flow_fr_gw?: number | null; flow_lu_gw?: number | null; flow_nl_gw?: number | null; flow_no_gw?: number | null; flow_pl_gw?: number | null; flow_se_gw?: number | null; measured_at_unix?: number | null; }; function num(x: unknown): number { return typeof x === "number" && Number.isFinite(x) ? x : 0; } async function fetchOne( collection: string, fetcher: typeof fetch ): Promise<{ data: T | null; stale: boolean }> { try { const res = await fetcher(`${CMS_BASE}/api/content/${collection}/current`, { signal: AbortSignal.timeout(8_000), }); if (!res.ok) return { data: null, stale: false }; const stale = res.headers.get("x-rustycms-external-stale") === "1"; const data = (await res.json()) as T; return { data, stale }; } catch { return { data: null, stale: false }; } } type PriceArrayResp = { unix_seconds?: number[]; price?: (number | null)[]; }; /** * Pick the day-ahead price slot whose start ≤ now. Energy-Charts * returns 96 slots covering the day; the last cell is typically a * future slot. */ async function fetchPriceAtNow( bzn: string, fetcher: typeof fetch ): Promise<{ price: number | null; slotUnix: number | null; stale: boolean }> { try { const res = await fetcher( `https://api.energy-charts.info/price?bzn=${encodeURIComponent(bzn)}`, { signal: AbortSignal.timeout(8_000) } ); if (!res.ok) return { price: null, slotUnix: null, stale: false }; const data = (await res.json()) as PriceArrayResp; const times = data.unix_seconds ?? []; const prices = data.price ?? []; if (times.length === 0 || prices.length === 0) { return { price: null, slotUnix: null, stale: false }; } const nowSec = Math.floor(Date.now() / 1000); let pickIdx = -1; for (let i = 0; i < times.length; i++) { if (times[i] <= nowSec) pickIdx = i; else break; } if (pickIdx < 0) return { price: null, slotUnix: null, stale: false }; const value = prices[pickIdx]; return { price: typeof value === "number" && Number.isFinite(value) ? value : null, slotUnix: times[pickIdx] ?? null, stale: false, }; } catch { return { price: null, slotUnix: null, stale: true }; } } export const GET: RequestHandler = async ({ fetch }) => { const [smard, energyCharts, cross, priceDe, priceFr] = await Promise.all([ fetchSmardSnapshot(fetch), // Energy-Charts via the CMS — used as per-field fallback when SMARD // returns null for a specific filter (notably load 410 when SMARD's // pipeline lags more than the production filters). fetchOne("strommix_de", fetch), fetchOne("crossborder_de", fetch), fetchPriceAtNow("DE-LU", fetch), fetchPriceAtNow("FR", fetch), ]); const ec = energyCharts.data ?? {}; // Per-field fallback. SMARD value `null` = filter unavailable; we // reach for the Energy-Charts equivalent. SMARD `0` is preserved // (genuine night-time solar / decommissioned nuclear / etc). const pick = (smardVal: number | null | undefined, ecVal: number | null | undefined): number => typeof smardVal === "number" ? smardVal : typeof ecVal === "number" && Number.isFinite(ecVal) ? ecVal : 0; const v = { lignite: pick(smard?.values.lignite, ec.lignite_mw), hardCoal: pick(smard?.values.hardCoal, ec.hard_coal_mw), gas: pick(smard?.values.gas, ec.gas_mw), nuclear: pick(smard?.values.nuclear, ec.nuclear_mw), windOnshore: pick(smard?.values.windOnshore, ec.wind_onshore_mw), windOffshore: pick(smard?.values.windOffshore, ec.wind_offshore_mw), solar: pick(smard?.values.solar, ec.solar_mw), hydro: pick(smard?.values.hydro, ec.hydro_mw), biomass: pick(smard?.values.biomass, ec.biomass_mw), load: pick(smard?.values.load, ec.load_mw), }; // Track whether any field needed the Energy-Charts fallback so the // widget can attribute the source honestly. const usedFallback = !smard || (Object.keys(v) as (keyof typeof v)[]).some( (k) => smard.values[k as keyof typeof smard.values] == null && (v[k] ?? 0) > 0 ); // Conventional, the way the widget label spells it: lignite + hard // coal + gas + nuclear. Pumped-storage + biomass + run-of-river // hydro are NOT included. const conventionalMw = v.lignite + v.hardCoal + v.gas + v.nuclear; const weatherRenewableMw = v.windOnshore + v.windOffshore + v.solar; const c = cross.data ?? {}; const flowsByCountry: Record = { AT: num(c.flow_at_gw), BE: num(c.flow_be_gw), CH: num(c.flow_ch_gw), CZ: num(c.flow_cz_gw), DK: num(c.flow_dk_gw), FR: num(c.flow_fr_gw), LU: num(c.flow_lu_gw), NL: num(c.flow_nl_gw), NO: num(c.flow_no_gw), PL: num(c.flow_pl_gw), SE: num(c.flow_se_gw), }; const netFlowGw = Object.values(flowsByCountry).reduce((a, b) => a + b, 0); const body: StrommixResponse = { measuredAtUnix: smard?.measuredAtUnix ?? ec.measured_at_unix ?? null, dataSource: !smard ? "energy-charts" : usedFallback ? "smard+energy-charts" : "smard", windOnshoreMw: v.windOnshore, windOffshoreMw: v.windOffshore, solarMw: v.solar, biomassMw: v.biomass, hydroMw: v.hydro, ligniteMw: v.lignite, hardCoalMw: v.hardCoal, gasMw: v.gas, nuclearMw: v.nuclear, loadMw: v.load, weatherRenewableMw, conventionalMw, netFlowGw, crossborderMeasuredAtUnix: c.measured_at_unix ?? null, flowsByCountry, priceDeEurMwh: priceDe.price, priceFrEurMwh: priceFr.price, priceMeasuredAtUnix: priceDe.slotUnix, staleStrommix: smard == null && !energyCharts.data, staleCrossborder: cross.stale, stalePrice: priceDe.stale || priceFr.stale, }; return new Response(JSON.stringify(body), { headers: { "content-type": "application/json", // 5 min ≈ SMARD update cadence. Browser/CDN hold the response that // long; SvelteKit re-fetches afterwards. "cache-control": "public, max-age=300, s-maxage=300", }, }); };