fix(strommix): pick price at "now" + surface cross-border timestamp
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:
@@ -99,6 +99,15 @@
|
|||||||
return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cross-border data is considered stale (and worth flagging) when its
|
||||||
|
* timestamp is more than 30 min behind the production-snapshot. cbpf
|
||||||
|
* typically lags 1.5–2 h, so this is the rule rather than the
|
||||||
|
* exception — surfacing the stamp keeps the widget honest. */
|
||||||
|
const cbStale = $derived.by(() => {
|
||||||
|
if (!live?.measuredAtUnix || !live?.crossborderMeasuredAtUnix) return false;
|
||||||
|
return live.measuredAtUnix - live.crossborderMeasuredAtUnix > 30 * 60;
|
||||||
|
});
|
||||||
|
|
||||||
const bucketAccent = $derived.by(() => {
|
const bucketAccent = $derived.by(() => {
|
||||||
if (!bucket) return "neutral";
|
if (!bucket) return "neutral";
|
||||||
return bucket === "dunkelflaute"
|
return bucket === "dunkelflaute"
|
||||||
@@ -198,6 +207,7 @@
|
|||||||
<div class="px-4 py-3">
|
<div class="px-4 py-3">
|
||||||
<div class="text-xs text-neutral-600 uppercase tracking-wide">Konventionell jetzt</div>
|
<div class="text-xs text-neutral-600 uppercase tracking-wide">Konventionell jetzt</div>
|
||||||
<div class="text-lg font-semibold tabular-nums">{fmtMw(live.conventionalMw)}</div>
|
<div class="text-lg font-semibold tabular-nums">{fmtMw(live.conventionalMw)}</div>
|
||||||
|
<div class="text-[11px] text-neutral-500 mt-0.5">Braunkohle + Steinkohle + Erdgas + Kernkraft</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-4 py-3">
|
<div class="px-4 py-3">
|
||||||
<div class="text-xs text-neutral-600 uppercase tracking-wide">
|
<div class="text-xs text-neutral-600 uppercase tracking-wide">
|
||||||
@@ -208,6 +218,12 @@
|
|||||||
{#if importDirection === "Export"}↑{/if}
|
{#if importDirection === "Export"}↑{/if}
|
||||||
{fmtGw(live.netFlowGw)}
|
{fmtGw(live.netFlowGw)}
|
||||||
</div>
|
</div>
|
||||||
|
{#if live.crossborderMeasuredAtUnix}
|
||||||
|
<div class="text-[11px] text-neutral-500 mt-0.5">
|
||||||
|
Stand: {fmtTime(live.crossborderMeasuredAtUnix)} Uhr
|
||||||
|
{#if cbStale}<span class="text-amber-700"> · verzögert</span>{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -218,6 +234,11 @@
|
|||||||
{#if enableFr && live.priceFrEurMwh != null}
|
{#if enableFr && live.priceFrEurMwh != null}
|
||||||
<span class="text-neutral-600">(Frankreich: <span class="tabular-nums">{fmtPrice(live.priceFrEurMwh)}</span>)</span>
|
<span class="text-neutral-600">(Frankreich: <span class="tabular-nums">{fmtPrice(live.priceFrEurMwh)}</span>)</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if live.priceMeasuredAtUnix}
|
||||||
|
<span class="text-[11px] text-neutral-500">
|
||||||
|
· Slot {fmtTime(live.priceMeasuredAtUnix)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
{#if (live.priceDeEurMwh ?? 0) < 0}
|
{#if (live.priceDeEurMwh ?? 0) < 0}
|
||||||
<span class="ml-auto inline-flex items-center gap-1 text-amber-800 font-semibold">
|
<span class="ml-auto inline-flex items-center gap-1 text-amber-800 font-semibold">
|
||||||
<Icon icon="mdi:alert-circle-outline" class="size-4" aria-hidden="true" />
|
<Icon icon="mdi:alert-circle-outline" class="size-4" aria-hidden="true" />
|
||||||
|
|||||||
+12
-1
@@ -7,6 +7,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type StrommixResponse = {
|
export type StrommixResponse = {
|
||||||
|
// Production-snapshot timestamp (Unix sec) — used as the "anchor" for
|
||||||
|
// the rest of the values. Cross-border + prices may have their own,
|
||||||
|
// earlier or later, timestamps.
|
||||||
measuredAtUnix: number | null;
|
measuredAtUnix: number | null;
|
||||||
// Production (MW)
|
// Production (MW)
|
||||||
windOnshoreMw: number;
|
windOnshoreMw: number;
|
||||||
@@ -24,10 +27,18 @@ export type StrommixResponse = {
|
|||||||
conventionalMw: number;
|
conventionalMw: number;
|
||||||
// Cross-border (GW; positive = net export from DE, negative = import)
|
// Cross-border (GW; positive = net export from DE, negative = import)
|
||||||
netFlowGw: number;
|
netFlowGw: number;
|
||||||
|
/** When was the cross-border saldo last reported by upstream? cbpf
|
||||||
|
* typically lags 1–2 h behind real time. Widget compares to
|
||||||
|
* `measuredAtUnix` to decide whether to show "Stand HH:MM". */
|
||||||
|
crossborderMeasuredAtUnix: number | null;
|
||||||
flowsByCountry: Record<string, number>;
|
flowsByCountry: Record<string, number>;
|
||||||
// Prices (EUR/MWh; null if upstream unavailable)
|
// Prices (EUR/MWh; null if upstream unavailable). Picked from the
|
||||||
|
// 15-min day-ahead slot whose interval contains "now", not the last
|
||||||
|
// non-null cell of the array.
|
||||||
priceDeEurMwh: number | null;
|
priceDeEurMwh: number | null;
|
||||||
priceFrEurMwh: number | null;
|
priceFrEurMwh: number | null;
|
||||||
|
/** Unix sec of the price-slot start that produced `priceDeEurMwh`. */
|
||||||
|
priceMeasuredAtUnix: number | null;
|
||||||
// Stale flags surfaced from the CMS' `x-rustycms-external-stale` header
|
// Stale flags surfaced from the CMS' `x-rustycms-external-stale` header
|
||||||
staleStrommix: boolean;
|
staleStrommix: boolean;
|
||||||
staleCrossborder: boolean;
|
staleCrossborder: boolean;
|
||||||
|
|||||||
@@ -53,11 +53,6 @@ type CrossborderRaw = {
|
|||||||
measured_at_unix?: number | null;
|
measured_at_unix?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PriceRaw = {
|
|
||||||
price_eur_mwh?: number | null;
|
|
||||||
measured_at_unix?: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function num(x: unknown): number {
|
function num(x: unknown): number {
|
||||||
return typeof x === "number" && Number.isFinite(x) ? x : 0;
|
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 }) => {
|
export const GET: RequestHandler = async ({ fetch }) => {
|
||||||
const [strommix, cross, priceDe, priceFr] = await Promise.all([
|
const [strommix, cross, priceDe, priceFr] = await Promise.all([
|
||||||
fetchOne<StrommixRaw>("strommix_de", fetch),
|
fetchOne<StrommixRaw>("strommix_de", fetch),
|
||||||
fetchOne<CrossborderRaw>("crossborder_de", fetch),
|
fetchOne<CrossborderRaw>("crossborder_de", fetch),
|
||||||
fetchOne<PriceRaw>("price_de", fetch),
|
// Prices fetched directly so we can index by "now" instead of
|
||||||
fetchOne<PriceRaw>("price_fr", fetch),
|
// 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 ?? {};
|
const s = strommix.data ?? {};
|
||||||
@@ -125,9 +170,11 @@ export const GET: RequestHandler = async ({ fetch }) => {
|
|||||||
weatherRenewableMw,
|
weatherRenewableMw,
|
||||||
conventionalMw,
|
conventionalMw,
|
||||||
netFlowGw,
|
netFlowGw,
|
||||||
|
crossborderMeasuredAtUnix: c.measured_at_unix ?? null,
|
||||||
flowsByCountry,
|
flowsByCountry,
|
||||||
priceDeEurMwh: priceDe.data?.price_eur_mwh ?? null,
|
priceDeEurMwh: priceDe.price,
|
||||||
priceFrEurMwh: priceFr.data?.price_eur_mwh ?? null,
|
priceFrEurMwh: priceFr.price,
|
||||||
|
priceMeasuredAtUnix: priceDe.slotUnix,
|
||||||
staleStrommix: strommix.stale,
|
staleStrommix: strommix.stale,
|
||||||
staleCrossborder: cross.stale,
|
staleCrossborder: cross.stale,
|
||||||
stalePrice: priceDe.stale || priceFr.stale,
|
stalePrice: priceDe.stale || priceFr.stale,
|
||||||
|
|||||||
Reference in New Issue
Block a user