Wettervorhersagen mit Daten des DWD – Teil [4/4]: Die ganze Applikation

Nach der ganzen Theorie werden in diesem Artikel noch einmal alle Schritte zusammengefasst, damit man einen besseren Überblick über die notwendigen Elemente hat.
Um den Code übersichtlich zu halten, wurden Eingabeüberprüfung, große Teile der Fehlerbehandlung und das Caching ausgelassen.

Am Ende das Beitrags kann man zum einen ein Rumpfprogramm herunterladen, dass nur die westenlichen Elemente beinhaltet als auch eine komplett ausgearbeitete Applikation herunterladen.

Ablauf

Koordinaten des Standorts berechnen

IDs der MOSMIX-Stationen ermitteln

IDs der Daten-Dateien auslesen

Schnittmenge aus MOSMIX- und Daten-IDS erstellen

Nächstgelegene Station berechnen

KMZ-Datei der Station herunterladen und entpacken

Zeitstempel und Placemarks extrahieren und kombinieren

Ausgabe der Wetterdaten

Die Implementation

Tools, Konstanten und und Benutzereingabe:

import unzipper from 'unzipper';
import xml2js from 'xml2js';
import { Readable } from 'stream';

const MOSMIX_L_DIR_URL = 'https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/single_stations/';

const MOSMIX_L_LATEST = 'https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_S/all_stations/kml/MOSMIX_S_LATEST_240.kmz';

const USER_AGENT = 'Latz DWD-weather/1.0';

const city = process.argv[2];

import unzipper from 'unzipper';
import xml2js from 'xml2js';
import { Readable } from 'stream';

const MOSMIX_L_DIR_URL = 'https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/single_stations/';

const MOSMIX_L_LATEST = 'https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_S/all_stations/kml/MOSMIX_S_LATEST_240.kmz';

const USER_AGENT = 'Latz DWD-weather/1.0';

const city = process.argv[2];

Zuerst ermitteln wir die Koordinaten des gewünschten Standorts mit Hilfe von Nominatim:

async function geocode(locationName) {
// Exponential Backoff für Rate-Limiting
let delay = 2000;
let lastError;

for (let attempt = 0; attempt < 3; attempt++) {
await new Promise(resolve => setTimeout(resolve, delay));

const url =
'https://nominatim.openstreetmap.org/search?' + `q=${encodeURIComponent(locationName)}&format=json&limit=1`;

const response = await fetch(url, {
headers: { 'User-Agent': USER_AGENT },
});

if (response.status === 429) {
lastError = new Error(`Rate limited (attempt ${attempt + 1}/3)`);
delay *= 2;
continue;
}

if (!response.ok) {
throw new Error(`Nominatim: ${response.status} ${response.statusText}`);
}

const results = await response.json();
if (!results || results.length === 0) {
throw new Error(`Location not found: "${locationName}"`);
}

const geocoded = {
lat: parseFloat(results[0].lat),
lon: parseFloat(results[0].lon),
displayName: results[0].display_name,
};

// Cachen mit Timestamp für per-Eintrag TTL
// Disabld in Demo-App!
// cached[cacheKey] = { ...geocoded, cachedAt: Date.now() };
// writeCache(GEOCODE_CACHE_FILE, cached);

return geocoded;
}

throw lastError || new Error('Nominatim rate limit exceeded');
}

async function geocode(locationName) {
// Exponential Backoff für Rate-Limiting
let delay = 2000;
let lastError;

for (let attempt = 0; attempt < 3; attempt++) {
await new Promise(resolve => setTimeout(resolve, delay));

const url =
'https://nominatim.openstreetmap.org/search?' + `q=${encodeURIComponent(locationName)}&format=json&limit=1`;

const response = await fetch(url, {
headers: { 'User-Agent': USER_AGENT },
});

if (response.status === 429) {
lastError = new Error(`Rate limited (attempt ${attempt + 1}/3)`);
delay *= 2;
continue;
}

if (!response.ok) {
throw new Error(`Nominatim: ${response.status} ${response.statusText}`);
}

const results = await response.json();
if (!results || results.length === 0) {
throw new Error(`Location not found: "${locationName}"`);
}

const geocoded = {
lat: parseFloat(results[0].lat),
lon: parseFloat(results[0].lon),
displayName: results[0].display_name,
};

// Cachen mit Timestamp für per-Eintrag TTL
// Disabld in Demo-App!
// cached[cacheKey] = { ...geocoded, cachedAt: Date.now() };
// writeCache(GEOCODE_CACHE_FILE, cached);

return geocoded;
}

throw lastError || new Error('Nominatim rate limit exceeded');
}

