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