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.
196 lines
6.6 KiB
TypeScript
196 lines
6.6 KiB
TypeScript
/**
|
||
* 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,
|
||
};
|
||
}
|