2d85681252
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.
231 lines
7.8 KiB
TypeScript
231 lines
7.8 KiB
TypeScript
/**
|
||
* 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<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",
|
||
},
|
||
});
|
||
};
|