refactor(strommix): route EC live snapshot through CMS, keep direct fallback
Deploy / verify (push) Successful in 52s
Deploy / deploy (push) Successful in 54s

Energy-Charts /public_power now fetched via strommix_history_de CMS collection
(which uses ${time:day_start|iso}/${time:now|iso} in its source URL). When the
CMS returns null (old binary without env_subst support), falls back to a direct
EC call so charts keep working during the transition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-13 08:17:41 +02:00
parent 81e419aa4d
commit cdc2a79950
+21 -55
View File
@@ -60,33 +60,26 @@ function lastNonNull(arr: (number | null)[]): number | 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(
/** Direct EC fetch — fallback when strommix_history_de via CMS returns null
* (e.g. CMS not yet deployed with ${time:...} env_subst support). */
async function fetchEnergyChartsDirectFallback(
fetcher: typeof fetch,
fromSec: number
): Promise<{ data: StrommixRaw | null; rawSeries: PublicPowerResp | null; stale: boolean }> {
): Promise<{ data: StrommixRaw | null; stale: boolean }> {
try {
const nowSec = Math.floor(Date.now() / 1000);
const fmt = (sec: number) =>
new Date(sec * 1000).toISOString().slice(0, 16) + "Z";
const start = fmt(fromSec);
const end = fmt(nowSec);
const url =
`https://api.energy-charts.info/public_power?country=de` +
`&start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`;
`&start=${encodeURIComponent(fmt(fromSec))}&end=${encodeURIComponent(fmt(nowSec))}`;
const res = await fetcher(url, { signal: AbortSignal.timeout(8_000) });
if (!res.ok) return { data: null, rawSeries: null, stale: false };
if (!res.ok) return { data: null, stale: false };
const d = (await res.json()) as PublicPowerResp;
const find = (n: string) =>
d.production_types?.find((p) => p.name === n)?.data ?? [];
const ts = d.unix_seconds ?? [];
return {
rawSeries: d,
data: {
wind_onshore_mw: lastNonNull(find("Wind onshore")),
wind_offshore_mw: lastNonNull(find("Wind offshore")),
@@ -103,36 +96,10 @@ async function fetchEnergyChartsPublicPower(
stale: false,
};
} catch {
return { data: null, rawSeries: null, stale: true };
return { data: 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 = {
flow_at_gw?: number | null;
flow_be_gw?: number | null;
@@ -527,11 +494,10 @@ export const GET: RequestHandler = async ({ fetch }) => {
histCrossborder,
] = await Promise.all([
fetchSmardSnapshot(fetch),
// Energy-Charts /public_power direct (with explicit time range —
// upstream returns 404 without start/end). Window starts at midnight so
// the full-day series doubles as the sparkline fallback when the CMS
// history backend is empty. Used as per-field fallback when SMARD lags.
fetchEnergyChartsPublicPower(fetch, dayStartSec),
// Energy-Charts data via CMS cache (strommix_history_de/current).
// The schema URL uses ${time:day_start|iso} + ${time:now|iso} so the
// CMS fetch always succeeds. Used as per-field fallback when SMARD lags.
fetchOne<StrommixRaw>("strommix_history_de", fetch),
fetchOne<CrossborderRaw>("crossborder_de", fetch),
fetchPriceAtNow("DE-LU", fetch),
fetchPriceAtNow("FR", fetch),
@@ -568,7 +534,13 @@ export const GET: RequestHandler = async ({ fetch }) => {
const smard =
smardRaw && nowSec - smardRaw.measuredAtUnix < 90 * 60 ? smardRaw : null;
const ec = energyCharts.data ?? {};
// When the CMS-backed fetch returns null (old CMS binary without ${time:...}
// support, or upstream 404), fall back to a direct Energy-Charts call.
const energyChartsResolved =
energyCharts.data !== null
? energyCharts
: await fetchEnergyChartsDirectFallback(fetch, dayStartSec);
const ec = energyChartsResolved.data ?? {};
// Per-field fallback. SMARD value `null` = filter unavailable; we
// reach for the Energy-Charts equivalent. SMARD `0` is preserved
@@ -624,17 +596,11 @@ export const GET: RequestHandler = async ({ fetch }) => {
const netFlowGw = Object.values(flowsByCountry).reduce((a, b) => a + b, 0);
// Historical aggregates derived from CMS `_history`. `null` when the
// backing collection is unavailable (e.g. CMS not yet upgraded with
// 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).
// backing collection is unavailable — widget renders without those panels.
const todayHourly =
histStrommix && histStrommix.items.length > 0
? buildTodayHourly(histStrommix.items)
: energyCharts.rawSeries
? buildTodayHourlyFromEC(energyCharts.rawSeries, dayStartSec)
: null;
: null;
const negPriceHoursYtd = histPriceDe ? countNegPriceSlots(histPriceDe.items) : null;
const crossborderYtd = histCrossborder
? aggregateCrossborderRange(histCrossborder.items, yearStartSec)
@@ -673,7 +639,7 @@ export const GET: RequestHandler = async ({ fetch }) => {
priceDeEurMwh: priceDe.price,
priceFrEurMwh: priceFr.price,
priceMeasuredAtUnix: priceDe.slotUnix,
staleStrommix: smard == null && !energyCharts.data,
staleStrommix: smard == null && !energyChartsResolved.data,
staleCrossborder: cross.stale,
stalePrice: priceDe.stale || priceFr.stale,
todayHourly,