Als nächstes kommt die Funktion, um die Stations-IDs zu erhalten. Wie beschrieben, ist nicht für jede Station aus MOSMIX_L tatsächlich ein Datensatz vorhanden. Deswegen muss man das Verzeichnis mit den Dateien auslesen und anschließend die Koordinaten für die Stationen mit vorhandenem Datensatz ermitteln:

async function getMOSMIXLStationIDs() {
const response = await fetch(MOSMIX_L_DIR_URL, {
headers: { 'User-Agent': USER_AGENT },
});

if (!response.ok) {
throw new Error(`Failed to fetch MOSMIX_L directory: ${response.status}`);
}

const html = await response.text();
const ids = [];
const regex = /href="([0-9]{5})\//g;
let match;

while ((match = regex.exec(html)) !== null) {
ids.push(match[1]);
}

if (ids.length === 0) {
throw new Error('No station IDs found in MOSMIX_L directory');
}

return ids;
}

async function getMOSMIXLStationIDs() {
const response = await fetch(MOSMIX_L_DIR_URL, {
headers: { 'User-Agent': USER_AGENT },
});

if (!response.ok) {
throw new Error(`Failed to fetch MOSMIX_L directory: ${response.status}`);
}

const html = await response.text();
const ids = [];
const regex = /href="([0-9]{5})\//g;
let match;

while ((match = regex.exec(html)) !== null) {
ids.push(match[1]);
}

if (ids.length === 0) {
throw new Error('No station IDs found in MOSMIX_L directory');
}

return ids;
}

Jetzt die Koordinaten der Stationen ermitteln:

async function streamStationCoordinates(url) {
const response = await fetch(url, {
headers: { 'User-Agent': USER_AGENT },
});

if (!response.ok) {
throw new Error(`Failed to fetch MOSMIX_S KMZ: ${response.status}`);
}

const buffer = Buffer.from(await response.arrayBuffer());
const coords = {};

await new Promise((resolve, reject) => {
Readable.from([buffer])
.pipe(unzipper.Parse())
.on('entry', entry => {
if (!entry.path.endsWith('.kml')) {
entry.autodrain();
return;
}

let buf = '';

entry.on('data', chunk => {
buf += chunk.toString('latin1');

// Parse complete Placemark blocks as they arrive
let start;
while ((start = buf.indexOf('<kml:Placemark>')) !== -1) {
const end = buf.indexOf('</kml:Placemark>', start);
if (end === -1) break; // Block noch nicht vollständig

const block = buf.slice(start, end + 16);
buf = buf.slice(end + 16); // Verarbeiteten Teil verwerfen

const nameM = block.match(/<kml:name>([^<]+)<\/kml:name>/);
const coordM = block.match(/<kml:coordinates>([^<]+)<\/kml:coordinates>/);
const descM = block.match(/<kml:description>([^<]+)<\/kml:description>/);

if (nameM && coordM) {
const id = nameM[1].trim();
const parts = coordM[1].trim().split(',');
const lon = parseFloat(parts[0]);
const lat = parseFloat(parts[1]);
const elevation = parseFloat(parts[2]) || 0;
const name = descM ? descM[1].trim() : id;
coords[id] = { lat, lon, elevation, name };
}
}
});

entry.on('end', resolve);
entry.on('error', reject);
})
.on('error', reject);
});

return coords;
}

