Files
windwiderstand/src/lib/components/blocks/strommix/CapacityFactorBars.svelte
T
Peter Meier 88514b065a 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).
2026-05-07 13:06:30 +02:00

130 lines
4.1 KiB
Svelte

<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>