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:
@@ -23,7 +23,7 @@
|
||||
|
||||
import type { RequestHandler } from "./$types";
|
||||
import { env } from "$env/dynamic/public";
|
||||
import type { StrommixResponse } from "$lib/strommix";
|
||||
import type { ResidualLoadPoint, StrommixResponse } from "$lib/strommix";
|
||||
import { fetchSmardSnapshot } from "$lib/smard";
|
||||
|
||||
const CMS_BASE = (
|
||||
@@ -147,6 +147,295 @@ type PriceArrayResp = {
|
||||
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
|
||||
* 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 }) => {
|
||||
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),
|
||||
// Energy-Charts /public_power direct (with explicit time range —
|
||||
// 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),
|
||||
fetchPriceAtNow("DE-LU", 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
|
||||
@@ -206,7 +537,7 @@ export const GET: RequestHandler = async ({ fetch }) => {
|
||||
// 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
|
||||
// still win.
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const nowSec = nowSecAtRequest;
|
||||
const smard =
|
||||
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);
|
||||
|
||||
// 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 = {
|
||||
measuredAtUnix: smard?.measuredAtUnix ?? ec.measured_at_unix ?? null,
|
||||
dataSource: !smard ? "energy-charts" : usedFallback ? "smard+energy-charts" : "smard",
|
||||
@@ -289,6 +644,15 @@ export const GET: RequestHandler = async ({ fetch }) => {
|
||||
staleStrommix: smard == null && !energyCharts.data,
|
||||
staleCrossborder: cross.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), {
|
||||
|
||||
Reference in New Issue
Block a user