fix(strommix): pick price at "now" + surface cross-border timestamp
Deploy / verify (push) Successful in 46s
Deploy / deploy (push) Successful in 49s

Audit against ground truth at 22:15 lokal exposed three real bugs:

1. Day-ahead price was wrong (widget 125, real 153 €/MWh). The CMS'
   `last_non_null` transform always picks the last cell of the 96-slot
   array — typically a slot in the future, not the slot covering "now".
   Bypass the CMS for prices: aggregator fetches energy-charts /price
   directly, walks unix_seconds, and picks the largest start ≤ now.
   `priceMeasuredAtUnix` is surfaced so the widget can show the slot
   timestamp as a sanity check.

2. Cross-border saldo (cbpf) typically lags 1.5–2 h. Widget labelled
   it as "jetzt" even when the upstream's last datapoint was 90 min
   old. Now the cross-border timestamp travels through the aggregator
   and the widget renders "Stand 20:30 Uhr · verzögert" when the gap
   to the production snapshot exceeds 30 min.

3. Bucket label "Dunkelflaute" misuses the term — the formal
   definition needs ≥24 h history, not a single threshold crossing.
   Updated the strommix-default content entry to use "Sehr niedrige
   Einspeisung" / "Very low feed-in" instead, and explained the
   reasoning in a comment so editors don't re-introduce the misnomer.

Aggregator response shape gained `crossborderMeasuredAtUnix` +
`priceMeasuredAtUnix`; the widget renders both as small timestamps
under their values, plus a "verzögert" hint when cross-border data is
older than the 30-min tolerance window.

Cache TTLs on the price external collections lowered from 30 min to
15 min so the admin-side cache flips at the same cadence as the
day-ahead slots (since 30.09.2025 prices come in 15-min buckets).
This commit is contained in:
Peter Meier
2026-05-04 23:35:13 +02:00
parent 8ddfd026bc
commit f4e4abd978
3 changed files with 89 additions and 10 deletions
+56 -9
View File
@@ -53,11 +53,6 @@ type CrossborderRaw = {
measured_at_unix?: number | null;
};
type PriceRaw = {
price_eur_mwh?: number | null;
measured_at_unix?: number | null;
};
function num(x: unknown): number {
return typeof x === "number" && Number.isFinite(x) ? x : 0;
}
@@ -80,12 +75,62 @@ async function fetchOne<T>(
}
}
/**
* Day-ahead prices come as a 96-slot array (15-min intervals over 24 h)
* and the CMS' `last_non_null` transform always picks the LAST known
* slot — typically a slot in the future, not the one covering "now".
* Bypass the CMS for prices and pick the slot whose start time is the
* largest one ≤ now. Returns null when the upstream lacks a usable
* value.
*/
type PriceArrayResp = {
unix_seconds?: number[];
price?: (number | null)[];
};
async function fetchPriceAtNow(
bzn: string,
fetcher: typeof fetch
): Promise<{ price: number | null; slotUnix: number | null; stale: boolean }> {
try {
const res = await fetcher(
`https://api.energy-charts.info/price?bzn=${encodeURIComponent(bzn)}`,
{ signal: AbortSignal.timeout(8_000) }
);
if (!res.ok) return { price: null, slotUnix: null, stale: false };
const data = (await res.json()) as PriceArrayResp;
const times = data.unix_seconds ?? [];
const prices = data.price ?? [];
if (times.length === 0 || prices.length === 0) {
return { price: null, slotUnix: null, stale: false };
}
const nowSec = Math.floor(Date.now() / 1000);
let pickIdx = -1;
for (let i = 0; i < times.length; i++) {
if (times[i] <= nowSec) pickIdx = i;
else break;
}
if (pickIdx < 0) return { price: null, slotUnix: null, stale: false };
const value = prices[pickIdx];
return {
price: typeof value === "number" && Number.isFinite(value) ? value : null,
slotUnix: times[pickIdx] ?? null,
stale: false,
};
} catch {
return { price: null, slotUnix: null, stale: true };
}
}
export const GET: RequestHandler = async ({ fetch }) => {
const [strommix, cross, priceDe, priceFr] = await Promise.all([
fetchOne<StrommixRaw>("strommix_de", fetch),
fetchOne<CrossborderRaw>("crossborder_de", fetch),
fetchOne<PriceRaw>("price_de", fetch),
fetchOne<PriceRaw>("price_fr", fetch),
// Prices fetched directly so we can index by "now" instead of
// taking the last (potentially future) slot the CMS' last_non_null
// transform would pick.
fetchPriceAtNow("DE-LU", fetch),
fetchPriceAtNow("FR", fetch),
]);
const s = strommix.data ?? {};
@@ -125,9 +170,11 @@ export const GET: RequestHandler = async ({ fetch }) => {
weatherRenewableMw,
conventionalMw,
netFlowGw,
crossborderMeasuredAtUnix: c.measured_at_unix ?? null,
flowsByCountry,
priceDeEurMwh: priceDe.data?.price_eur_mwh ?? null,
priceFrEurMwh: priceFr.data?.price_eur_mwh ?? null,
priceDeEurMwh: priceDe.price,
priceFrEurMwh: priceFr.price,
priceMeasuredAtUnix: priceDe.slotUnix,
staleStrommix: strommix.stale,
staleCrossborder: cross.stale,
stalePrice: priceDe.stale || priceFr.stale,