async function streamStationCoordinates(url) {
const response = await fetch(url, {
headers: { 'User-Agent': USER_AGENT },
});

if (!response.ok) {
throw new Error(`Failed to fetch MOSMIX_S KMZ: ${response.status}`);
}

const buffer = Buffer.from(await response.arrayBuffer());
const coords = {};

await new Promise((resolve, reject) => {
Readable.from([buffer])
.pipe(unzipper.Parse())
.on('entry', entry => {
if (!entry.path.endsWith('.kml')) {
entry.autodrain();
return;
}

let buf = '';

entry.on('data', chunk => {
buf += chunk.toString('latin1');

// Parse complete Placemark blocks as they arrive
let start;
while ((start = buf.indexOf('<kml:Placemark>')) !== -1) {
const end = buf.indexOf('</kml:Placemark>', start);
if (end === -1) break; // Block noch nicht vollständig

const block = buf.slice(start, end + 16);
buf = buf.slice(end + 16); // Verarbeiteten Teil verwerfen

const nameM = block.match(/<kml:name>([^<]+)<\/kml:name>/);
const coordM = block.match(/<kml:coordinates>([^<]+)<\/kml:coordinates>/);
const descM = block.match(/<kml:description>([^<]+)<\/kml:description>/);

if (nameM && coordM) {
const id = nameM[1].trim();
const parts = coordM[1].trim().split(',');
const lon = parseFloat(parts[0]);
const lat = parseFloat(parts[1]);
const elevation = parseFloat(parts[2]) || 0;
const name = descM ? descM[1].trim() : id;
coords[id] = { lat, lon, elevation, name };
}
}
});

entry.on('end', resolve);
entry.on('error', reject);
})
.on('error', reject);
});

return coords;
}

Für die Ermittlung der nächstgelegenen Station müssen zuerst die Koordinaten des in der Kommandozeilen übergebenen Orts ermitteln:

async function geocode(locationName) {
// Exponential Backoff für Rate-Limiting
let delay = 2000;
let lastError;

for (let attempt = 0; attempt < 3; attempt++) {
await new Promise(resolve => setTimeout(resolve, delay));

const url =
'https://nominatim.openstreetmap.org/search?' + `q=${encodeURIComponent(locationName)}&format=json&limit=1`;

const response = await fetch(url, {
headers: { 'User-Agent': USER_AGENT },
});

if (response.status === 429) {
lastError = new Error(`Rate limited (attempt ${attempt + 1}/3)`);
delay *= 2;
continue;
}

if (!response.ok) {
throw new Error(`Nominatim: ${response.status} ${response.statusText}`);
}

const results = await response.json();
if (!results || results.length === 0) {
throw new Error(`Location not found: "${locationName}"`);
}

const geocoded = {
lat: parseFloat(results[0].lat),
lon: parseFloat(results[0].lon),
displayName: results[0].display_name,
};

// Cachen mit Timestamp für per-Eintrag TTL
// Disabld in Demo-App!
// cached[cacheKey] = { ...geocoded, cachedAt: Date.now() };
// writeCache(GEOCODE_CACHE_FILE, cached);

return geocoded;
}

throw lastError || new Error('Nominatim rate limit exceeded');
}

async function geocode(locationName) {
// Exponential Backoff für Rate-Limiting
let delay = 2000;
let lastError;

for (let attempt = 0; attempt < 3; attempt++) {
await new Promise(resolve => setTimeout(resolve, delay));

const url =
'https://nominatim.openstreetmap.org/search?' + `q=${encodeURIComponent(locationName)}&format=json&limit=1`;

const response = await fetch(url, {
headers: { 'User-Agent': USER_AGENT },
});

if (response.status === 429) {
lastError = new Error(`Rate limited (attempt ${attempt + 1}/3)`);
delay *= 2;
continue;
}

if (!response.ok) {
throw new Error(`Nominatim: ${response.status} ${response.statusText}`);
}

const results = await response.json();
if (!results || results.length === 0) {
throw new Error(`Location not found: "${locationName}"`);
}

const geocoded = {
lat: parseFloat(results[0].lat),
lon: parseFloat(results[0].lon),
displayName: results[0].display_name,
};

// Cachen mit Timestamp für per-Eintrag TTL
// Disabld in Demo-App!
// cached[cacheKey] = { ...geocoded, cachedAt: Date.now() };
// writeCache(GEOCODE_CACHE_FILE, cached);

return geocoded;
}

throw lastError || new Error('Nominatim rate limit exceeded');
}

Aus den Stations-IDs und den Koordinaten kann man den kombinierten Katalog zusammensetzen:

async function getStationCatalog() {
// MOSMIX_L Station-IDs
const ids = await getMOSMIXLStationIDs();

// MOSMIX_S Koordinaten
let coords = {};
try {
const kmzUrl = await getLatestMOSMIXSUrl();
coords = await streamStationCoordinates(kmzUrl);
} catch (err) {
console.warn(`Warning: Could not fetch station coordinates: ${err.message}`);
}

// Zusammenführen: MOSMIX_L IDs + MOSMIX_S Koordinaten
const stations = ids.map(id => {
const coord = coords[id];
if (coord) {
return { id, lat: coord.lat, lon: coord.lon, elevation: coord.elevation, name: coord.name };
} else {
// Fallback: Mittelpunkt Deutschland — deutlich schlechtere Ergebnisse
return { id, lat: 51.5, lon: 10.0, elevation: 0, name: `Station ${id}` };
}
});

return stations;
}

