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
+191
View File
@@ -0,0 +1,191 @@
/**
* Direct SMARD (smard.de) fetcher — official German grid data, more
* accurate than Energy-Charts for production + load. Used by the
* Strommix-Live-Widget aggregator.
*
* SMARD layout (per filter_id):
* 1) `index_quarterhour.json` lists 7-day archive start-timestamps.
* The last entry is the most recent archive.
* 2) `<filter_id>_DE_quarterhour_<archive_ts>.json` returns
* `{ series: [[unix_ms, mwh_value | null], ...] }` — 672 slots
* (7 days × 96 quarter-hours). Values are MWh per 15 min;
* multiply by 4 to get average MW over that quarter.
*
* Filter IDs (DE region, MWh per 15 min):
* 1223 Braunkohle 4070 Steinkohle
* 4069 Erdgas 1224 Kernenergie (DE = 0 since 2023)
* 4067 Wind Onshore 1225 Wind Offshore
* 1226 Photovoltaik 1228 Wasserkraft (run-of-river + storage)
* 4066 Biomasse 1227 Sonstige Konventionelle
* 4071 Pumpspeicher 410 Netzlast (Stromverbrauch)
*/
const SMARD_BASE = "https://www.smard.de/app/chart_data";
export const SMARD_FILTERS = {
// Conventional (label-relevant)
lignite: 1223, // Braunkohle
hardCoal: 4070, // Steinkohle
gas: 4069, // Erdgas
nuclear: 1224, // Kernenergie
// Renewable
windOnshore: 4067,
windOffshore: 1225,
solar: 1226, // Photovoltaik
hydro: 1228, // Wasserkraft
biomass: 4066,
// Other
otherConventional: 1227, // Sonstige Konventionelle (NOT in conv-summe)
pumpedStorage: 4071, // Pumpspeicher (NOT in conv-summe — Speicher)
// Demand
load: 410, // Netzlast
} as const;
export type SmardFilter = keyof typeof SMARD_FILTERS;
type SmardSlot = { tsMs: number; mwh: number };
type IndexResp = { timestamps?: number[] };
type SeriesResp = { series?: [number, number | null][] };
async function fetchLatestArchiveStart(
filterId: number,
fetcher: typeof fetch
): Promise<number | null> {
try {
const res = await fetcher(`${SMARD_BASE}/${filterId}/DE/index_quarterhour.json`, {
signal: AbortSignal.timeout(8_000),
});
if (!res.ok) return null;
const data = (await res.json()) as IndexResp;
const list = data.timestamps ?? [];
return list[list.length - 1] ?? null;
} catch {
return null;
}
}
async function fetchArchive(
filterId: number,
archiveTs: number,
fetcher: typeof fetch
): Promise<[number, number | null][] | null> {
try {
const res = await fetcher(
`${SMARD_BASE}/${filterId}/DE/${filterId}_DE_quarterhour_${archiveTs}.json`,
{ signal: AbortSignal.timeout(8_000) }
);
if (!res.ok) return null;
const data = (await res.json()) as SeriesResp;
return data.series ?? null;
} catch {
return null;
}
}
/**
* Pick the latest non-null slot from a series.
*/
function lastNonNull(series: [number, number | null][]): SmardSlot | null {
for (let i = series.length - 1; i >= 0; i--) {
const [tsMs, val] = series[i];
if (typeof val === "number" && Number.isFinite(val)) {
return { tsMs, mwh: val };
}
}
return null;
}
/**
* Fetch the latest non-null datapoint for one filter ID.
* Returns null when the index or archive is unreachable.
*/
export async function fetchSmardLatest(
filterId: number,
fetcher: typeof fetch
): Promise<SmardSlot | null> {
const archive = await fetchLatestArchiveStart(filterId, fetcher);
if (archive == null) return null;
const series = await fetchArchive(filterId, archive, fetcher);
if (series == null) return null;
return lastNonNull(series);
}
export type SmardSnapshot = {
/** Timestamp common to all filters — the EARLIEST "latest" across the
* set, so every value is from a slot that all filters have reported.
* Unix seconds (not ms). */
measuredAtUnix: number;
/** Average MW for each filter at the aligned slot. Missing filters → 0. */
values: Record<SmardFilter, number>;
};
/**
* Fetch the entire snapshot in parallel + align timestamps.
*
* Strategy: pull each filter's `last_non_null`, then align to the
* MINIMUM of the timestamps so we don't show value A from 21:00 next to
* value B from 20:30. Returns the aligned timestamp + the value at that
* exact slot for each filter.
*/
export async function fetchSmardSnapshot(
fetcher: typeof fetch
): Promise<SmardSnapshot | null> {
const filters = Object.entries(SMARD_FILTERS) as [SmardFilter, number][];
const results = await Promise.all(
filters.map(async ([key, id]) => {
// For 1224 (Nuclear) we expect 404 in DE — treat as 0/null gracefully.
const slot = await fetchSmardLatest(id, fetcher);
return { key, id, slot };
})
);
// Drop entirely-missing filters (1224 in DE returns 404 → null).
const present = results.filter((r) => r.slot != null) as Array<{
key: SmardFilter;
id: number;
slot: SmardSlot;
}>;
if (present.length === 0) return null;
// Aligned timestamp = the earliest of the latest stamps. Anything past
// it would lack data for at least one filter; we'd rather show a
// slightly-older fully-aligned snapshot than mismatched freshness.
const alignedMs = Math.min(...present.map((r) => r.slot.tsMs));
// For each filter, fetch the archive once more to read the value at
// the aligned timestamp (not just the latest). For most filters
// they'll match.
// Optimisation: re-walk the archives we already have if we cached
// them; here we re-fetch since each slot lookup needs the full series.
const aligned = await Promise.all(
present.map(async ({ key, id, slot }) => {
if (slot.tsMs === alignedMs) {
return [key, slot.mwh] as const;
}
// Need value at alignedMs — re-fetch archive (cached upstream).
const archive = await fetchLatestArchiveStart(id, fetcher);
if (archive == null) return [key, null] as const;
const series = await fetchArchive(id, archive, fetcher);
if (series == null) return [key, null] as const;
const found = series.find(([t]) => t === alignedMs);
const v = found?.[1];
return [key, typeof v === "number" && Number.isFinite(v) ? v : null] as const;
})
);
const values = {} as Record<SmardFilter, number>;
for (const key of Object.keys(SMARD_FILTERS) as SmardFilter[]) {
values[key] = 0;
}
for (const [key, mwh] of aligned) {
if (mwh != null) {
// 15-min energy (MWh) → average power over that slot (MW).
values[key] = mwh * 4;
}
}
return {
measuredAtUnix: Math.floor(alignedMs / 1000),
values,
};
}