Files
windwiderstand/src/routes/api/strommix/+server.ts
T
Peter Meier 96cd9e9c57
Deploy / verify (push) Successful in 45s
Deploy / deploy (push) Successful in 49s
fix(strommix): SMARD as primary source — accuracy parity with audit
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.
2026-05-04 23:47:52 +02:00

190 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 23 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",
},
});
};