async function getStationCatalog() {
// MOSMIX_L Station-IDs
const ids = await getMOSMIXLStationIDs();

// MOSMIX_S Koordinaten
let coords = {};
try {
const kmzUrl = await getLatestMOSMIXSUrl();
coords = await streamStationCoordinates(kmzUrl);
} catch (err) {
console.warn(`Warning: Could not fetch station coordinates: ${err.message}`);
}

// Zusammenführen: MOSMIX_L IDs + MOSMIX_S Koordinaten
const stations = ids.map(id => {
const coord = coords[id];
if (coord) {
return { id, lat: coord.lat, lon: coord.lon, elevation: coord.elevation, name: coord.name };
} else {
// Fallback: Mittelpunkt Deutschland — deutlich schlechtere Ergebnisse
return { id, lat: 51.5, lon: 10.0, elevation: 0, name: `Station ${id}` };
}
});

return stations;
}

Jetzt die Koordinaten der Stationen laden:

async function streamStationCoordinates(url) {
const response = await fetch(url, {
headers: { 'User-Agent': USER_AGENT }
});

if (!response.ok) {
throw new Error(`Failed to fetch MOSMIX_S KMZ: ${response.status}`);
}

const buffer = Buffer.from(await response.arrayBuffer());
const coords = {};

await new Promise((resolve, reject) => {
Readable.from([buffer])
.pipe(unzipper.Parse())
.on('entry', entry => {
if (!entry.path.endsWith('.kml')) {
entry.autodrain();
return;
}

let buf = '';

entry.on('data', chunk => {
buf += chunk.toString('latin1');

// Parse complete Placemark blocks as they arrive
let start;
while ((start = buf.indexOf('<kml:Placemark>')) !== -1) {
const end = buf.indexOf('</kml:Placemark>', start);
if (end === -1) break; // Block noch nicht vollständig

const block = buf.slice(start, end + 16);
buf = buf.slice(end + 16); // Verarbeiteten Teil verwerfen

const nameM = block.match(/<kml:name>([^<]+)<\/kml:name>/);
const coordM = block.match(/<kml:coordinates>([^<]+)<\/kml:coordinates>/);
const descM = block.match(/<kml:description>([^<]+)<\/kml:description>/);

if (nameM && coordM) {
const id = nameM[1].trim();
const parts = coordM[1].trim().split(',');
const lon = parseFloat(parts[0]);
const lat = parseFloat(parts[1]);
const elevation = parseFloat(parts[2]) || 0;
const name = descM ? descM[1].trim() : id;
coords[id] = { lat, lon, elevation, name };
}
}
});

entry.on('end', resolve);
entry.on('error', reject);
})
.on('error', reject);
});

return coords;
}

async function streamStationCoordinates(url) {
const response = await fetch(url, {
headers: { 'User-Agent': USER_AGENT }
});

if (!response.ok) {
throw new Error(`Failed to fetch MOSMIX_S KMZ: ${response.status}`);
}

const buffer = Buffer.from(await response.arrayBuffer());
const coords = {};

await new Promise((resolve, reject) => {
Readable.from([buffer])
.pipe(unzipper.Parse())
.on('entry', entry => {
if (!entry.path.endsWith('.kml')) {
entry.autodrain();
return;
}

let buf = '';

entry.on('data', chunk => {
buf += chunk.toString('latin1');

// Parse complete Placemark blocks as they arrive
let start;
while ((start = buf.indexOf('<kml:Placemark>')) !== -1) {
const end = buf.indexOf('</kml:Placemark>', start);
if (end === -1) break; // Block noch nicht vollständig

const block = buf.slice(start, end + 16);
buf = buf.slice(end + 16); // Verarbeiteten Teil verwerfen

const nameM = block.match(/<kml:name>([^<]+)<\/kml:name>/);
const coordM = block.match(/<kml:coordinates>([^<]+)<\/kml:coordinates>/);
const descM = block.match(/<kml:description>([^<]+)<\/kml:description>/);

if (nameM && coordM) {
const id = nameM[1].trim();
const parts = coordM[1].trim().split(',');
const lon = parseFloat(parts[0]);
const lat = parseFloat(parts[1]);
const elevation = parseFloat(parts[2]) || 0;
const name = descM ? descM[1].trim() : id;
coords[id] = { lat, lon, elevation, name };
}
}
});

entry.on('end', resolve);
entry.on('error', reject);
})
.on('error', reject);
});

return coords;
}

