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:
@@ -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}
|
||||
|
||||
<!-- 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
|
||||
kann. Werte sind aufeinander abgestimmt (gleicher SMARD-15-min-Slot). -->
|
||||
{#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}
|
||||
Reference in New Issue
Block a user