,

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];

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');
}

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;
}

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;
}

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');
}

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;
}

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;
}

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;
}

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;
}

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());
}

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);
  });
}

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

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;
}

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;
}

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
  },
  
  .....
]  

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);

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.

Comments

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

To respond on your own website, enter the URL of your response which should contain a link to this post’s permalink URL. Your response will then appear (possibly after moderation) on this page. Want to update or remove your response? Update or delete your post and re-enter your post’s URL again. (Find out more about Webmentions.)