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
+58
View File
@@ -50,4 +50,62 @@ export type StrommixResponse = {
staleStrommix: boolean;
staleCrossborder: boolean;
stalePrice: boolean;
// ---------------------------------------------------------------------
// Historical aggregates (sourced from CMS `_history` endpoint over
// raw upstream snapshots; null = no data yet for this widget).
// ---------------------------------------------------------------------
/** Today's residual-load curve. One entry per upstream slot (typically
* 15 min). `loadMw - weatherRenewableMw` = the gap conventional plants
* + imports must fill. `null` when history backend is empty / disabled. */
todayHourly: ResidualLoadPoint[] | null;
/** Negative-price hours year-to-date (DE-LU, day-ahead). Counted by
* iterating history snapshots and summing 15-min slots whose price
* was below 0. */
negPriceHoursYtd: number | null;
/** Cross-border net balance YTD in GWh (positive = net export from DE,
* negative = net import). */
netCrossborderGwhYtd: number | null;
/** Per-country YTD balance, same sign convention as `netFlowGw`. */
crossborderByCountryGwhYtd: Record<string, number> | null;
/** Cross-border balance for *today only*, GWh per country and total.
* Same sign convention. `null` when crossborder history is empty. */
crossborderTodayGwh: { total: number; perCountry: Record<string, number> } | null;
/** Most recent contiguous run of `(wind+solar) / load < 10%`. `null`
* when no such run exists in retained history. `endedHoursAgo: 0`
* means the run is still ongoing (last slot is within the threshold). */
lastDunkelflaute: DunkelflautePhase | null;
/** Top 5 longest Dunkelflaute phases in retained history, newest first
* if equal length. Empty array when history exists but no qualifying
* phase found. `null` when history is unavailable. */
worstDunkelflauten: DunkelflautePhase[] | null;
};
/** One upstream slot in the residual-load series. */
export type ResidualLoadPoint = {
/** Unix seconds at the start of the slot. */
unix: number;
/** Total electrical load on the German grid (MW). */
loadMw: number;
/** Wind onshore + wind offshore + solar (MW). */
weatherRenewableMw: number;
};
/** A contiguous low-renewables run, computed from history snapshots. */
export type DunkelflautePhase = {
/** Unix sec of the first slot below the threshold. */
startUnix: number;
/** Run duration in hours (slot count × slot length). */
durationH: number;
/** Lowest Wind+Solar share observed during the run, percentage of load. */
minPct: number;
/** Hours since the run ended (0 = ongoing). */
endedHoursAgo: number;
};