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
@@ -99,6 +99,15 @@
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.52 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(() => {
if (!bucket) return "neutral";
return bucket === "dunkelflaute"
@@ -198,6 +207,7 @@
<div class="px-4 py-3">
<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-[11px] text-neutral-500 mt-0.5">Braunkohle + Steinkohle + Erdgas + Kernkraft</div>
</div>
<div class="px-4 py-3">
<div class="text-xs text-neutral-600 uppercase tracking-wide">
@@ -208,6 +218,12 @@
{#if importDirection === "Export"}{/if}
{fmtGw(live.netFlowGw)}
</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>
@@ -218,6 +234,11 @@
{#if enableFr && live.priceFrEurMwh != null}
<span class="text-neutral-600">(Frankreich: <span class="tabular-nums">{fmtPrice(live.priceFrEurMwh)}</span>)</span>
{/if}
{#if live.priceMeasuredAtUnix}
<span class="text-[11px] text-neutral-500">
· Slot {fmtTime(live.priceMeasuredAtUnix)}
</span>
{/if}
{#if (live.priceDeEurMwh ?? 0) < 0}
<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" />
+12 -1
View File
@@ -7,6 +7,9 @@
*/
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;
// Production (MW)
windOnshoreMw: number;
@@ -24,10 +27,18 @@ export type StrommixResponse = {
conventionalMw: number;
// Cross-border (GW; positive = net export from DE, negative = import)
netFlowGw: number;
/** When was the cross-border saldo last reported by upstream? cbpf
* typically lags 12 h behind real time. Widget compares to
* `measuredAtUnix` to decide whether to show "Stand HH:MM". */
crossborderMeasuredAtUnix: number | null;
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;
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
staleStrommix: boolean;
staleCrossborder: boolean;