/** * 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, type SmardSnapshot } from "$lib/smard"; const CMS_BASE = ( env.PUBLIC_CMS_URL || import.meta.env.PUBLIC_CMS_URL || "http://localhost:3000" ).replace(/\/$/, ""); 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, cross, priceDe, priceFr] = await Promise.all([ fetchSmardSnapshot(fetch), fetchOne("crossborder_de", fetch), fetchPriceAtNow("DE-LU", fetch), fetchPriceAtNow("FR", fetch), ]); // Production breakdown — SMARD as primary. Each filter is in MW // already (SMARD MWh × 4). const v: SmardSnapshot["values"] = smard?.values ?? { lignite: 0, hardCoal: 0, gas: 0, nuclear: 0, windOnshore: 0, windOffshore: 0, solar: 0, hydro: 0, biomass: 0, otherConventional: 0, pumpedStorage: 0, load: 0, }; // Conventional, the way the widget label spells it: lignite + hard // coal + gas + nuclear. Pumped-storage + biomass + run-of-river // hydro are NOT included (different categories or already-stored // energy that would double-count). 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 ?? null, dataSource: smard ? "smard" : "energy-charts", 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, 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", }, }); };