feat(strommix): history-driven panels (residual load, dunkelflaute, neg-price, flow balance)

Aggregator route /api/strommix now reads three RustyCMS history-mode
collections in parallel — strommix_history_de, price_de_history,
crossborder_history_de — and projects derived metrics on top of the raw
upstream snapshots stored there:

- todayHourly: per-slot Last + Wind+Solar for the residual-load chart
- negPriceHoursYtd: count of YTD slots where DE-LU day-ahead < 0
- crossborderTodayGwh / netCrossborderGwhYtd / crossborderByCountryGwhYtd:
  GW × slot-hours integrated into a per-country GWh balance
- lastDunkelflaute / worstDunkelflauten: contiguous runs where
  (wind+solar)/load drops below 10 % for at least 1 h, top 5 by duration

All four are null-graceful — collections that the CMS hasn't backfilled
yet leave the corresponding panel hidden instead of breaking the widget.

Five new components in lib/components/blocks/strommix/:
- ResidualLoadChart.svelte — SVG line chart, no chart-lib dep
- CapacityFactorBars.svelte — live vs installed Wind/Wind-Off/Solar
- DunkelflauteCounter.svelte — last phase card + top-5 list
- NegPriceCounter.svelte — YTD hours + manual EinsMan compensation
- FlowBalanceTodayYtd.svelte — today + YTD saldo with per-country breakdown

