From 080e69dcac1c896140be2a6d4564b0f5de5144d2 Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Tue, 5 May 2026 00:09:25 +0200 Subject: [PATCH] =?UTF-8?q?fix(strommix):=20bypass=20CMS=20for=20Energy-Ch?= =?UTF-8?q?arts=20production=20=E2=80=94=20upstream=20now=20needs=20explic?= =?UTF-8?q?it=20start/end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Energy-Charts' /public_power endpoint started rejecting requests without `start` + `end` query params ("HTTP 404: no content available"). The CMS-cached `strommix_de` external schema cannot pass dynamic time windows (external-collections v1 has no runtime-vars), so the per-field fallback chain was effectively dead — when SMARD's load filter lagged 12+ h, both sources returned null and the widget surfaced "Lastwert nicht verfügbar" with all zeros. Aggregator now fetches /public_power directly with a 2-hour window, picks the latest non-null per series, and feeds those values into the existing per-field fallback. CMS strommix_de schema becomes obsolete for the widget but stays available for admin debugging. Same shape as the previous CMS payload, so the fallback merge is unchanged. Picks `Wind onshore`, `Wind offshore`, `Solar`, `Biomass`, `Hydro Run-of-River`, `Fossil brown coal / lignite`, `Fossil hard coal`, `Fossil gas`, `Nuclear`, `Load` from production_types. --- src/routes/api/strommix/+server.ts | 69 ++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/src/routes/api/strommix/+server.ts b/src/routes/api/strommix/+server.ts index 661bbaf..3063a94 100644 --- a/src/routes/api/strommix/+server.ts +++ b/src/routes/api/strommix/+server.ts @@ -46,6 +46,66 @@ type StrommixRaw = { measured_at_unix?: number | null; }; +/** + * Fetch Energy-Charts /public_power directly with an explicit time + * window. The CMS-cached `strommix_de` schema cannot do this because + * external-collections v1 has no runtime-vars; without start/end the + * upstream returns 404 ("no content available"). 2-hour window covers + * latest 8 quarter-hour slots; we pick the latest non-null per series. + */ +type PublicPowerSeries = { name: string; data: (number | null)[] }; +type PublicPowerResp = { + unix_seconds?: number[]; + production_types?: PublicPowerSeries[]; +}; + +function lastNonNull(arr: (number | null)[]): number | null { + for (let i = arr.length - 1; i >= 0; i--) { + const v = arr[i]; + if (typeof v === "number" && Number.isFinite(v)) return v; + } + return null; +} + +async function fetchEnergyChartsPublicPower( + fetcher: typeof fetch +): Promise<{ data: StrommixRaw | null; stale: boolean }> { + try { + const nowSec = Math.floor(Date.now() / 1000); + const fmt = (sec: number) => + new Date(sec * 1000).toISOString().slice(0, 16) + "Z"; + const start = fmt(nowSec - 2 * 60 * 60); + const end = fmt(nowSec); + const url = + `https://api.energy-charts.info/public_power?country=de` + + `&start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`; + const res = await fetcher(url, { signal: AbortSignal.timeout(8_000) }); + if (!res.ok) return { data: null, stale: false }; + const d = (await res.json()) as PublicPowerResp; + const find = (n: string) => + d.production_types?.find((p) => p.name === n)?.data ?? []; + const ts = d.unix_seconds ?? []; + return { + data: { + wind_onshore_mw: lastNonNull(find("Wind onshore")), + wind_offshore_mw: lastNonNull(find("Wind offshore")), + solar_mw: lastNonNull(find("Solar")), + biomass_mw: lastNonNull(find("Biomass")), + hydro_mw: lastNonNull(find("Hydro Run-of-River")), + lignite_mw: lastNonNull(find("Fossil brown coal / lignite")), + hard_coal_mw: lastNonNull(find("Fossil hard coal")), + gas_mw: lastNonNull(find("Fossil gas")), + nuclear_mw: lastNonNull(find("Nuclear")), + load_mw: lastNonNull(find("Load")), + measured_at_unix: ts[ts.length - 1] ?? null, + }, + stale: false, + }; + } catch { + return { data: null, stale: true }; + } +} + type CrossborderRaw = { flow_at_gw?: number | null; flow_be_gw?: number | null; @@ -129,10 +189,11 @@ async function fetchPriceAtNow( export const GET: RequestHandler = async ({ fetch }) => { const [smardRaw, 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), + // Energy-Charts /public_power direct (with explicit time range — + // upstream now returns 404 without start/end). Used as per-field + // fallback when SMARD's load filter (id 410) lags 12+ hours behind + // the production filters and the SMARD snapshot gets discarded. + fetchEnergyChartsPublicPower(fetch), fetchOne("crossborder_de", fetch), fetchPriceAtNow("DE-LU", fetch), fetchPriceAtNow("FR", fetch),