Files
Substrate/get-de-energy
svemagie e0e879d917 feat: add DE bundesAPI integrations — Lobbyregister, SMARD, Haushalt, DIP
Four new data sources and fetch scripts via bundesAPI community project:

- DS-00011 Lobbyregister: 6,799 registrants, €0.86–0.91B declared lobbying (FY2024)
- DS-00012 SMARD Strommarkt: 60.2% renewable 2024, Wind Onshore #1 (107 TWh)
- DS-00013 Bundeshaushalt: €474.75B Ist-Wert 2024, 38.2% Soziales, 10.6% Verteidigung
- DS-00014 DIP Bundestag: 7,605 Drucksachen WP21, 12,507 Vorgänge, 83 Plenarprotokolle

Each integration: live-data fetch script (bun/TypeScript) + DATASET-TEMPLATE
markdown + CSV outputs. Scripts idempotent — re-run for current data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 12:54:02 +02:00

196 lines
7.6 KiB
Plaintext
Executable File

#!/usr/bin/env bun
/**
* Get DE Energy Mix Data (SMARD / Bundesnetzagentur)
*
* Fetches monthly generation data from the SMARD API and produces:
* - Data/DE-Energy-Mix/energy-mix-latest.csv
*
* API: https://www.smard.de/app
* Source: https://www.smard.de/home/marktdaten
* No authentication required.
*
* Strategy:
* 1. For each generation filter, fetch the timestamp index.
* 2. Find the latest year where the series has >=10 non-null data points.
* 3. If no complete year exists (e.g. Kernenergie decommissioned April 2023),
* the source is assigned 0 MWh for the consensus year.
* 4. The "consensus year" is determined by the majority vote across all sources.
*/
// Bun on macOS lacks some CA certificates in its bundled cert store; this flag
// allows the fetch to proceed against the known-good government endpoint.
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
import { writeFileSync, mkdirSync } from "fs";
import { join } from "path";
const SMARD_BASE = "https://www.smard.de/app";
const REGION = "DE";
const RESOLUTION = "month";
const OUT_DIR = join(__dirname, "Data/DE-Energy-Mix");
// Generation filter codes (Stromerzeugung)
const FILTERS: { id: number; name_de: string; renewable: boolean }[] = [
{ id: 4067, name_de: "Wind Onshore", renewable: true },
{ id: 1225, name_de: "Wind Offshore", renewable: true },
{ id: 4068, name_de: "Photovoltaik", renewable: true },
{ id: 4066, name_de: "Biomasse", renewable: true },
{ id: 1226, name_de: "Wasserkraft", renewable: true },
{ id: 1228, name_de: "Sonstige Erneuerbare", renewable: true },
{ id: 1223, name_de: "Braunkohle", renewable: false },
{ id: 4069, name_de: "Steinkohle", renewable: false },
{ id: 4071, name_de: "Erdgas", renewable: false },
{ id: 1224, name_de: "Kernenergie", renewable: false },
{ id: 1227, name_de: "Sonstige Konventionelle", renewable: false },
];
// TypeScript interfaces for the SMARD API responses
interface IndexResponse {
timestamps: number[];
}
interface TimeseriesResponse {
meta_data: {
version: number;
created: number;
};
series: [number, number | null][];
}
interface FilterResult {
filter_id: number;
source_de: string;
renewable: boolean;
total_mwh: number;
year_found: number | null; // null = no complete year found (treated as 0)
}
async function fetchJson<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
return res.json() as Promise<T>;
}
/**
* Find the latest year with >=minNonNull non-null monthly data points.
* Returns the year and total MWh, or null if no complete year found.
*/
async function findLatestCompleteYear(
filterId: number,
timestamps: number[],
minNonNull = 10
): Promise<{ year: number; total_mwh: number } | null> {
// Try from newest to oldest annual timestamp
for (let i = timestamps.length - 1; i >= 0; i--) {
const ts = timestamps[i];
const url = `${SMARD_BASE}/chart_data/${filterId}/${REGION}/${filterId}_${REGION}_${RESOLUTION}_${ts}.json`;
const data = await fetchJson<TimeseriesResponse>(url);
const nonNullPoints = data.series.filter(([, v]) => v !== null);
if (nonNullPoints.length >= minNonNull) {
const year = new Date(ts).getUTCFullYear();
const total_mwh = data.series.reduce((acc, [, v]) => acc + (v ?? 0), 0);
return { year, total_mwh };
}
}
return null;
}
async function main() {
console.log("Fetching DE energy mix data from SMARD (Bundesnetzagentur)…\n");
mkdirSync(OUT_DIR, { recursive: true });
// Phase 1: fetch all filters, find each source's latest complete year
const rawResults: FilterResult[] = [];
for (const filter of FILTERS) {
const indexUrl = `${SMARD_BASE}/chart_data/${filter.id}/${REGION}/index_${RESOLUTION}.json`;
console.log(`Fetching ${filter.name_de} (${filter.id})…`);
const index = await fetchJson<IndexResponse>(indexUrl);
const result = await findLatestCompleteYear(filter.id, index.timestamps);
if (result) {
console.log(` → ${result.year}: ${(result.total_mwh / 1e6).toFixed(2)} TWh`);
rawResults.push({
filter_id: filter.id,
source_de: filter.name_de,
renewable: filter.renewable,
total_mwh: result.total_mwh,
year_found: result.year,
});
} else {
console.log(` → No complete year found — treating as 0 MWh`);
rawResults.push({
filter_id: filter.id,
source_de: filter.name_de,
renewable: filter.renewable,
total_mwh: 0,
year_found: null,
});
}
}
// Phase 2: determine consensus year (mode of year_found across active sources)
const yearCounts = new Map<number, number>();
for (const r of rawResults) {
if (r.year_found !== null) {
yearCounts.set(r.year_found, (yearCounts.get(r.year_found) ?? 0) + 1);
}
}
const consensusYear = [...yearCounts.entries()].sort((a, b) => b[1] - a[1])[0][0];
console.log(`\nConsensus year: ${consensusYear}`);
// Sources with a different year than consensus are zeroed out for consistency.
// Example: Kernenergie was decommissioned April 2023; its index only goes to 2022.
// In a 2024 mix, it contributes 0 MWh.
const results = rawResults.map((r) => {
if (r.year_found !== null && r.year_found !== consensusYear) {
console.log(` Note: ${r.source_de} latest complete year is ${r.year_found}, not ${consensusYear} — zeroing for consensus year consistency`);
return { ...r, total_mwh: 0, year_found: null };
}
return r;
});
// Phase 3: calculate shares based on total generation across all sources
const totalGeneration = results.reduce((s, r) => s + r.total_mwh, 0);
const renewableTotal = results.filter((r) => r.renewable).reduce((s, r) => s + r.total_mwh, 0);
const coalTotal = results
.filter((r) => r.source_de === "Braunkohle" || r.source_de === "Steinkohle")
.reduce((s, r) => s + r.total_mwh, 0);
const nuclearTotal = results.find((r) => r.source_de === "Kernenergie")?.total_mwh ?? 0;
const renewableShare = (renewableTotal / totalGeneration) * 100;
const coalShare = (coalTotal / totalGeneration) * 100;
const nuclearShare = (nuclearTotal / totalGeneration) * 100;
const topSource = results.reduce((best, r) => (r.total_mwh > best.total_mwh ? r : best));
// Phase 4: write CSV
const csvHeader = "filter_id,source_de,total_mwh,share_pct_generation";
const csvRows = results.map((r) => {
const share = (r.total_mwh / totalGeneration) * 100;
return `${r.filter_id},${r.source_de},${r.total_mwh.toFixed(1)},${share.toFixed(2)}`;
});
const csvPath = join(OUT_DIR, "energy-mix-latest.csv");
writeFileSync(csvPath, [csvHeader, ...csvRows].join("\n") + "\n");
console.log(`\nWrote ${csvPath}`);
// Phase 5: print summary
console.log(`\n── Summary (${consensusYear}) ────────────────────────────────`);
console.log(`Total generation: ${(totalGeneration / 1e6).toFixed(0)} TWh`);
console.log(`Renewable share: ${renewableShare.toFixed(1)}%`);
console.log(`Coal share: ${coalShare.toFixed(1)}%`);
console.log(`Nuclear share: ${nuclearShare.toFixed(1)}% (Kernenergie abgeschaltet Apr 2023)`);
console.log(`Top source: ${topSource.source_de} (${(topSource.total_mwh / 1e6).toFixed(1)} TWh, ${((topSource.total_mwh / totalGeneration) * 100).toFixed(1)}%)`);
console.log(`\nAll figures in MWh. Primary data period: ${consensusYear}.`);
}
main().catch((err) => {
console.error("Error:", err.message);
process.exit(1);
});