96cd9e9c57
Audit at 22:15 MESZ flagged the conventional sum (lignite + hard coal + gas + nuclear) as 19.1 GW in the widget vs 15.65 GW in reality. The breakdown showed the gap was Energy-Charts' `Fossil gas` (6.9 GW) which bundles natural gas + GuD + similar, while SMARD's `Erdgas` filter 4069 (3.8 GW) tracks just natural gas the way the widget label implies. SMARD is the canonical German grid feed; switching the primary source resolves the discrepancy directly. - New `lib/smard.ts` fetches the 11 relevant filter IDs in parallel: conventional (1223/4070/4069/1224), wind (4067/1225), solar (1226), hydro (1228), biomass (4066), other-conventional + pumped storage (1227/4071, surfaced separately), load (410). Two-step protocol (index.json → archive.json) handled inside; output is MW (MWh × 4) aligned to one common slot timestamp so every value on screen comes from the same point in time. - Aggregator route uses SMARD for production + load, Energy-Charts only for cross-border saldo + day-ahead prices (where SMARD has no convenient single-call endpoint). `dataSource: "smard" | "energy-charts"` flag travels in the response so the widget can attribute the numbers and fall back gracefully when SMARD is down. - StrommixBlock gets a "Werte prüfen" disclosure that lists every component (wind onshore + offshore, solar, the four conventional buckets, biomass, hydro, load) with its MW value and the slot timestamp + data source. Fact-check users can verify the addition themselves now without asking. Verified live: SMARD @ 21:00 UTC = lignite 8484 + hard coal 3480 + gas 3811 + nuclear 0 = 15.78 GW conventional. Matches the user's ground-truth 15.65 GW within sampling noise.
190 lines
6.2 KiB
TypeScript
190 lines
6.2 KiB
TypeScript
/**
|
||
* Aggregator route for the Strommix-Live-Widget.
|
||
*
|
||
* Production + load come from **SMARD** (smard.de) — the canonical
|
||
* German grid feed. Cross-border saldo + day-ahead prices come from
|
||
* Energy-Charts (api.energy-charts.info) where SMARD has no convenient
|
||
* single-call endpoint.
|
||
*
|
||
* Why split sources:
|
||
* - SMARD's `Erdgas` (filter 4069) is just natural gas. Energy-Charts'
|
||
* `Fossil gas` bundles natural gas + GuD + similar — values diverge
|
||
* by 2–3 GW. The audit user pointed at SMARD as ground truth.
|
||
* - SMARD has 15-min slot data with explicit timestamps; we align all
|
||
* filters to the same slot so every number on screen comes from one
|
||
* point in time.
|
||
* - Energy-Charts wins for cross-border + prices since SMARD's
|
||
* flow + price endpoints need extra plumbing we don't need here.
|
||
*
|
||
* Every value on the response carries its measurement timestamp so the
|
||
* widget can render "Stand HH:MM" + "verzögert" hints when an upstream
|
||
* lags.
|
||
*/
|
||
|
||
import type { RequestHandler } from "./$types";
|
||
import { env } from "$env/dynamic/public";
|
||
import type { StrommixResponse } from "$lib/strommix";
|
||
import { fetchSmardSnapshot, type SmardSnapshot } from "$lib/smard";
|
||
|
||
const CMS_BASE = (
|
||
env.PUBLIC_CMS_URL ||
|
||
import.meta.env.PUBLIC_CMS_URL ||
|
||
"http://localhost:3000"
|
||
).replace(/\/$/, "");
|
||
|
||
type CrossborderRaw = {
|
||
flow_at_gw?: number | null;
|
||
flow_be_gw?: number | null;
|
||
flow_ch_gw?: number | null;
|
||
flow_cz_gw?: number | null;
|
||
flow_dk_gw?: number | null;
|
||
flow_fr_gw?: number | null;
|
||
flow_lu_gw?: number | null;
|
||
flow_nl_gw?: number | null;
|
||
flow_no_gw?: number | null;
|
||
flow_pl_gw?: number | null;
|
||
flow_se_gw?: number | null;
|
||
measured_at_unix?: number | null;
|
||
};
|
||
|
||
function num(x: unknown): number {
|
||
return typeof x === "number" && Number.isFinite(x) ? x : 0;
|
||
}
|
||
|
||
async function fetchOne<T>(
|
||
collection: string,
|
||
fetcher: typeof fetch
|
||
): Promise<{ data: T | null; stale: boolean }> {
|
||
try {
|
||
const res = await fetcher(`${CMS_BASE}/api/content/${collection}/current`, {
|
||
signal: AbortSignal.timeout(8_000),
|
||
});
|
||
if (!res.ok) return { data: null, stale: false };
|
||
const stale = res.headers.get("x-rustycms-external-stale") === "1";
|
||
const data = (await res.json()) as T;
|
||
return { data, stale };
|
||
} catch {
|
||
return { data: null, stale: false };
|
||
}
|
||
}
|
||
|
||
type PriceArrayResp = {
|
||
unix_seconds?: number[];
|
||
price?: (number | null)[];
|
||
};
|
||
|
||
/**
|
||
* Pick the day-ahead price slot whose start ≤ now. Energy-Charts
|
||
* returns 96 slots covering the day; the last cell is typically a
|
||
* future slot.
|
||
*/
|
||
async function fetchPriceAtNow(
|
||
bzn: string,
|
||
fetcher: typeof fetch
|
||
): Promise<{ price: number | null; slotUnix: number | null; stale: boolean }> {
|
||
try {
|
||
const res = await fetcher(
|
||
`https://api.energy-charts.info/price?bzn=${encodeURIComponent(bzn)}`,
|
||
{ signal: AbortSignal.timeout(8_000) }
|
||
);
|
||
if (!res.ok) return { price: null, slotUnix: null, stale: false };
|
||
const data = (await res.json()) as PriceArrayResp;
|
||
const times = data.unix_seconds ?? [];
|
||
const prices = data.price ?? [];
|
||
if (times.length === 0 || prices.length === 0) {
|
||
return { price: null, slotUnix: null, stale: false };
|
||
}
|
||
const nowSec = Math.floor(Date.now() / 1000);
|
||
let pickIdx = -1;
|
||
for (let i = 0; i < times.length; i++) {
|
||
if (times[i] <= nowSec) pickIdx = i;
|
||
else break;
|
||
}
|
||
if (pickIdx < 0) return { price: null, slotUnix: null, stale: false };
|
||
const value = prices[pickIdx];
|
||
return {
|
||
price: typeof value === "number" && Number.isFinite(value) ? value : null,
|
||
slotUnix: times[pickIdx] ?? null,
|
||
stale: false,
|
||
};
|
||
} catch {
|
||
return { price: null, slotUnix: null, stale: true };
|
||
}
|
||
}
|
||
|
||
export const GET: RequestHandler = async ({ fetch }) => {
|
||
const [smard, cross, priceDe, priceFr] = await Promise.all([
|
||
fetchSmardSnapshot(fetch),
|
||
fetchOne<CrossborderRaw>("crossborder_de", fetch),
|
||
fetchPriceAtNow("DE-LU", fetch),
|
||
fetchPriceAtNow("FR", fetch),
|
||
]);
|
||
|
||
// Production breakdown — SMARD as primary. Each filter is in MW
|
||
// already (SMARD MWh × 4).
|
||
const v: SmardSnapshot["values"] = smard?.values ?? {
|
||
lignite: 0, hardCoal: 0, gas: 0, nuclear: 0,
|
||
windOnshore: 0, windOffshore: 0, solar: 0,
|
||
hydro: 0, biomass: 0,
|
||
otherConventional: 0, pumpedStorage: 0, load: 0,
|
||
};
|
||
|
||
// Conventional, the way the widget label spells it: lignite + hard
|
||
// coal + gas + nuclear. Pumped-storage + biomass + run-of-river
|
||
// hydro are NOT included (different categories or already-stored
|
||
// energy that would double-count).
|
||
const conventionalMw = v.lignite + v.hardCoal + v.gas + v.nuclear;
|
||
const weatherRenewableMw = v.windOnshore + v.windOffshore + v.solar;
|
||
|
||
const c = cross.data ?? {};
|
||
const flowsByCountry: Record<string, number> = {
|
||
AT: num(c.flow_at_gw),
|
||
BE: num(c.flow_be_gw),
|
||
CH: num(c.flow_ch_gw),
|
||
CZ: num(c.flow_cz_gw),
|
||
DK: num(c.flow_dk_gw),
|
||
FR: num(c.flow_fr_gw),
|
||
LU: num(c.flow_lu_gw),
|
||
NL: num(c.flow_nl_gw),
|
||
NO: num(c.flow_no_gw),
|
||
PL: num(c.flow_pl_gw),
|
||
SE: num(c.flow_se_gw),
|
||
};
|
||
const netFlowGw = Object.values(flowsByCountry).reduce((a, b) => a + b, 0);
|
||
|
||
const body: StrommixResponse = {
|
||
measuredAtUnix: smard?.measuredAtUnix ?? null,
|
||
dataSource: smard ? "smard" : "energy-charts",
|
||
windOnshoreMw: v.windOnshore,
|
||
windOffshoreMw: v.windOffshore,
|
||
solarMw: v.solar,
|
||
biomassMw: v.biomass,
|
||
hydroMw: v.hydro,
|
||
ligniteMw: v.lignite,
|
||
hardCoalMw: v.hardCoal,
|
||
gasMw: v.gas,
|
||
nuclearMw: v.nuclear,
|
||
loadMw: v.load,
|
||
weatherRenewableMw,
|
||
conventionalMw,
|
||
netFlowGw,
|
||
crossborderMeasuredAtUnix: c.measured_at_unix ?? null,
|
||
flowsByCountry,
|
||
priceDeEurMwh: priceDe.price,
|
||
priceFrEurMwh: priceFr.price,
|
||
priceMeasuredAtUnix: priceDe.slotUnix,
|
||
staleStrommix: smard == null,
|
||
staleCrossborder: cross.stale,
|
||
stalePrice: priceDe.stale || priceFr.stale,
|
||
};
|
||
|
||
return new Response(JSON.stringify(body), {
|
||
headers: {
|
||
"content-type": "application/json",
|
||
// 5 min ≈ SMARD update cadence. Browser/CDN hold the response that
|
||
// long; SvelteKit re-fetches afterwards.
|
||
"cache-control": "public, max-age=300, s-maxage=300",
|
||
},
|
||
});
|
||
};
|