fix(strommix): show sparkline without history backend
Deploy / verify (push) Successful in 55s
Deploy / deploy (push) Successful in 57s

The strommix_history_de collection source URL 404s (Energy-Charts needs
start/end params), so the CMS history backend stays empty and todayHourly
was always null — making the Wind & Solar sparkline never appear.

Extend fetchEnergyChartsPublicPower to start at midnight (was: -2h) and
return the raw series alongside the snapshot values. Build todayHourly
from it when the history backend has no items. No extra API call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-13 07:58:34 +02:00
parent 10fb2a3ef6
commit 81e419aa4d
+48 -16
View File
@@ -46,13 +46,6 @@ type StrommixRaw = {
measured_at_unix?: number | null; measured_at_unix?: number | null;
}; };
/**
* Fetch Energy-Charts /public_power directly with an explicit time
* window. The CMS-cached `strommix_de` schema cannot do this because
* external-collections v1 has no runtime-vars; without start/end the
* upstream returns 404 ("no content available"). 2-hour window covers
* latest 8 quarter-hour slots; we pick the latest non-null per series.
*/
type PublicPowerSeries = { name: string; data: (number | null)[] }; type PublicPowerSeries = { name: string; data: (number | null)[] };
type PublicPowerResp = { type PublicPowerResp = {
unix_seconds?: number[]; unix_seconds?: number[];
@@ -67,25 +60,33 @@ function lastNonNull(arr: (number | null)[]): number | null {
return null; return null;
} }
/**
* Fetch Energy-Charts /public_power with an explicit time window.
* `fromSec` is the window start (pass `dayStartSec` to cover all of today).
* Returns both the current-snapshot values (for live numbers) and the raw
* series (for the today-hourly sparkline when the history backend is empty).
*/
async function fetchEnergyChartsPublicPower( async function fetchEnergyChartsPublicPower(
fetcher: typeof fetch fetcher: typeof fetch,
): Promise<{ data: StrommixRaw | null; stale: boolean }> { fromSec: number
): Promise<{ data: StrommixRaw | null; rawSeries: PublicPowerResp | null; stale: boolean }> {
try { try {
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
const fmt = (sec: number) => const fmt = (sec: number) =>
new Date(sec * 1000).toISOString().slice(0, 16) + "Z"; new Date(sec * 1000).toISOString().slice(0, 16) + "Z";
const start = fmt(nowSec - 2 * 60 * 60); const start = fmt(fromSec);
const end = fmt(nowSec); const end = fmt(nowSec);
const url = const url =
`https://api.energy-charts.info/public_power?country=de` + `https://api.energy-charts.info/public_power?country=de` +
`&start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`; `&start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`;
const res = await fetcher(url, { signal: AbortSignal.timeout(8_000) }); const res = await fetcher(url, { signal: AbortSignal.timeout(8_000) });
if (!res.ok) return { data: null, stale: false }; if (!res.ok) return { data: null, rawSeries: null, stale: false };
const d = (await res.json()) as PublicPowerResp; const d = (await res.json()) as PublicPowerResp;
const find = (n: string) => const find = (n: string) =>
d.production_types?.find((p) => p.name === n)?.data ?? []; d.production_types?.find((p) => p.name === n)?.data ?? [];
const ts = d.unix_seconds ?? []; const ts = d.unix_seconds ?? [];
return { return {
rawSeries: d,
data: { data: {
wind_onshore_mw: lastNonNull(find("Wind onshore")), wind_onshore_mw: lastNonNull(find("Wind onshore")),
wind_offshore_mw: lastNonNull(find("Wind offshore")), wind_offshore_mw: lastNonNull(find("Wind offshore")),
@@ -102,10 +103,36 @@ async function fetchEnergyChartsPublicPower(
stale: false, stale: false,
}; };
} catch { } catch {
return { data: null, stale: true }; return { data: null, rawSeries: null, stale: true };
} }
} }
/** Build today's sparkline series directly from an Energy-Charts response.
* Used as fallback when the CMS history backend has no data yet. */
function buildTodayHourlyFromEC(
d: PublicPowerResp,
dayStartSec: number
): ResidualLoadPoint[] {
const ts = d.unix_seconds ?? [];
const find = (n: string) =>
d.production_types?.find((p) => p.name === n)?.data ?? [];
const load = find("Load");
const wOn = find("Wind onshore");
const wOff = find("Wind offshore");
const sol = find("Solar");
const result: ResidualLoadPoint[] = [];
for (let i = 0; i < ts.length; i++) {
if (ts[i] < dayStartSec) continue;
if (load[i] == null) continue;
result.push({
unix: ts[i],
loadMw: num(load[i]),
weatherRenewableMw: num(wOn[i]) + num(wOff[i]) + num(sol[i]),
});
}
return result.sort((a, b) => a.unix - b.unix);
}
type CrossborderRaw = { type CrossborderRaw = {
flow_at_gw?: number | null; flow_at_gw?: number | null;
flow_be_gw?: number | null; flow_be_gw?: number | null;
@@ -501,10 +528,10 @@ export const GET: RequestHandler = async ({ fetch }) => {
] = await Promise.all([ ] = await Promise.all([
fetchSmardSnapshot(fetch), fetchSmardSnapshot(fetch),
// Energy-Charts /public_power direct (with explicit time range — // Energy-Charts /public_power direct (with explicit time range —
// upstream now returns 404 without start/end). Used as per-field // upstream returns 404 without start/end). Window starts at midnight so
// fallback when SMARD's load filter (id 410) lags 12+ hours behind // the full-day series doubles as the sparkline fallback when the CMS
// the production filters and the SMARD snapshot gets discarded. // history backend is empty. Used as per-field fallback when SMARD lags.
fetchEnergyChartsPublicPower(fetch), fetchEnergyChartsPublicPower(fetch, dayStartSec),
fetchOne<CrossborderRaw>("crossborder_de", fetch), fetchOne<CrossborderRaw>("crossborder_de", fetch),
fetchPriceAtNow("DE-LU", fetch), fetchPriceAtNow("DE-LU", fetch),
fetchPriceAtNow("FR", fetch), fetchPriceAtNow("FR", fetch),
@@ -599,9 +626,14 @@ export const GET: RequestHandler = async ({ fetch }) => {
// Historical aggregates derived from CMS `_history`. `null` when the // Historical aggregates derived from CMS `_history`. `null` when the
// backing collection is unavailable (e.g. CMS not yet upgraded with // backing collection is unavailable (e.g. CMS not yet upgraded with
// history-mode schemas) — widget renders without the affected panels. // history-mode schemas) — widget renders without the affected panels.
// Build today's sparkline series. Prefer the CMS history backend (full
// deduped timeline); fall back to the Energy-Charts direct response when
// history is empty (e.g. backfill not yet run on a fresh install).
const todayHourly = const todayHourly =
histStrommix && histStrommix.items.length > 0 histStrommix && histStrommix.items.length > 0
? buildTodayHourly(histStrommix.items) ? buildTodayHourly(histStrommix.items)
: energyCharts.rawSeries
? buildTodayHourlyFromEC(energyCharts.rawSeries, dayStartSec)
: null; : null;
const negPriceHoursYtd = histPriceDe ? countNegPriceSlots(histPriceDe.items) : null; const negPriceHoursYtd = histPriceDe ? countNegPriceSlots(histPriceDe.items) : null;
const crossborderYtd = histCrossborder const crossborderYtd = histCrossborder