Die Stationen ermitteln, für die tatsächlich Daten vorhanden sind:

async function getStationCatalog() {
// MOSMIX_L Station-IDs
const ids = await getMOSMIXLStationIDs();

// MOSMIX_S Koordinaten
let coords = {};
try {
const kmzUrl = await getLatestMOSMIXSUrl();
coords = await streamStationCoordinates(kmzUrl);
} catch (err) {
console.warn(`Warning: Could not fetch station coordinates: ${err.message}`);
}

// Zusammenführen: MOSMIX_L IDs + MOSMIX_S Koordinaten
const stations = ids.map(id => {
const coord = coords[id];
if (coord) {
return { id, lat: coord.lat, lon: coord.lon, elevation: coord.elevation, name: coord.name };
} else {
// Fallback: Mittelpunkt Deutschland — deutlich schlechtere Ergebnisse
return { id, lat: 51.5, lon: 10.0, elevation: 0, name: `Station ${id}` };
}
});

// writeCache(CACHE_FILE, stations);

return stations;
}

async function getStationCatalog() {
// MOSMIX_L Station-IDs
const ids = await getMOSMIXLStationIDs();

// MOSMIX_S Koordinaten
let coords = {};
try {
const kmzUrl = await getLatestMOSMIXSUrl();
coords = await streamStationCoordinates(kmzUrl);
} catch (err) {
console.warn(`Warning: Could not fetch station coordinates: ${err.message}`);
}

// Zusammenführen: MOSMIX_L IDs + MOSMIX_S Koordinaten
const stations = ids.map(id => {
const coord = coords[id];
if (coord) {
return { id, lat: coord.lat, lon: coord.lon, elevation: coord.elevation, name: coord.name };
} else {
// Fallback: Mittelpunkt Deutschland — deutlich schlechtere Ergebnisse
return { id, lat: 51.5, lon: 10.0, elevation: 0, name: `Station ${id}` };
}
});

// writeCache(CACHE_FILE, stations);

return stations;
}

Die nächstgelegene Station ermitteln:

function haversineKm(lat1, lon1, lat2, lon2) {
const R = 6371; // Erdradius in km
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * Math.PI / 180) *
Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.asin(Math.sqrt(a));
}

function findNearest(targetLat, targetLon, points) {
if (!points || points.length === 0) {
throw new Error('Points array must not be empty');
}

let best = { dist: Infinity };

for (const point of points) {
const dist = haversineKm(targetLat, targetLon, point.lat, point.lon);
if (dist < best.dist) {
best = { ...point, dist };
}
}

return best;
}

function haversineKm(lat1, lon1, lat2, lon2) {
const R = 6371; // Erdradius in km
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * Math.PI / 180) *
Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.asin(Math.sqrt(a));
}

function findNearest(targetLat, targetLon, points) {
if (!points || points.length === 0) {
throw new Error('Points array must not be empty');
}

let best = { dist: Infinity };

for (const point of points) {
const dist = haversineKm(targetLat, targetLon, point.lat, point.lon);
if (dist < best.dist) {
best = { ...point, dist };
}
}

return best;
}

Jetzt kommen wir endlich dazu, die tatsächlichen Wetterdaten zu laden:

onst USER_AGENT = 'dwd-weather-cli/1.0 (github.com/latz/dwd-weather)';

async function fetchKMZ(stationId) {
const url = `https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/` +
`single_stations/${stationId}/kml/MOSMIX_L_LATEST_${stationId}.kmz`;

const response = await fetch(url, {
headers: {
'User-Agent': USER_AGENT
}
});

if (response.status === 404) {
throw new Error(`Station ${stationId} has no MOSMIX data available`);
}

if (!response.ok) {
throw new Error(`Failed to fetch KMZ: ${response.status} ${response.statusText}`);
}

return Buffer.from(await response.arrayBuffer());
}

onst USER_AGENT = 'dwd-weather-cli/1.0 (github.com/latz/dwd-weather)';

