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:
@@ -362,6 +362,21 @@ export interface LiveStrommixBlockData {
|
|||||||
disclaimer_threshold_eur_mwh?: number;
|
disclaimer_threshold_eur_mwh?: number;
|
||||||
/** Reference (resolved: { _slug, _type, ...page-fields } or just slug string). */
|
/** Reference (resolved: { _slug, _type, ...page-fields } or just slug string). */
|
||||||
explanation_page?: string | { _slug?: string; title?: 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;
|
layout?: BlockLayout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,11 @@
|
|||||||
import { useTranslate, T } from "$lib/translations";
|
import { useTranslate, T } from "$lib/translations";
|
||||||
import Icon from "@iconify/svelte";
|
import Icon from "@iconify/svelte";
|
||||||
import "$lib/iconify-offline";
|
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();
|
let { block }: { block: LiveStrommixBlockData } = $props();
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
@@ -467,6 +472,77 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Capacity-factor bars: live vs. installiert. Nur full-Variante; in
|
||||||
|
compact wäre's optisch zu dicht. Renders nur, wenn Stammdaten
|
||||||
|
gesetzt sind. -->
|
||||||
|
{#if live && !isCompact && (block.installed_wind_onshore_gw || block.installed_solar_gw)}
|
||||||
|
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
|
||||||
|
<CapacityFactorBars
|
||||||
|
windOnshoreLiveMw={live.windOnshoreMw}
|
||||||
|
windOffshoreLiveMw={live.windOffshoreMw}
|
||||||
|
solarLiveMw={live.solarMw}
|
||||||
|
installedWindOnshoreGw={block.installed_wind_onshore_gw ?? 0}
|
||||||
|
installedWindOffshoreGw={block.installed_wind_offshore_gw ?? 0}
|
||||||
|
installedSolarGw={block.installed_solar_gw ?? 0}
|
||||||
|
installedAsOf={block.installed_as_of}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Residuallast-Kurve heute: Last minus Wind+Solar. Nur full-Variante,
|
||||||
|
und nur wenn der Aggregator Historien-Daten geliefert hat (CMS mit
|
||||||
|
history-mode-Schema im Hintergrund — sonst null). -->
|
||||||
|
{#if live && !isCompact && live.todayHourly && live.todayHourly.length > 0}
|
||||||
|
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
|
||||||
|
<h4 class="text-sm font-semibold text-stein-800">
|
||||||
|
Residuallast heute
|
||||||
|
</h4>
|
||||||
|
<p class="mt-0.5 text-[11px] text-stein-600">
|
||||||
|
Last minus Wind+Solar im Tagesverlauf. Die schraffierte Lücke
|
||||||
|
füllen konventionelle Kraftwerke und Importe.
|
||||||
|
</p>
|
||||||
|
<div class="mt-2">
|
||||||
|
<ResidualLoadChart points={live.todayHourly} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Dunkelflaute-Counter: letzte Phase + Top-5 Liste. Nur full-Variante.
|
||||||
|
Renders nur, wenn Aggregator History-Daten + qualifying runs hat. -->
|
||||||
|
{#if live && !isCompact && (live.lastDunkelflaute || (live.worstDunkelflauten && live.worstDunkelflauten.length > 0))}
|
||||||
|
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
|
||||||
|
<DunkelflauteCounter
|
||||||
|
last={live.lastDunkelflaute}
|
||||||
|
worst={live.worstDunkelflauten}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Negativpreis- + EinsMan-Counter. Nur full. Renders wenn entweder
|
||||||
|
History-Aggregat oder hand-gepflegte EinsMan-Zahl vorhanden. -->
|
||||||
|
{#if live && !isCompact && (live.negPriceHoursYtd != null || typeof block.einsman_costs_ytd_eur_mio === "number")}
|
||||||
|
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
|
||||||
|
<NegPriceCounter
|
||||||
|
negHoursYtd={live.negPriceHoursYtd}
|
||||||
|
einsmanEurMio={block.einsman_costs_ytd_eur_mio ?? null}
|
||||||
|
einsmanAsOf={block.einsman_costs_as_of}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Cross-Border-Bilanz: heute + YTD pro Land. Nur full, nur wenn
|
||||||
|
History-Daten da sind. -->
|
||||||
|
{#if live && !isCompact && (live.crossborderTodayGwh || live.netCrossborderGwhYtd != null)}
|
||||||
|
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
|
||||||
|
<FlowBalanceTodayYtd
|
||||||
|
today={live.crossborderTodayGwh}
|
||||||
|
ytd={live.netCrossborderGwhYtd != null && live.crossborderByCountryGwhYtd
|
||||||
|
? { total: live.netCrossborderGwhYtd, perCountry: live.crossborderByCountryGwhYtd }
|
||||||
|
: null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Production breakdown: aufklappbar damit user die Summe nachvollziehen
|
<!-- Production breakdown: aufklappbar damit user die Summe nachvollziehen
|
||||||
kann. Werte sind aufeinander abgestimmt (gleicher SMARD-15-min-Slot). -->
|
kann. Werte sind aufeinander abgestimmt (gleicher SMARD-15-min-Slot). -->
|
||||||
{#if live}
|
{#if live}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* "Live vs. installiert"-Bargraph für Wind onshore, Wind offshore und
|
||||||
|
* Solar. Kernaussage: das Schild auf dem Mast (installierte Leistung)
|
||||||
|
* ist nicht das, was zu jedem Zeitpunkt rauskommt.
|
||||||
|
*/
|
||||||
|
type Props = {
|
||||||
|
windOnshoreLiveMw: number;
|
||||||
|
windOffshoreLiveMw: number;
|
||||||
|
solarLiveMw: number;
|
||||||
|
installedWindOnshoreGw: number;
|
||||||
|
installedWindOffshoreGw: number;
|
||||||
|
installedSolarGw: number;
|
||||||
|
installedAsOf?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
windOnshoreLiveMw,
|
||||||
|
windOffshoreLiveMw,
|
||||||
|
solarLiveMw,
|
||||||
|
installedWindOnshoreGw,
|
||||||
|
installedWindOffshoreGw,
|
||||||
|
installedSolarGw,
|
||||||
|
installedAsOf,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
label: string;
|
||||||
|
liveMw: number;
|
||||||
|
installedGw: number;
|
||||||
|
fillClass: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows: Row[] = $derived([
|
||||||
|
{
|
||||||
|
label: "Wind onshore",
|
||||||
|
liveMw: windOnshoreLiveMw,
|
||||||
|
installedGw: installedWindOnshoreGw,
|
||||||
|
fillClass: "bg-himmel-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Wind offshore",
|
||||||
|
liveMw: windOffshoreLiveMw,
|
||||||
|
installedGw: installedWindOffshoreGw,
|
||||||
|
fillClass: "bg-himmel-700",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Solar",
|
||||||
|
liveMw: solarLiveMw,
|
||||||
|
installedGw: installedSolarGw,
|
||||||
|
fillClass: "bg-erde-400",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
function pct(liveMw: number, installedGw: number): number {
|
||||||
|
if (installedGw <= 0) return 0;
|
||||||
|
return (liveMw / (installedGw * 1000)) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPct(p: number): string {
|
||||||
|
return (
|
||||||
|
p.toLocaleString("de-DE", {
|
||||||
|
maximumFractionDigits: p < 10 ? 1 : 0,
|
||||||
|
}) + " %"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtGw(mw: number): string {
|
||||||
|
return (
|
||||||
|
(mw / 1000).toLocaleString("de-DE", {
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}) + " GW"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<header class="flex flex-col gap-0.5">
|
||||||
|
<h4 class="text-sm font-semibold text-stein-800">
|
||||||
|
Auslastung jetzt vs. installiert
|
||||||
|
</h4>
|
||||||
|
{#if installedAsOf}
|
||||||
|
<span class="text-[11px] text-stein-500">
|
||||||
|
Installierte Leistung: Stand {installedAsOf},
|
||||||
|
Marktstammdatenregister
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ul class="m-0! flex list-none flex-col gap-2.5 p-0">
|
||||||
|
{#each rows as row (row.label)}
|
||||||
|
{@const p = pct(row.liveMw, row.installedGw)}
|
||||||
|
<li class="flex flex-col gap-1">
|
||||||
|
<div class="flex justify-between text-xs">
|
||||||
|
<span class="font-medium text-stein-700">{row.label}</span>
|
||||||
|
<span class="tabular-nums text-stein-700">
|
||||||
|
{fmtGw(row.liveMw)}
|
||||||
|
<span class="text-stein-500">
|
||||||
|
/ {row.installedGw.toLocaleString("de-DE", {
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
})} GW
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="relative h-2 overflow-hidden rounded-full bg-stein-200"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
aria-valuenow={Math.round(p)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full transition-[width] duration-500 {row.fillClass}"
|
||||||
|
style="width: {Math.min(100, p)}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-xs tabular-nums">
|
||||||
|
<span class="font-semibold text-stein-800">{fmtPct(p)}</span
|
||||||
|
>
|
||||||
|
<span class="text-stein-500">
|
||||||
|
{Math.max(0, 100 - p).toLocaleString("de-DE", {
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
})} % nicht abgerufen
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { DunkelflautePhase } from "$lib/strommix";
|
||||||
|
|
||||||
|
/** Letzte Dunkelflaute-Phase + Top-N-Liste der schlimmsten Phasen. */
|
||||||
|
type Props = {
|
||||||
|
last: DunkelflautePhase | null;
|
||||||
|
worst: DunkelflautePhase[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { last, worst }: Props = $props();
|
||||||
|
|
||||||
|
function fmtDate(unix: number): string {
|
||||||
|
const d = new Date(unix * 1000);
|
||||||
|
return d.toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(unix: number): string {
|
||||||
|
const d = new Date(unix * 1000);
|
||||||
|
return d.toLocaleTimeString("de-DE", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(h: number): string {
|
||||||
|
if (h < 1) return `${Math.round(h * 60)} min`;
|
||||||
|
if (h < 24)
|
||||||
|
return `${h.toLocaleString("de-DE", { maximumFractionDigits: 1 })} h`;
|
||||||
|
const days = Math.floor(h / 24);
|
||||||
|
const rem = Math.round(h - days * 24);
|
||||||
|
return rem > 0 ? `${days} Tg ${rem} h` : `${days} Tg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtAgo(hoursAgo: number): string {
|
||||||
|
if (hoursAgo < 1) return "läuft jetzt";
|
||||||
|
if (hoursAgo < 24) return `vor ${Math.round(hoursAgo)} h`;
|
||||||
|
const days = Math.round(hoursAgo / 24);
|
||||||
|
return `vor ${days} ${days === 1 ? "Tag" : "Tagen"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPct(pct: number): string {
|
||||||
|
return pct.toLocaleString("de-DE", { maximumFractionDigits: 1 }) + " %";
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasLast = $derived(last != null);
|
||||||
|
const hasWorst = $derived((worst?.length ?? 0) > 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if hasLast || hasWorst}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<header class="flex flex-col gap-0.5">
|
||||||
|
<h4 class="text-sm font-semibold text-stein-800">
|
||||||
|
Dunkelflauten in den letzten 12 Monaten
|
||||||
|
</h4>
|
||||||
|
<p class="m-0 text-[11px] text-stein-600">
|
||||||
|
Phase mit Wind + Solar unter 10 % der Last.
|
||||||
|
Genau die Stunden, die Wind- und Solarzubau alleine nicht
|
||||||
|
füllen können.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if last}
|
||||||
|
<div class="rounded-xs border-l-4 border-fire-500 bg-fire-50 px-3 py-2.5">
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<span class="text-[10px] uppercase tracking-wide text-stein-500">
|
||||||
|
Letzte Phase
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-semibold text-fire-700">
|
||||||
|
{fmtAgo(last.endedHoursAgo)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul class="mt-1.5 m-0 grid list-none grid-cols-3 gap-x-3 gap-y-1 p-0 sm:grid-cols-3">
|
||||||
|
<li class="flex flex-col">
|
||||||
|
<span class="text-[10px] text-stein-500">Beginn</span>
|
||||||
|
<span class="text-xs font-semibold tabular-nums text-stein-800">
|
||||||
|
{fmtDate(last.startUnix)}, {fmtTime(last.startUnix)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex flex-col">
|
||||||
|
<span class="text-[10px] text-stein-500">Dauer</span>
|
||||||
|
<span class="text-xs font-semibold tabular-nums text-stein-800">
|
||||||
|
{fmtDuration(last.durationH)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex flex-col">
|
||||||
|
<span class="text-[10px] text-stein-500">Tiefster Wert</span>
|
||||||
|
<span class="text-xs font-semibold tabular-nums text-stein-800">
|
||||||
|
{fmtPct(last.minPct)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if hasWorst && worst}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-[10px] uppercase tracking-wide text-stein-500">
|
||||||
|
Längste Phasen
|
||||||
|
</span>
|
||||||
|
<ol class="m-0 flex list-none flex-col p-0">
|
||||||
|
{#each worst as phase, i (phase.startUnix)}
|
||||||
|
<li
|
||||||
|
class="grid grid-cols-[2rem_6rem_1fr_auto] gap-2 border-t border-stein-200 py-1 text-xs tabular-nums first:border-t-0"
|
||||||
|
>
|
||||||
|
<span class="text-stein-500">#{i + 1}</span>
|
||||||
|
<span class="text-stein-700">{fmtDate(phase.startUnix)}</span>
|
||||||
|
<span class="font-semibold text-stein-800">
|
||||||
|
{fmtDuration(phase.durationH)}
|
||||||
|
</span>
|
||||||
|
<span class="text-stein-500">
|
||||||
|
min {fmtPct(phase.minPct)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Import-/Export-Bilanz heute + YTD pro Nachbarland.
|
||||||
|
*
|
||||||
|
* Sign convention beim Aggregator: positiv = Export aus DE, negativ =
|
||||||
|
* Import nach DE.
|
||||||
|
*/
|
||||||
|
type Props = {
|
||||||
|
today: { total: number; perCountry: Record<string, number> } | null;
|
||||||
|
ytd: { total: number; perCountry: Record<string, number> } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { today, ytd }: Props = $props();
|
||||||
|
|
||||||
|
const COUNTRY_LABELS: Record<string, string> = {
|
||||||
|
AT: "Österreich",
|
||||||
|
BE: "Belgien",
|
||||||
|
CH: "Schweiz",
|
||||||
|
CZ: "Tschechien",
|
||||||
|
DK: "Dänemark",
|
||||||
|
FR: "Frankreich",
|
||||||
|
LU: "Luxemburg",
|
||||||
|
NL: "Niederlande",
|
||||||
|
NO: "Norwegen",
|
||||||
|
PL: "Polen",
|
||||||
|
SE: "Schweden",
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtGwh(gwh: number): string {
|
||||||
|
const abs = Math.abs(gwh);
|
||||||
|
if (abs >= 1_000) {
|
||||||
|
return (
|
||||||
|
(gwh / 1000).toLocaleString("de-DE", {
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
signDisplay: "exceptZero",
|
||||||
|
}) + " TWh"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
gwh.toLocaleString("de-DE", {
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
signDisplay: "exceptZero",
|
||||||
|
}) + " GWh"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function direction(gwh: number): {
|
||||||
|
label: string;
|
||||||
|
tone: "import" | "export" | "neutral";
|
||||||
|
} {
|
||||||
|
if (gwh > 0) return { label: "Saldo Export", tone: "export" };
|
||||||
|
if (gwh < 0) return { label: "Saldo Import", tone: "import" };
|
||||||
|
return { label: "ausgeglichen", tone: "neutral" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toneClass(tone: "import" | "export" | "neutral"): string {
|
||||||
|
if (tone === "import") return "border-fire-500 bg-fire-50";
|
||||||
|
if (tone === "export") return "border-wald-500 bg-wald-50";
|
||||||
|
return "border-stein-300 bg-stein-50";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortedRows(
|
||||||
|
perCountry: Record<string, number>,
|
||||||
|
): { code: string; gwh: number }[] {
|
||||||
|
return Object.entries(perCountry)
|
||||||
|
.map(([code, gwh]) => ({ code, gwh }))
|
||||||
|
.sort((a, b) => Math.abs(b.gwh) - Math.abs(a.gwh));
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearLabel = $derived(new Date().getFullYear().toString());
|
||||||
|
const visible = $derived(today != null || ytd != null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if visible}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<header class="flex flex-col gap-0.5">
|
||||||
|
<h4 class="text-sm font-semibold text-stein-800">
|
||||||
|
Stromhandel mit Nachbarn
|
||||||
|
</h4>
|
||||||
|
<p class="m-0 text-[11px] text-stein-600">
|
||||||
|
Saldo positiv = Deutschland exportiert, negativ = importiert.
|
||||||
|
Bei Dunkelflauten füllen vor allem Frankreich und Skandinavien
|
||||||
|
die Lücke.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
{#if today}
|
||||||
|
{@const dir = direction(today.total)}
|
||||||
|
<div class="rounded-xs border-l-4 px-3 py-2.5 {toneClass(dir.tone)}">
|
||||||
|
<span class="text-[10px] uppercase tracking-wide text-stein-500">
|
||||||
|
Heute
|
||||||
|
</span>
|
||||||
|
<div class="mt-0.5 flex items-baseline justify-between gap-2">
|
||||||
|
<span class="text-xl font-bold tabular-nums text-stein-900">
|
||||||
|
{fmtGwh(today.total)}
|
||||||
|
</span>
|
||||||
|
<span class="text-[11px] text-stein-600">{dir.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if ytd}
|
||||||
|
{@const dir = direction(ytd.total)}
|
||||||
|
<div class="rounded-xs border-l-4 px-3 py-2.5 {toneClass(dir.tone)}">
|
||||||
|
<span class="text-[10px] uppercase tracking-wide text-stein-500">
|
||||||
|
{yearLabel} YTD
|
||||||
|
</span>
|
||||||
|
<div class="mt-0.5 flex items-baseline justify-between gap-2">
|
||||||
|
<span class="text-xl font-bold tabular-nums text-stein-900">
|
||||||
|
{fmtGwh(ytd.total)}
|
||||||
|
</span>
|
||||||
|
<span class="text-[11px] text-stein-600">{dir.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if ytd}
|
||||||
|
<details class="text-xs">
|
||||||
|
<summary class="cursor-pointer text-stein-600 hover:text-stein-900">
|
||||||
|
Aufschlüsselung nach Nachbarland (YTD)
|
||||||
|
</summary>
|
||||||
|
<ul class="m-0 mt-1.5 list-none p-0">
|
||||||
|
{#each sortedRows(ytd.perCountry) as row (row.code)}
|
||||||
|
{@const isImport = row.gwh < 0}
|
||||||
|
{@const isExport = !isImport && row.gwh !== 0}
|
||||||
|
<li
|
||||||
|
class="grid grid-cols-[1fr_auto_4rem] gap-2 border-t border-stein-200 py-1 tabular-nums first:border-t-0"
|
||||||
|
>
|
||||||
|
<span class="text-stein-700">
|
||||||
|
{COUNTRY_LABELS[row.code] ?? row.code}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="font-semibold {isImport
|
||||||
|
? 'text-fire-700'
|
||||||
|
: isExport
|
||||||
|
? 'text-wald-700'
|
||||||
|
: 'text-stein-700'}"
|
||||||
|
>
|
||||||
|
{fmtGwh(row.gwh)}
|
||||||
|
</span>
|
||||||
|
<span class="text-right text-[11px] text-stein-500">
|
||||||
|
{isImport ? "Import" : isExport ? "Export" : "·"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/** Stunden mit negativem Day-Ahead-Preis YTD + manuell gepflegte
|
||||||
|
* EinsMan-Entschädigungssumme aus dem Block. */
|
||||||
|
type Props = {
|
||||||
|
negHoursYtd: number | null;
|
||||||
|
einsmanEurMio: number | null;
|
||||||
|
einsmanAsOf: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { negHoursYtd, einsmanEurMio, einsmanAsOf }: Props = $props();
|
||||||
|
|
||||||
|
function fmtNum(n: number, fractionDigits = 0): string {
|
||||||
|
return n.toLocaleString("de-DE", {
|
||||||
|
minimumFractionDigits: fractionDigits,
|
||||||
|
maximumFractionDigits: fractionDigits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const showNeg = $derived(typeof negHoursYtd === "number");
|
||||||
|
const showEinsman = $derived(typeof einsmanEurMio === "number");
|
||||||
|
const visible = $derived(showNeg || showEinsman);
|
||||||
|
|
||||||
|
const yearLabel = $derived(new Date().getFullYear().toString());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if visible}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<header class="flex flex-col gap-0.5">
|
||||||
|
<h4 class="text-sm font-semibold text-stein-800">
|
||||||
|
Überschussstrom & Abregelung {yearLabel}
|
||||||
|
</h4>
|
||||||
|
<p class="m-0 text-[11px] text-stein-600">
|
||||||
|
Hohe Wind- und Solarspitzen drücken den Strompreis, bei
|
||||||
|
Netzengpässen werden Anlagen abgeregelt — und entschädigt.
|
||||||
|
Beides Folgekosten der bisherigen Zubaupolitik.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
{#if showNeg && negHoursYtd != null}
|
||||||
|
<div class="rounded-xs border-l-4 border-fire-400 bg-white px-3 py-2.5 shadow-sm">
|
||||||
|
<div class="flex items-baseline gap-1.5">
|
||||||
|
<span class="text-2xl font-bold tabular-nums text-stein-900">
|
||||||
|
{fmtNum(negHoursYtd)}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-stein-600">Stunden</span>
|
||||||
|
</div>
|
||||||
|
<span class="mt-0.5 block text-[11px] leading-snug text-stein-600">
|
||||||
|
mit negativem Day-Ahead-Preis
|
||||||
|
<span class="text-stein-400">(DE-LU)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showEinsman && einsmanEurMio != null}
|
||||||
|
<div class="rounded-xs border-l-4 border-fire-400 bg-white px-3 py-2.5 shadow-sm">
|
||||||
|
<div class="flex items-baseline gap-1.5">
|
||||||
|
<span class="text-2xl font-bold tabular-nums text-stein-900">
|
||||||
|
{fmtNum(einsmanEurMio, 0)}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-stein-600">Mio €</span>
|
||||||
|
</div>
|
||||||
|
<span class="mt-0.5 block text-[11px] leading-snug text-stein-600">
|
||||||
|
Entschädigung Abregelung (§ 13 EnWG, § 51 EEG)
|
||||||
|
{#if einsmanAsOf}
|
||||||
|
<span class="block text-[10px] text-stein-400">
|
||||||
|
Stand {einsmanAsOf}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ResidualLoadPoint } from "$lib/strommix";
|
||||||
|
|
||||||
|
/** Today's per-slot Last + Wetter-Erneuerbare. */
|
||||||
|
let { points }: { points: ResidualLoadPoint[] } = $props();
|
||||||
|
|
||||||
|
type Layout = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
padX: number;
|
||||||
|
padY: number;
|
||||||
|
innerW: number;
|
||||||
|
innerH: number;
|
||||||
|
};
|
||||||
|
const layout: Layout = $derived.by(() => {
|
||||||
|
const width = 600;
|
||||||
|
const height = 180;
|
||||||
|
const padX = 36;
|
||||||
|
const padY = 16;
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
padX,
|
||||||
|
padY,
|
||||||
|
innerW: width - 2 * padX,
|
||||||
|
innerH: height - 2 * padY,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
type Range = { minX: number; maxX: number; maxY: number };
|
||||||
|
const range: Range = $derived.by(() => {
|
||||||
|
if (points.length === 0) {
|
||||||
|
return { minX: 0, maxX: 1, maxY: 1 };
|
||||||
|
}
|
||||||
|
const minX = points[0].unix;
|
||||||
|
const maxX = points[points.length - 1].unix;
|
||||||
|
const maxY = Math.max(
|
||||||
|
...points.map((p) => Math.max(p.loadMw, p.weatherRenewableMw)),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
return { minX, maxX, maxY };
|
||||||
|
});
|
||||||
|
|
||||||
|
function xOf(unix: number): number {
|
||||||
|
const span = range.maxX - range.minX || 1;
|
||||||
|
return layout.padX + ((unix - range.minX) / span) * layout.innerW;
|
||||||
|
}
|
||||||
|
function yOf(mw: number): number {
|
||||||
|
const span = range.maxY || 1;
|
||||||
|
return layout.padY + (1 - mw / span) * layout.innerH;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPath = $derived.by(() => {
|
||||||
|
if (points.length === 0) return "";
|
||||||
|
return points
|
||||||
|
.map(
|
||||||
|
(p, i) =>
|
||||||
|
`${i === 0 ? "M" : "L"} ${xOf(p.unix)} ${yOf(p.loadMw)}`,
|
||||||
|
)
|
||||||
|
.join(" ");
|
||||||
|
});
|
||||||
|
|
||||||
|
const renewPath = $derived.by(() => {
|
||||||
|
if (points.length === 0) return "";
|
||||||
|
return points
|
||||||
|
.map(
|
||||||
|
(p, i) =>
|
||||||
|
`${i === 0 ? "M" : "L"} ${xOf(p.unix)} ${yOf(p.weatherRenewableMw)}`,
|
||||||
|
)
|
||||||
|
.join(" ");
|
||||||
|
});
|
||||||
|
|
||||||
|
const gapPath = $derived.by(() => {
|
||||||
|
if (points.length === 0) return "";
|
||||||
|
const top = points
|
||||||
|
.map(
|
||||||
|
(p, i) =>
|
||||||
|
`${i === 0 ? "M" : "L"} ${xOf(p.unix)} ${yOf(p.loadMw)}`,
|
||||||
|
)
|
||||||
|
.join(" ");
|
||||||
|
const bottom = points
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((p) => `L ${xOf(p.unix)} ${yOf(p.weatherRenewableMw)}`)
|
||||||
|
.join(" ");
|
||||||
|
return `${top} ${bottom} Z`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ticks = $derived.by(() => {
|
||||||
|
if (points.length === 0) return [];
|
||||||
|
const result: { unix: number; label: string }[] = [];
|
||||||
|
const start = new Date(range.minX * 1000);
|
||||||
|
start.setMinutes(0, 0, 0);
|
||||||
|
const end = new Date(range.maxX * 1000);
|
||||||
|
for (let t = start.getTime(); t <= end.getTime(); t += 4 * 3_600_000) {
|
||||||
|
const d = new Date(t);
|
||||||
|
result.push({
|
||||||
|
unix: Math.floor(t / 1000),
|
||||||
|
label: d.getHours().toString().padStart(2, "0") + ":00",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const yTicks = $derived.by(() => {
|
||||||
|
const step = range.maxY > 60_000 ? 20_000 : 10_000;
|
||||||
|
const out: { mw: number; label: string }[] = [];
|
||||||
|
for (let mw = 0; mw <= range.maxY; mw += step) {
|
||||||
|
out.push({ mw, label: `${mw / 1000} GW` });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if points.length > 0}
|
||||||
|
<figure class="m-0">
|
||||||
|
<svg
|
||||||
|
class="block h-auto w-full font-sans"
|
||||||
|
viewBox="0 0 {layout.width} {layout.height}"
|
||||||
|
role="img"
|
||||||
|
aria-label="Residuallast heute: Last minus Wind und Solar im Tagesverlauf"
|
||||||
|
>
|
||||||
|
{#each yTicks as t (t.mw)}
|
||||||
|
<line
|
||||||
|
stroke="var(--color-stein-200)"
|
||||||
|
stroke-width="1"
|
||||||
|
x1={layout.padX}
|
||||||
|
x2={layout.width - layout.padX}
|
||||||
|
y1={yOf(t.mw)}
|
||||||
|
y2={yOf(t.mw)}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
fill="var(--color-stein-500)"
|
||||||
|
font-size="10"
|
||||||
|
text-anchor="end"
|
||||||
|
x={layout.padX - 4}
|
||||||
|
y={yOf(t.mw) + 3}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</text>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if gapPath}
|
||||||
|
<path d={gapPath} fill="var(--color-fire-200)" fill-opacity="0.55" stroke="none" />
|
||||||
|
{/if}
|
||||||
|
{#if renewPath}
|
||||||
|
<path
|
||||||
|
d={renewPath}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--color-wald-500)"
|
||||||
|
stroke-width="1.75"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if loadPath}
|
||||||
|
<path
|
||||||
|
d={loadPath}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--color-stein-800)"
|
||||||
|
stroke-width="1.75"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each ticks as t (t.unix)}
|
||||||
|
<line
|
||||||
|
stroke="var(--color-stein-200)"
|
||||||
|
stroke-width="1"
|
||||||
|
stroke-dasharray="2 3"
|
||||||
|
x1={xOf(t.unix)}
|
||||||
|
x2={xOf(t.unix)}
|
||||||
|
y1={layout.padY}
|
||||||
|
y2={layout.height - layout.padY}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
fill="var(--color-stein-500)"
|
||||||
|
font-size="10"
|
||||||
|
text-anchor="middle"
|
||||||
|
x={xOf(t.unix)}
|
||||||
|
y={layout.height - 2}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</text>
|
||||||
|
{/each}
|
||||||
|
</svg>
|
||||||
|
<figcaption class="mt-2 flex flex-wrap gap-3 text-xs text-stein-600">
|
||||||
|
<span class="inline-flex items-center gap-1.5">
|
||||||
|
<span class="inline-block size-3 rounded-sm bg-stein-800"></span>
|
||||||
|
Last
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center gap-1.5">
|
||||||
|
<span class="inline-block size-3 rounded-sm bg-wald-500"></span>
|
||||||
|
Wind + Solar
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center gap-1.5">
|
||||||
|
<span class="inline-block size-3 rounded-sm bg-fire-200"></span>
|
||||||
|
Residuallast (Lücke)
|
||||||
|
</span>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
{/if}
|
||||||
@@ -50,4 +50,62 @@ export type StrommixResponse = {
|
|||||||
staleStrommix: boolean;
|
staleStrommix: boolean;
|
||||||
staleCrossborder: boolean;
|
staleCrossborder: boolean;
|
||||||
stalePrice: 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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
import type { RequestHandler } from "./$types";
|
import type { RequestHandler } from "./$types";
|
||||||
import { env } from "$env/dynamic/public";
|
import { env } from "$env/dynamic/public";
|
||||||
import type { StrommixResponse } from "$lib/strommix";
|
import type { ResidualLoadPoint, StrommixResponse } from "$lib/strommix";
|
||||||
import { fetchSmardSnapshot } from "$lib/smard";
|
import { fetchSmardSnapshot } from "$lib/smard";
|
||||||
|
|
||||||
const CMS_BASE = (
|
const CMS_BASE = (
|
||||||
@@ -147,6 +147,295 @@ type PriceArrayResp = {
|
|||||||
price?: (number | null)[];
|
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
|
* Pick the day-ahead price slot whose start ≤ now. Energy-Charts
|
||||||
* returns 96 slots covering the day; the last cell is typically a
|
* 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 }) => {
|
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),
|
fetchSmardSnapshot(fetch),
|
||||||
// Energy-Charts /public_power direct (with explicit time range —
|
// Energy-Charts /public_power direct (with explicit time range —
|
||||||
// upstream now returns 404 without start/end). Used as per-field
|
// 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),
|
fetchOne<CrossborderRaw>("crossborder_de", fetch),
|
||||||
fetchPriceAtNow("DE-LU", fetch),
|
fetchPriceAtNow("DE-LU", fetch),
|
||||||
fetchPriceAtNow("FR", 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
|
// 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
|
// 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
|
// Energy-Charts entirely. 90 min lets typical 30–60 min SMARD lag
|
||||||
// still win.
|
// still win.
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = nowSecAtRequest;
|
||||||
const smard =
|
const smard =
|
||||||
smardRaw && nowSec - smardRaw.measuredAtUnix < 90 * 60 ? smardRaw : null;
|
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);
|
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 = {
|
const body: StrommixResponse = {
|
||||||
measuredAtUnix: smard?.measuredAtUnix ?? ec.measured_at_unix ?? null,
|
measuredAtUnix: smard?.measuredAtUnix ?? ec.measured_at_unix ?? null,
|
||||||
dataSource: !smard ? "energy-charts" : usedFallback ? "smard+energy-charts" : "smard",
|
dataSource: !smard ? "energy-charts" : usedFallback ? "smard+energy-charts" : "smard",
|
||||||
@@ -289,6 +644,15 @@ export const GET: RequestHandler = async ({ fetch }) => {
|
|||||||
staleStrommix: smard == null && !energyCharts.data,
|
staleStrommix: smard == null && !energyCharts.data,
|
||||||
staleCrossborder: cross.stale,
|
staleCrossborder: cross.stale,
|
||||||
stalePrice: priceDe.stale || priceFr.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), {
|
return new Response(JSON.stringify(body), {
|
||||||
|
|||||||
Reference in New Issue
Block a user