Block schema additions (block-types.ts + strommix-default.json5 in
cms_content): installed_*_gw / installed_as_of for the capacity-factor
bars (BNetzA Marktstammdatenregister snapshot, manually maintained), and
einsman_costs_ytd_eur_mio / einsman_costs_as_of for the NegPrice panel
(BNetzA quarterly report figures, no API).
This commit is contained in:
Peter Meier
2026-05-07 13:06:30 +02:00
parent c4a09f03f4
commit 88514b065a
9 changed files with 1193 additions and 3 deletions
+367 -3
View File
@@ -23,7 +23,7 @@
import type { RequestHandler } from "./$types";
import { env } from "$env/dynamic/public";
import type { StrommixResponse } from "$lib/strommix";
import type { ResidualLoadPoint, StrommixResponse } from "$lib/strommix";
import { fetchSmardSnapshot } from "$lib/smard";
const CMS_BASE = (
@@ -147,6 +147,295 @@ type PriceArrayResp = {
price?: (number | null)[];
};
/**
* One row from RustyCMS' `_history` endpoint. The `data` field carries the
* raw upstream JSON exactly as it was when persisted — projection happens
* client-side here so a future schema-mapping change cannot lose old data.
*/
type HistoryItem<TData = unknown> = { at: number; data: TData };
type HistoryResponse<TData = unknown> = {
collection: string;
environment: string;
total: number;
returned: number;
items: HistoryItem<TData>[];
};
/**
* Generic helper: read a slice of an external collection's history.
* `from` / `to` are unix-seconds (the endpoint also accepts ISO strings,
* but unix avoids any TZ surprise on the client). Returns `null` when the
* upstream errors or has no rows yet — callers downgrade gracefully.
*/
async function fetchHistorySeries<TData>(
collection: string,
fromUnix: number,
toUnix: number,
fetcher: typeof fetch,
limit = 5000
): Promise<HistoryResponse<TData> | null> {
try {
const url =
`${CMS_BASE}/api/content/${collection}/_history` +
`?from=${fromUnix}&to=${toUnix}&limit=${limit}`;
const res = await fetcher(url, { signal: AbortSignal.timeout(15_000) });
if (!res.ok) return null;
return (await res.json()) as HistoryResponse<TData>;
} catch {
return null;
}
}
/** Public-power upstream payload shape (as persisted in history). */
type PublicPowerSnapshot = {
unix_seconds?: number[];
production_types?: PublicPowerSeries[];
};
/**
* Build today's residual-load curve from history snapshots. Each snapshot
* holds a window's worth of slots (4 × 15 min for a 1h chunk; 96 for a
* day-chunk backfill). We lay them on a Map keyed by `unix_seconds` so
* overlapping windows merge cleanly.
*/
function buildTodayHourly(snapshots: HistoryItem<PublicPowerSnapshot>[]): ResidualLoadPoint[] {
const dayStartSec = (() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return Math.floor(d.getTime() / 1000);
})();
const merged = new Map<number, ResidualLoadPoint>();
for (const snap of snapshots) {
const ts = snap.data.unix_seconds ?? [];
const prod = snap.data.production_types ?? [];
const findData = (name: string): (number | null)[] =>
prod.find((p) => p.name === name)?.data ?? [];
const load = findData("Load");
const wOn = findData("Wind onshore");
const wOff = findData("Wind offshore");
const sol = findData("Solar");
for (let i = 0; i < ts.length; i++) {
const slot = ts[i];
if (slot < dayStartSec) continue;
const loadV = num(load[i]);
const renewV = num(wOn[i]) + num(wOff[i]) + num(sol[i]);
// Only keep slots with at least a load reading — empty future slots
// arrive as `null` from upstream and shouldn't appear on the chart.
if (load[i] == null) continue;
merged.set(slot, {
unix: slot,
loadMw: loadV,
weatherRenewableMw: renewV,
});
}
}
return Array.from(merged.values()).sort((a, b) => a.unix - b.unix);
}
/**
* Count negative-price slots in the price-history series. Energy-Charts
* delivers `price` arrays per snapshot; we sum slots with `price < 0`
* across all snapshots in the YTD window.
*
* Same caveat as `buildTodayHourly`: snapshots overlap by design, so we
* dedup by slot timestamp.
*/
function countNegPriceSlots(snapshots: HistoryItem<PublicPowerSnapshot & PriceArrayResp>[]): number {
const seen = new Set<number>();
let count = 0;
for (const snap of snapshots) {
const ts = snap.data.unix_seconds ?? [];
const price = snap.data.price ?? [];
for (let i = 0; i < ts.length; i++) {
const slot = ts[i];
if (seen.has(slot)) continue;
seen.add(slot);
const p = price[i];
if (typeof p === "number" && p < 0) count += 1;
}
}
// Energy-Charts day-ahead is hourly: each "negative slot" = 1 negative hour.
return count;
}
/**
* Sum cross-border flows in `[fromUnix, +∞)` into a per-country total in
* GWh. Source values are GW per slot (typically 15 min) — multiplying by
* slot-duration in hours yields GWh. Slot duration is inferred from the
* gap between consecutive timestamps within a snapshot; defaults to 0.25h
* when only one slot is present.
*
* Slots are deduped across overlapping snapshots, so the same `fromUnix`
* cutoff can produce a today-only or YTD aggregate without double-counting.
*/
type CrossborderHistoryItem = HistoryItem<{
unix_seconds?: number[];
countries?: { name: string; data: (number | null)[] }[];
}>;
function aggregateCrossborderRange(
snapshots: CrossborderHistoryItem[],
fromUnix: number
): { total: number; perCountry: Record<string, number> } {
const slotDuration = new Map<number, number>(); // slot → duration in hours
const byCountry: Record<string, number> = {};
const counted = new Map<string, Set<number>>(); // country → slots already counted
const COUNTRY_CODE: Record<string, string> = {
Austria: "AT",
Belgium: "BE",
"Czech Republic": "CZ",
Denmark: "DK",
France: "FR",
Luxembourg: "LU",
Netherlands: "NL",
Norway: "NO",
Poland: "PL",
Sweden: "SE",
Switzerland: "CH",
};
for (const snap of snapshots) {
const ts = snap.data.unix_seconds ?? [];
const slotH = ts.length > 1 ? (ts[1] - ts[0]) / 3600 : 0.25;
for (let i = 0; i < ts.length; i++) {
if (!slotDuration.has(ts[i])) slotDuration.set(ts[i], slotH);
}
for (const c of snap.data.countries ?? []) {
const code = COUNTRY_CODE[c.name];
if (!code) continue;
let seenForCountry = counted.get(code);
if (!seenForCountry) {
seenForCountry = new Set<number>();
counted.set(code, seenForCountry);
}
let acc = byCountry[code] ?? 0;
for (let i = 0; i < ts.length; i++) {
const slot = ts[i];
if (slot < fromUnix) continue;
if (seenForCountry.has(slot)) continue;
const v = c.data[i];
if (typeof v !== "number" || !Number.isFinite(v)) continue;
const dur = slotDuration.get(slot) ?? slotH;
acc += v * dur;
seenForCountry.add(slot);
}
byCountry[code] = acc;
}
}
const total = Object.values(byCountry).reduce((a, b) => a + b, 0);
return { total, perCountry: byCountry };
}
/**
* Detect contiguous runs where Wind+Solar share of load drops below
* `thresholdPct` (e.g. 10 %). Operates on `strommix_history_de` snapshots:
* extracts per-slot load + wind+solar, dedups by slot, sorts, then walks
* the timeline.
*
* Returns the *latest* run plus the top `topN` longest runs (ties broken
* by recency). Empty list when history exists but no qualifying run.
*/
type SlotSample = { unix: number; load: number; renew: number; pct: number };
function extractSlotSamples(
snapshots: HistoryItem<PublicPowerSnapshot>[]
): SlotSample[] {
const merged = new Map<number, { load: number; renew: number }>();
for (const snap of snapshots) {
const ts = snap.data.unix_seconds ?? [];
const prod = snap.data.production_types ?? [];
const find = (n: string) => prod.find((p) => p.name === n)?.data ?? [];
const load = find("Load");
const wOn = find("Wind onshore");
const wOff = find("Wind offshore");
const sol = find("Solar");
for (let i = 0; i < ts.length; i++) {
const lv = load[i];
if (typeof lv !== "number" || !Number.isFinite(lv) || lv <= 0) continue;
// Don't overwrite — first non-null wins per slot.
if (merged.has(ts[i])) continue;
const r = num(wOn[i]) + num(wOff[i]) + num(sol[i]);
merged.set(ts[i], { load: lv, renew: r });
}
}
const out: SlotSample[] = [];
for (const [unix, { load, renew }] of merged) {
out.push({ unix, load, renew, pct: (renew / load) * 100 });
}
out.sort((a, b) => a.unix - b.unix);
return out;
}
function detectDunkelflauten(
snapshots: HistoryItem<PublicPowerSnapshot>[],
thresholdPct: number,
minDurationH: number,
topN: number,
nowUnix: number
): { last: ReturnType<typeof toPhase> | null; worst: ReturnType<typeof toPhase>[] } {
const samples = extractSlotSamples(snapshots);
if (samples.length === 0) {
return { last: null, worst: [] };
}
// Infer slot length from first two samples; default 15 min.
const slotH = samples.length > 1 ? (samples[1].unix - samples[0].unix) / 3600 : 0.25;
type Run = { startUnix: number; endUnix: number; minPct: number; durationH: number };
const runs: Run[] = [];
let current: Run | null = null;
for (const s of samples) {
if (s.pct < thresholdPct) {
if (current == null) {
current = { startUnix: s.unix, endUnix: s.unix, minPct: s.pct, durationH: slotH };
} else {
current.endUnix = s.unix;
current.minPct = Math.min(current.minPct, s.pct);
current.durationH += slotH;
}
} else if (current != null) {
runs.push(current);
current = null;
}
}
if (current != null) runs.push(current);
const qualifying = runs.filter((r) => r.durationH >= minDurationH);
if (qualifying.length === 0) {
return { last: null, worst: [] };
}
const last = qualifying[qualifying.length - 1];
const worst = qualifying
.slice()
.sort((a, b) => b.durationH - a.durationH || b.startUnix - a.startUnix)
.slice(0, topN);
return {
last: toPhase(last, nowUnix, slotH),
worst: worst.map((r) => toPhase(r, nowUnix, slotH)),
};
}
function toPhase(
r: { startUnix: number; endUnix: number; minPct: number; durationH: number },
nowUnix: number,
slotH: number
) {
// Run "ended" at endUnix + slotH (exclusive end of last slot).
const endPlusSlot = r.endUnix + slotH * 3600;
const endedHoursAgo = Math.max(0, (nowUnix - endPlusSlot) / 3600);
return {
startUnix: r.startUnix,
durationH: r.durationH,
minPct: r.minPct,
endedHoursAgo,
};
}
/**
* Pick the day-ahead price slot whose start ≤ now. Energy-Charts
* returns 96 slots covering the day; the last cell is typically a
@@ -187,7 +476,29 @@ async function fetchPriceAtNow(
}
export const GET: RequestHandler = async ({ fetch }) => {
const [smardRaw, energyCharts, cross, priceDe, priceFr] = await Promise.all([
const nowSecAtRequest = Math.floor(Date.now() / 1000);
const dayStartSec = (() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return Math.floor(d.getTime() / 1000);
})();
const yearStartSec = (() => {
const d = new Date();
d.setMonth(0, 1);
d.setHours(0, 0, 0, 0);
return Math.floor(d.getTime() / 1000);
})();
const [
smardRaw,
energyCharts,
cross,
priceDe,
priceFr,
histStrommix,
histPriceDe,
histCrossborder,
] = await Promise.all([
fetchSmardSnapshot(fetch),
// Energy-Charts /public_power direct (with explicit time range —
// upstream now returns 404 without start/end). Used as per-field
@@ -197,6 +508,26 @@ export const GET: RequestHandler = async ({ fetch }) => {
fetchOne<CrossborderRaw>("crossborder_de", fetch),
fetchPriceAtNow("DE-LU", fetch),
fetchPriceAtNow("FR", fetch),
// History reads — null when CMS has no history backend yet, so the
// widget gracefully renders without the historical panels.
fetchHistorySeries<PublicPowerSnapshot>(
"strommix_history_de",
dayStartSec,
nowSecAtRequest,
fetch
),
fetchHistorySeries<PublicPowerSnapshot & PriceArrayResp>(
"price_de_history",
yearStartSec,
nowSecAtRequest,
fetch
),
fetchHistorySeries<{ unix_seconds?: number[]; countries?: { name: string; data: (number | null)[] }[] }>(
"crossborder_history_de",
yearStartSec,
nowSecAtRequest,
fetch
),
]);
// SMARD aligned-min picks the earliest "latest" across filters. If
@@ -206,7 +537,7 @@ export const GET: RequestHandler = async ({ fetch }) => {
// when it is older than 90 min so the aggregator falls back to
// Energy-Charts entirely. 90 min lets typical 3060 min SMARD lag
// still win.
const nowSec = Math.floor(Date.now() / 1000);
const nowSec = nowSecAtRequest;
const smard =
smardRaw && nowSec - smardRaw.measuredAtUnix < 90 * 60 ? smardRaw : null;
@@ -265,6 +596,30 @@ 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.
const todayHourly =
histStrommix && histStrommix.items.length > 0
? buildTodayHourly(histStrommix.items)
: null;
const negPriceHoursYtd = histPriceDe ? countNegPriceSlots(histPriceDe.items) : null;
const crossborderYtd = histCrossborder
? aggregateCrossborderRange(histCrossborder.items, yearStartSec)
: null;
const crossborderToday = histCrossborder
? aggregateCrossborderRange(histCrossborder.items, dayStartSec)
: null;
// Dunkelflaute detection — tunables match the existing widget thresholds:
// < 10 % renewable share, runs of at least 1 h. Thresholds intentionally
// hard-coded here, not driven by block schema, since they're a fixed
// *fact* about the data (not editorial choice).
const dunkelflaute =
histStrommix && histStrommix.items.length > 0
? detectDunkelflauten(histStrommix.items, 10, 1, 5, nowSecAtRequest)
: null;
const body: StrommixResponse = {
measuredAtUnix: smard?.measuredAtUnix ?? ec.measured_at_unix ?? null,
dataSource: !smard ? "energy-charts" : usedFallback ? "smard+energy-charts" : "smard",
@@ -289,6 +644,15 @@ export const GET: RequestHandler = async ({ fetch }) => {
staleStrommix: smard == null && !energyCharts.data,
staleCrossborder: cross.stale,
stalePrice: priceDe.stale || priceFr.stale,
todayHourly,
negPriceHoursYtd,
netCrossborderGwhYtd: crossborderYtd?.total ?? null,
crossborderByCountryGwhYtd: crossborderYtd?.perCountry ?? null,
crossborderTodayGwh: crossborderToday
? { total: crossborderToday.total, perCountry: crossborderToday.perCountry }
: null,
lastDunkelflaute: dunkelflaute?.last ?? null,
worstDunkelflauten: dunkelflaute?.worst ?? null,
};
return new Response(JSON.stringify(body), {