async function fetchKMZ(stationId) {
const url = `https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/` +
`single_stations/${stationId}/kml/MOSMIX_L_LATEST_${stationId}.kmz`;

const response = await fetch(url, {
headers: {
'User-Agent': USER_AGENT
}
});

if (response.status === 404) {
throw new Error(`Station ${stationId} has no MOSMIX data available`);
}

if (!response.ok) {
throw new Error(`Failed to fetch KMZ: ${response.status} ${response.statusText}`);
}

return Buffer.from(await response.arrayBuffer());
}

Die geladene Datei ist als ZIP gepackt, zur weiteren Verarbeitung auspacken:

async function unzipKMZ(kmzBuffer) {
return new Promise((resolve, reject) => {
const stream = Readable.from([kmzBuffer]);

stream.pipe(unzipper.Parse())
.on('entry', entry => {
if (entry.path.endsWith('.kml')) {
entry.buffer().then(buf => {
resolve(buf);
}).catch(reject);
} else {
entry.autodrain(); // Andere Einträge verwerfen
}
})
.on('error', reject);
});
}

async function unzipKMZ(kmzBuffer) {
return new Promise((resolve, reject) => {
const stream = Readable.from([kmzBuffer]);

stream.pipe(unzipper.Parse())
.on('entry', entry => {
if (entry.path.endsWith('.kml')) {
entry.buffer().then(buf => {
resolve(buf);
}).catch(reject);
} else {
entry.autodrain(); // Andere Einträge verwerfen
}
})
.on('error', reject);
});
}

Zur einfacheren Verarbeitung mit JavaScript die XML-Daten in JSON umwandeln:

const parser = new xml2js.Parser();
const kml = await parser.parseStringPromise(kml_data_unzipped);

const parser = new xml2js.Parser();
const kml = await parser.parseStringPromise(kml_data_unzipped);

Jetzt die Zeitmarken und Placemarks aus den Daten extrahieren (Erklärung in Teil 3)

function getTimestamps(kml) {
const doc = kml['kml:kml']['kml:Document'][0];
const docExt = doc['kml:ExtendedData'] && doc['kml:ExtendedData'][0];
const prodDef = docExt['dwd:ProductDefinition'][0];
const timeSteps = prodDef['dwd:ForecastTimeSteps'][0]['dwd:TimeStep'];
const timestamps = timeSteps.map(ts => new Date(ts));
return timestamps;
}

function getPlacemarks(kml) {
const doc = kml['kml:kml']['kml:Document'][0];

const placemark = doc['kml:Placemark'][0];
const pmExt = placemark['kml:ExtendedData'] && placemark['kml:ExtendedData'][0];
const forecasts = (pmExt && pmExt['dwd:Forecast']) || [];
const parametersMap = {};

for (const forecast of forecasts) {
const elementName = forecast['$']['dwd:elementName']; // z.B. "TTT"
const valueStr = forecast['dwd:value'][0];

const values = valueStr.split(/\s+/).map(v => (v === '-' ? NaN : parseFloat(v)));

parametersMap[elementName] = values;
}
return parametersMap;
}

function getTimestamps(kml) {
const doc = kml['kml:kml']['kml:Document'][0];
const docExt = doc['kml:ExtendedData'] && doc['kml:ExtendedData'][0];
const prodDef = docExt['dwd:ProductDefinition'][0];
const timeSteps = prodDef['dwd:ForecastTimeSteps'][0]['dwd:TimeStep'];
const timestamps = timeSteps.map(ts => new Date(ts));
return timestamps;
}

function getPlacemarks(kml) {
const doc = kml['kml:kml']['kml:Document'][0];

const placemark = doc['kml:Placemark'][0];
const pmExt = placemark['kml:ExtendedData'] && placemark['kml:ExtendedData'][0];
const forecasts = (pmExt && pmExt['dwd:Forecast']) || [];
const parametersMap = {};

for (const forecast of forecasts) {
const elementName = forecast['$']['dwd:elementName']; // z.B. "TTT"
const valueStr = forecast['dwd:value'][0];

const values = valueStr.split(/\s+/).map(v => (v === '-' ? NaN : parseFloat(v)));

parametersMap[elementName] = values;
}
return parametersMap;
}

Jetzt kann man mit der Zeitstempeln und den Placemmarks die Wetterdaten einem Zeitpunkt zuordnen:

