Files
windwiderstand/src/lib/smard.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

196 lines
6.6 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.
/**
* 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. `null` means the
* filter could not be resolved (404, all-null series, fetch error)
* — distinguished from a genuine zero so callers can choose a
* fallback source per-field instead of ignoring real zeroes
* (e.g. solar at night, nuclear in DE). */
values: Record<SmardFilter, number | null>;
};
/**
* 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 | null>;
for (const key of Object.keys(SMARD_FILTERS) as SmardFilter[]) {
values[key] = null;
}
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,
};
}