Files
windwiderstand/src/routes/api/strommix/+server.ts
T
Peter Meier 2d85681252
Deploy / verify (push) Successful in 46s
Deploy / deploy (push) Successful in 48s
fix(strommix): per-field fallback to Energy-Charts when SMARD value missing
The widget rendered "Keine gültigen Live-Werte" the moment SMARD's
load filter (id 410) lagged behind the production filters by even one
slot — the aggregator coalesced "filter unavailable" with "filter
returned 0" and the percent calculation refused to divide by zero.

Three changes:

1. SmardSnapshot.values now uses `number | null` so callers can tell
   "filter genuinely missing" apart from "filter reported zero" (e.g.
   solar at night, decommissioned nuclear).
2. Aggregator pulls Energy-Charts (CMS strommix_de) in parallel and
   uses it as a per-field fallback for any SMARD null. New
   `dataSource: "smard+energy-charts"` flag travels in the response so
   the attribution stays honest. Pure SMARD when everything resolves;
   pure Energy-Charts when SMARD is entirely down; the hybrid label
   for the common case where only load drops out.
3. Widget renders the production breakdown + context row + price row
   even when the percentage is unavailable, with a clear "Lastwert
   von SMARD gerade nicht verfügbar" hint instead of going blank.
2026-05-04 23:55:57 +02:00

231 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 23 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<T>(
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<StrommixRaw>("strommix_de", fetch),
fetchOne<CrossborderRaw>("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<string, number> = {
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",
},
});
};