diff --git a/src/lib/block-types.ts b/src/lib/block-types.ts index 6091b58..5532604 100644 --- a/src/lib/block-types.ts +++ b/src/lib/block-types.ts @@ -362,6 +362,21 @@ export interface LiveStrommixBlockData { disclaimer_threshold_eur_mwh?: number; /** Reference (resolved: { _slug, _type, ...page-fields } or just slug string). */ explanation_page?: string | { _slug?: string; title?: string }; + // --------------------------------------------------------------------- + // Stammdaten (BNetzA Marktstammdatenregister, manuell gepflegt). Treiben + // den Live-vs-Installiert-Auslastungs-Bereich. Stand quartalsweise + // aktualisieren — die Werte bewegen sich um 1–3 GW pro Quartal. + // --------------------------------------------------------------------- + installed_wind_onshore_gw?: number; + installed_wind_offshore_gw?: number; + installed_solar_gw?: number; + /** Free-form Stand-Marker für die Stammdaten, z. B. "2025-Q4". */ + installed_as_of?: string; + /** Optional: Hand-gepflegte EinsMan-Entschädigungssumme YTD (Mio €). + * BNetzA liefert das nur quartalsweise per PDF. Wenn nicht gesetzt, + * zeigt das Widget den Counter ohne Eurosumme. */ + einsman_costs_ytd_eur_mio?: number; + einsman_costs_as_of?: string; layout?: BlockLayout; } diff --git a/src/lib/components/blocks/StrommixBlock.svelte b/src/lib/components/blocks/StrommixBlock.svelte index 93be455..92812a0 100644 --- a/src/lib/components/blocks/StrommixBlock.svelte +++ b/src/lib/components/blocks/StrommixBlock.svelte @@ -7,6 +7,11 @@ import { useTranslate, T } from "$lib/translations"; import Icon from "@iconify/svelte"; import "$lib/iconify-offline"; + import ResidualLoadChart from "./strommix/ResidualLoadChart.svelte"; + import CapacityFactorBars from "./strommix/CapacityFactorBars.svelte"; + import DunkelflauteCounter from "./strommix/DunkelflauteCounter.svelte"; + import NegPriceCounter from "./strommix/NegPriceCounter.svelte"; + import FlowBalanceTodayYtd from "./strommix/FlowBalanceTodayYtd.svelte"; let { block }: { block: LiveStrommixBlockData } = $props(); const t = useTranslate(); @@ -467,6 +472,77 @@ {/if} {/if} + + {#if live && !isCompact && (block.installed_wind_onshore_gw || block.installed_solar_gw)} +
+ +
+ {/if} + + + {#if live && !isCompact && live.todayHourly && live.todayHourly.length > 0} +
+

+ Residuallast heute +

+

+ Last minus Wind+Solar im Tagesverlauf. Die schraffierte Lücke + füllen konventionelle Kraftwerke und Importe. +

+
+ +
+
+ {/if} + + + {#if live && !isCompact && (live.lastDunkelflaute || (live.worstDunkelflauten && live.worstDunkelflauten.length > 0))} +
+ +
+ {/if} + + + {#if live && !isCompact && (live.negPriceHoursYtd != null || typeof block.einsman_costs_ytd_eur_mio === "number")} +
+ +
+ {/if} + + + {#if live && !isCompact && (live.crossborderTodayGwh || live.netCrossborderGwhYtd != null)} +
+ +
+ {/if} + {#if live} diff --git a/src/lib/components/blocks/strommix/CapacityFactorBars.svelte b/src/lib/components/blocks/strommix/CapacityFactorBars.svelte new file mode 100644 index 0000000..93b6e94 --- /dev/null +++ b/src/lib/components/blocks/strommix/CapacityFactorBars.svelte @@ -0,0 +1,129 @@ + + +
+
+

+ Auslastung jetzt vs. installiert +

+ {#if installedAsOf} + + Installierte Leistung: Stand {installedAsOf}, + Marktstammdatenregister + + {/if} +
+ + +
diff --git a/src/lib/components/blocks/strommix/DunkelflauteCounter.svelte b/src/lib/components/blocks/strommix/DunkelflauteCounter.svelte new file mode 100644 index 0000000..bc45d15 --- /dev/null +++ b/src/lib/components/blocks/strommix/DunkelflauteCounter.svelte @@ -0,0 +1,123 @@ + + +{#if hasLast || hasWorst} +
+
+

+ Dunkelflauten in den letzten 12 Monaten +

+

+ Phase mit Wind + Solar unter 10 % der Last. + Genau die Stunden, die Wind- und Solarzubau alleine nicht + füllen können. +

+
+ + {#if last} +
+
+ + Letzte Phase + + + {fmtAgo(last.endedHoursAgo)} + +
+ +
+ {/if} + + {#if hasWorst && worst} +
+ + Längste Phasen + +
    + {#each worst as phase, i (phase.startUnix)} +
  1. + #{i + 1} + {fmtDate(phase.startUnix)} + + {fmtDuration(phase.durationH)} + + + min {fmtPct(phase.minPct)} + +
  2. + {/each} +
+
+ {/if} +
+{/if} diff --git a/src/lib/components/blocks/strommix/FlowBalanceTodayYtd.svelte b/src/lib/components/blocks/strommix/FlowBalanceTodayYtd.svelte new file mode 100644 index 0000000..fda00c1 --- /dev/null +++ b/src/lib/components/blocks/strommix/FlowBalanceTodayYtd.svelte @@ -0,0 +1,151 @@ + + +{#if visible} +
+
+

+ Stromhandel mit Nachbarn +

+

+ Saldo positiv = Deutschland exportiert, negativ = importiert. + Bei Dunkelflauten füllen vor allem Frankreich und Skandinavien + die Lücke. +

+
+ +
+ {#if today} + {@const dir = direction(today.total)} +
+ + Heute + +
+ + {fmtGwh(today.total)} + + {dir.label} +
+
+ {/if} + {#if ytd} + {@const dir = direction(ytd.total)} +
+ + {yearLabel} YTD + +
+ + {fmtGwh(ytd.total)} + + {dir.label} +
+
+ {/if} +
+ + {#if ytd} +
+ + Aufschlüsselung nach Nachbarland (YTD) + + +
+ {/if} +
+{/if} diff --git a/src/lib/components/blocks/strommix/NegPriceCounter.svelte b/src/lib/components/blocks/strommix/NegPriceCounter.svelte new file mode 100644 index 0000000..ae02cd9 --- /dev/null +++ b/src/lib/components/blocks/strommix/NegPriceCounter.svelte @@ -0,0 +1,75 @@ + + +{#if visible} +
+
+

+ Überschussstrom & Abregelung {yearLabel} +

+

+ Hohe Wind- und Solarspitzen drücken den Strompreis, bei + Netzengpässen werden Anlagen abgeregelt — und entschädigt. + Beides Folgekosten der bisherigen Zubaupolitik. +

+
+ +
+ {#if showNeg && negHoursYtd != null} +
+
+ + {fmtNum(negHoursYtd)} + + Stunden +
+ + mit negativem Day-Ahead-Preis + (DE-LU) + +
+ {/if} + + {#if showEinsman && einsmanEurMio != null} +
+
+ + {fmtNum(einsmanEurMio, 0)} + + Mio € +
+ + Entschädigung Abregelung (§ 13 EnWG, § 51 EEG) + {#if einsmanAsOf} + + Stand {einsmanAsOf} + + {/if} + +
+ {/if} +
+
+{/if} diff --git a/src/lib/components/blocks/strommix/ResidualLoadChart.svelte b/src/lib/components/blocks/strommix/ResidualLoadChart.svelte new file mode 100644 index 0000000..41b4f59 --- /dev/null +++ b/src/lib/components/blocks/strommix/ResidualLoadChart.svelte @@ -0,0 +1,199 @@ + + +{#if points.length > 0} +
+ + {#each yTicks as t (t.mw)} + + + {t.label} + + {/each} + + {#if gapPath} + + {/if} + {#if renewPath} + + {/if} + {#if loadPath} + + {/if} + + {#each ticks as t (t.unix)} + + + {t.label} + + {/each} + +
+ + + Last + + + + Wind + Solar + + + + Residuallast (Lücke) + +
+
+{/if} diff --git a/src/lib/strommix.ts b/src/lib/strommix.ts index 1f469c9..6269066 100644 --- a/src/lib/strommix.ts +++ b/src/lib/strommix.ts @@ -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 | 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 } | 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; }; diff --git a/src/routes/api/strommix/+server.ts b/src/routes/api/strommix/+server.ts index 3063a94..a2fc321 100644 --- a/src/routes/api/strommix/+server.ts +++ b/src/routes/api/strommix/+server.ts @@ -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 = { at: number; data: TData }; +type HistoryResponse = { + collection: string; + environment: string; + total: number; + returned: number; + items: HistoryItem[]; +}; + +/** + * 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( + collection: string, + fromUnix: number, + toUnix: number, + fetcher: typeof fetch, + limit = 5000 +): Promise | 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; + } 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[]): ResidualLoadPoint[] { + const dayStartSec = (() => { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return Math.floor(d.getTime() / 1000); + })(); + + const merged = new Map(); + 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[]): number { + const seen = new Set(); + 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 } { + const slotDuration = new Map(); // slot → duration in hours + const byCountry: Record = {}; + const counted = new Map>(); // country → slots already counted + + const COUNTRY_CODE: Record = { + 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(); + 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[] +): SlotSample[] { + const merged = new Map(); + 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[], + thresholdPct: number, + minDurationH: number, + topN: number, + nowUnix: number +): { last: ReturnType | null; worst: ReturnType[] } { + 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("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( + "strommix_history_de", + dayStartSec, + nowSecAtRequest, + fetch + ), + fetchHistorySeries( + "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 30–60 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), {