function getParameters(timestamps, parametersMap) {
const timeseries = timestamps.map((time, idx) => {
const point = { time };

if (parametersMap['TTT']) {
const kelvin = parametersMap['TTT'][idx];
point.temp = Number.isFinite(kelvin) ? kelvin - 273.15 : null;
}

if (parametersMap['RR1c']) {
const precip = parametersMap['RR1c'][idx];
point.precipitation = Number.isFinite(precip) ? precip : null;
}

if (parametersMap['FF']) {
const windMs = parametersMap['FF'][idx];
point.windSpeed = Number.isFinite(windMs) ? windMs * 3.6 : null;
}

// ... weitere Parameter ...

return point;
});
return timeseries;
}

function getParameters(timestamps, parametersMap) {
const timeseries = timestamps.map((time, idx) => {
const point = { time };

if (parametersMap['TTT']) {
const kelvin = parametersMap['TTT'][idx];
point.temp = Number.isFinite(kelvin) ? kelvin - 273.15 : null;
}

if (parametersMap['RR1c']) {
const precip = parametersMap['RR1c'][idx];
point.precipitation = Number.isFinite(precip) ? precip : null;
}

if (parametersMap['FF']) {
const windMs = parametersMap['FF'][idx];
point.windSpeed = Number.isFinite(windMs) ? windMs * 3.6 : null;
}

// ... weitere Parameter ...

return point;
});
return timeseries;
}

Jetzt endlich haben wir die zeitlich zugordneten Wetterdaten:

parameters: [
{
time: 2026-05-06T04:00:00.000Z,
temp: null,
precipitation: null,
windSpeed: null
},
{
time: 2026-05-06T05:00:00.000Z,
temp: 11.200000000000045,
precipitation: 1.3,
windSpeed: 5.5440000000000005
},
{
time: 2026-05-06T06:00:00.000Z,
temp: 11.300000000000011,
precipitation: 0.7,
windSpeed: 3.708
},

.....
]

parameters: [
{
time: 2026-05-06T04:00:00.000Z,
temp: null,
precipitation: null,
windSpeed: null
},
{
time: 2026-05-06T05:00:00.000Z,
temp: 11.200000000000045,
precipitation: 1.3,
windSpeed: 5.5440000000000005
},
{
time: 2026-05-06T06:00:00.000Z,
temp: 11.300000000000011,
precipitation: 0.7,
windSpeed: 3.708
},

.....
]

Diese Daten muss man jetzt noch in ein besser lesbares Format konvertieren. Diese Übung überlasse ich dem geneigten Leser .

Zum Schluss nochmal eine Zusammenfassung aller notwendigen Schritte:

const city = process.argv[2];
const city_coords = await geocode(city);
const mosmix_ids = await getMOSMIXLStationIDs();
const station_coords = await streamStationCoordinates(MOSMIX_L_LATEST);
const stations_catalog = await getStationCatalog(mosmix_ids, station_coords);
const nearest_station = findNearest(city_coords.lat, city_coords.lon, stations_catalog);
const kmz_data = await fetchKMZ(nearest_station.id);
const kml_data_unzipped = await unzipKMZ(kmz_data);
const parser = new xml2js.Parser();
const kml = await parser.parseStringPromise(kml_data_unzipped);
const timestamps = getTimestamps(kml);
const placemarks = getPlacemarks(kml);
const parameters = getParameters(timestamps, placemarks);

const city = process.argv[2];
const city_coords = await geocode(city);
const mosmix_ids = await getMOSMIXLStationIDs();
const station_coords = await streamStationCoordinates(MOSMIX_L_LATEST);
const stations_catalog = await getStationCatalog(mosmix_ids, station_coords);
const nearest_station = findNearest(city_coords.lat, city_coords.lon, stations_catalog);
const kmz_data = await fetchKMZ(nearest_station.id);
const kml_data_unzipped = await unzipKMZ(kmz_data);
const parser = new xml2js.Parser();
const kml = await parser.parseStringPromise(kml_data_unzipped);
const timestamps = getTimestamps(kml);
const placemarks = getPlacemarks(kml);
const parameters = getParameters(timestamps, placemarks);

Downloads: das obige Beispiel als ZIP-Datei

GitHub-Repository mit ausführlichem Client

Ich hoffe, diese Artikelreihe hat dazu animiert, sich mit dem ausgiebigen Daten des DWD auseinander zu setzen. Es gibt noch viel, viel mehr Möglichkeiten, sie zu nutzen. Diese werden in späteren Artikeln erkundet.