fix(strommix): per-field fallback to Energy-Charts when SMARD value missing
Deploy / verify (push) Successful in 46s
Deploy / deploy (push) Successful in 48s

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.
This commit is contained in:
Peter Meier
2026-05-04 23:55:57 +02:00
parent 96cd9e9c57
commit 2d85681252
4 changed files with 95 additions and 35 deletions
+55 -14
View File
@@ -24,7 +24,7 @@
import type { RequestHandler } from "./$types";
import { env } from "$env/dynamic/public";
import type { StrommixResponse } from "$lib/strommix";
import { fetchSmardSnapshot, type SmardSnapshot } from "$lib/smard";
import { fetchSmardSnapshot } from "$lib/smard";
const CMS_BASE = (
env.PUBLIC_CMS_URL ||
@@ -32,6 +32,20 @@ const CMS_BASE = (
"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;
@@ -113,26 +127,53 @@ async function fetchPriceAtNow(
}
export const GET: RequestHandler = async ({ fetch }) => {
const [smard, cross, priceDe, priceFr] = await Promise.all([
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),
]);
// 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,
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 (different categories or already-stored
// energy that would double-count).
// hydro are NOT included.
const conventionalMw = v.lignite + v.hardCoal + v.gas + v.nuclear;
const weatherRenewableMw = v.windOnshore + v.windOffshore + v.solar;
@@ -153,8 +194,8 @@ export const GET: RequestHandler = async ({ fetch }) => {
const netFlowGw = Object.values(flowsByCountry).reduce((a, b) => a + b, 0);
const body: StrommixResponse = {
measuredAtUnix: smard?.measuredAtUnix ?? null,
dataSource: smard ? "smard" : "energy-charts",
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,
@@ -173,7 +214,7 @@ export const GET: RequestHandler = async ({ fetch }) => {
priceDeEurMwh: priceDe.price,
priceFrEurMwh: priceFr.price,
priceMeasuredAtUnix: priceDe.slotUnix,
staleStrommix: smard == null,
staleStrommix: smard == null && !energyCharts.data,
staleCrossborder: cross.stale,
stalePrice: priceDe.stale || priceFr.stale,
};