/** * Direct SMARD (smard.de) fetcher — official German grid data, more * accurate than Energy-Charts for production + load. Used by the * Strommix-Live-Widget aggregator. * * SMARD layout (per filter_id): * 1) `index_quarterhour.json` lists 7-day archive start-timestamps. * The last entry is the most recent archive. * 2) `_DE_quarterhour_.json` returns * `{ series: [[unix_ms, mwh_value | null], ...] }` — 672 slots * (7 days × 96 quarter-hours). Values are MWh per 15 min; * multiply by 4 to get average MW over that quarter. * * Filter IDs (DE region, MWh per 15 min): * 1223 Braunkohle 4070 Steinkohle * 4069 Erdgas 1224 Kernenergie (DE = 0 since 2023) * 4067 Wind Onshore 1225 Wind Offshore * 1226 Photovoltaik 1228 Wasserkraft (run-of-river + storage) * 4066 Biomasse 1227 Sonstige Konventionelle * 4071 Pumpspeicher 410 Netzlast (Stromverbrauch) */ const SMARD_BASE = "https://www.smard.de/app/chart_data"; export const SMARD_FILTERS = { // Conventional (label-relevant) lignite: 1223, // Braunkohle hardCoal: 4070, // Steinkohle gas: 4069, // Erdgas nuclear: 1224, // Kernenergie // Renewable windOnshore: 4067, windOffshore: 1225, solar: 1226, // Photovoltaik hydro: 1228, // Wasserkraft biomass: 4066, // Other otherConventional: 1227, // Sonstige Konventionelle (NOT in conv-summe) pumpedStorage: 4071, // Pumpspeicher (NOT in conv-summe — Speicher) // Demand load: 410, // Netzlast } as const; export type SmardFilter = keyof typeof SMARD_FILTERS; type SmardSlot = { tsMs: number; mwh: number }; type IndexResp = { timestamps?: number[] }; type SeriesResp = { series?: [number, number | null][] }; async function fetchLatestArchiveStart( filterId: number, fetcher: typeof fetch ): Promise { try { const res = await fetcher(`${SMARD_BASE}/${filterId}/DE/index_quarterhour.json`, { signal: AbortSignal.timeout(8_000), }); if (!res.ok) return null; const data = (await res.json()) as IndexResp; const list = data.timestamps ?? []; return list[list.length - 1] ?? null; } catch { return null; } } async function fetchArchive( filterId: number, archiveTs: number, fetcher: typeof fetch ): Promise<[number, number | null][] | null> { try { const res = await fetcher( `${SMARD_BASE}/${filterId}/DE/${filterId}_DE_quarterhour_${archiveTs}.json`, { signal: AbortSignal.timeout(8_000) } ); if (!res.ok) return null; const data = (await res.json()) as SeriesResp; return data.series ?? null; } catch { return null; } } /** * Pick the latest non-null slot from a series. */ function lastNonNull(series: [number, number | null][]): SmardSlot | null { for (let i = series.length - 1; i >= 0; i--) { const [tsMs, val] = series[i]; if (typeof val === "number" && Number.isFinite(val)) { return { tsMs, mwh: val }; } } return null; } /** * Fetch the latest non-null datapoint for one filter ID. * Returns null when the index or archive is unreachable. */ export async function fetchSmardLatest( filterId: number, fetcher: typeof fetch ): Promise { const archive = await fetchLatestArchiveStart(filterId, fetcher); if (archive == null) return null; const series = await fetchArchive(filterId, archive, fetcher); if (series == null) return null; return lastNonNull(series); } export type SmardSnapshot = { /** Timestamp common to all filters — the EARLIEST "latest" across the * set, so every value is from a slot that all filters have reported. * Unix seconds (not ms). */ measuredAtUnix: number; /** Average MW for each filter at the aligned slot. `null` means the * filter could not be resolved (404, all-null series, fetch error) * — distinguished from a genuine zero so callers can choose a * fallback source per-field instead of ignoring real zeroes * (e.g. solar at night, nuclear in DE). */ values: Record; }; /** * Fetch the entire snapshot in parallel + align timestamps. * * Strategy: pull each filter's `last_non_null`, then align to the * MINIMUM of the timestamps so we don't show value A from 21:00 next to * value B from 20:30. Returns the aligned timestamp + the value at that * exact slot for each filter. */ export async function fetchSmardSnapshot( fetcher: typeof fetch ): Promise { const filters = Object.entries(SMARD_FILTERS) as [SmardFilter, number][]; const results = await Promise.all( filters.map(async ([key, id]) => { // For 1224 (Nuclear) we expect 404 in DE — treat as 0/null gracefully. const slot = await fetchSmardLatest(id, fetcher); return { key, id, slot }; }) ); // Drop entirely-missing filters (1224 in DE returns 404 → null). const present = results.filter((r) => r.slot != null) as Array<{ key: SmardFilter; id: number; slot: SmardSlot; }>; if (present.length === 0) return null; // Aligned timestamp = the earliest of the latest stamps. Anything past // it would lack data for at least one filter; we'd rather show a // slightly-older fully-aligned snapshot than mismatched freshness. const alignedMs = Math.min(...present.map((r) => r.slot.tsMs)); // For each filter, fetch the archive once more to read the value at // the aligned timestamp (not just the latest). For most filters // they'll match. // Optimisation: re-walk the archives we already have if we cached // them; here we re-fetch since each slot lookup needs the full series. const aligned = await Promise.all( present.map(async ({ key, id, slot }) => { if (slot.tsMs === alignedMs) { return [key, slot.mwh] as const; } // Need value at alignedMs — re-fetch archive (cached upstream). const archive = await fetchLatestArchiveStart(id, fetcher); if (archive == null) return [key, null] as const; const series = await fetchArchive(id, archive, fetcher); if (series == null) return [key, null] as const; const found = series.find(([t]) => t === alignedMs); const v = found?.[1]; return [key, typeof v === "number" && Number.isFinite(v) ? v : null] as const; }) ); const values = {} as Record; for (const key of Object.keys(SMARD_FILTERS) as SmardFilter[]) { values[key] = null; } for (const [key, mwh] of aligned) { if (mwh != null) { // 15-min energy (MWh) → average power over that slot (MW). values[key] = mwh * 4; } } return { measuredAtUnix: Math.floor(alignedMs / 1000), values, }; }