88514b065a
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).
200 lines
6.3 KiB
Svelte
200 lines
6.3 KiB
Svelte
<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}
|