fix(strommix): SMARD as primary source — accuracy parity with audit
Deploy / verify (push) Successful in 45s
Deploy / deploy (push) Successful in 49s

Audit at 22:15 MESZ flagged the conventional sum (lignite + hard coal
+ gas + nuclear) as 19.1 GW in the widget vs 15.65 GW in reality. The
breakdown showed the gap was Energy-Charts' `Fossil gas` (6.9 GW)
which bundles natural gas + GuD + similar, while SMARD's `Erdgas`
filter 4069 (3.8 GW) tracks just natural gas the way the widget label
implies. SMARD is the canonical German grid feed; switching the
primary source resolves the discrepancy directly.

- New `lib/smard.ts` fetches the 11 relevant filter IDs in parallel:
  conventional (1223/4070/4069/1224), wind (4067/1225), solar (1226),
  hydro (1228), biomass (4066), other-conventional + pumped storage
  (1227/4071, surfaced separately), load (410). Two-step protocol
  (index.json → archive.json) handled inside; output is MW (MWh × 4)
  aligned to one common slot timestamp so every value on screen comes
  from the same point in time.
- Aggregator route uses SMARD for production + load, Energy-Charts
  only for cross-border saldo + day-ahead prices (where SMARD has no
  convenient single-call endpoint). `dataSource: "smard" |
  "energy-charts"` flag travels in the response so the widget can
  attribute the numbers and fall back gracefully when SMARD is down.
- StrommixBlock gets a "Werte prüfen" disclosure that lists every
  component (wind onshore + offshore, solar, the four conventional
  buckets, biomass, hydro, load) with its MW value and the slot
  timestamp + data source. Fact-check users can verify the addition
  themselves now without asking.

Verified live: SMARD @ 21:00 UTC = lignite 8484 + hard coal 3480 +
gas 3811 + nuclear 0 = 15.78 GW conventional. Matches the user's
ground-truth 15.65 GW within sampling noise.
This commit is contained in:
Peter Meier
2026-05-04 23:47:52 +02:00
parent f4e4abd978
commit 96cd9e9c57
5 changed files with 290 additions and 62 deletions
+59 -62
View File
@@ -1,22 +1,30 @@
/**
* Aggregator route for the Strommix-Live-Widget.
*
* Bundles four external collections from the CMS (strommix_de,
* crossborder_de, price_de, price_fr) into one JSON payload with the
* derived numbers the widget actually needs (renewable share, fossil
* sum, cross-border saldo). Re-aggregating in the frontend would mean
* four round-trips per visitor — this route does it once with a 5min
* SvelteKit cache header on top of the CMS-side TTL cache, so the
* upstream energy-charts API gets at most one fetch per 5min per node.
* 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.
*
* Sign convention for cross-border flow: positive = net export from DE,
* negative = net import into DE (verified against live response, see
* docs/external-collections.md notes).
* 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, type SmardSnapshot } from "$lib/smard";
const CMS_BASE = (
env.PUBLIC_CMS_URL ||
@@ -24,20 +32,6 @@ 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;
@@ -62,10 +56,9 @@ async function fetchOne<T>(
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) }
);
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;
@@ -75,19 +68,16 @@ async function fetchOne<T>(
}
}
/**
* Day-ahead prices come as a 96-slot array (15-min intervals over 24 h)
* and the CMS' `last_non_null` transform always picks the LAST known
* slot — typically a slot in the future, not the one covering "now".
* Bypass the CMS for prices and pick the slot whose start time is the
* largest one ≤ now. Returns null when the upstream lacks a usable
* value.
*/
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
@@ -123,23 +113,30 @@ async function fetchPriceAtNow(
}
export const GET: RequestHandler = async ({ fetch }) => {
const [strommix, cross, priceDe, priceFr] = await Promise.all([
fetchOne<StrommixRaw>("strommix_de", fetch),
const [smard, cross, priceDe, priceFr] = await Promise.all([
fetchSmardSnapshot(fetch),
fetchOne<CrossborderRaw>("crossborder_de", fetch),
// Prices fetched directly so we can index by "now" instead of
// taking the last (potentially future) slot the CMS' last_non_null
// transform would pick.
fetchPriceAtNow("DE-LU", fetch),
fetchPriceAtNow("FR", fetch),
]);
const s = strommix.data ?? {};
// 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,
};
// 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).
const conventionalMw = v.lignite + v.hardCoal + v.gas + v.nuclear;
const weatherRenewableMw = v.windOnshore + v.windOffshore + v.solar;
const c = cross.data ?? {};
const weatherRenewableMw = num(s.wind_onshore_mw) + num(s.wind_offshore_mw) + num(s.solar_mw);
const conventionalMw =
num(s.lignite_mw) + num(s.hard_coal_mw) + num(s.gas_mw) + num(s.nuclear_mw);
const flowsByCountry: Record<string, number> = {
AT: num(c.flow_at_gw),
BE: num(c.flow_be_gw),
@@ -156,17 +153,18 @@ export const GET: RequestHandler = async ({ fetch }) => {
const netFlowGw = Object.values(flowsByCountry).reduce((a, b) => a + b, 0);
const body: StrommixResponse = {
measuredAtUnix: s.measured_at_unix ?? null,
windOnshoreMw: num(s.wind_onshore_mw),
windOffshoreMw: num(s.wind_offshore_mw),
solarMw: num(s.solar_mw),
biomassMw: num(s.biomass_mw),
hydroMw: num(s.hydro_mw),
ligniteMw: num(s.lignite_mw),
hardCoalMw: num(s.hard_coal_mw),
gasMw: num(s.gas_mw),
nuclearMw: num(s.nuclear_mw),
loadMw: num(s.load_mw),
measuredAtUnix: smard?.measuredAtUnix ?? null,
dataSource: smard ? "smard" : "energy-charts",
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,
@@ -175,7 +173,7 @@ export const GET: RequestHandler = async ({ fetch }) => {
priceDeEurMwh: priceDe.price,
priceFrEurMwh: priceFr.price,
priceMeasuredAtUnix: priceDe.slotUnix,
staleStrommix: strommix.stale,
staleStrommix: smard == null,
staleCrossborder: cross.stale,
stalePrice: priceDe.stale || priceFr.stale,
};
@@ -183,9 +181,8 @@ export const GET: RequestHandler = async ({ fetch }) => {
return new Response(JSON.stringify(body), {
headers: {
"content-type": "application/json",
// 5min: matches the CMS-side TTL on strommix + crossborder.
// Browsers + CDNs (if any) hold the response for that window;
// SvelteKit re-fetches afterwards.
// 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",
},
});