Wettervorhersagen mit Daten des DWD – Teil [2/4]: Die richtige Wetterstation finden

Wer mit MOSMIX-Daten arbeiten will, steht vor einer grundlegenden Frage: Welche Station ist die nächste zu einem gegebenen Ort? Wenn ein Nutzer „München“ eingibt, welche der 2.698 MOSMIX-Stationen liefert die treffendste Vorhersage?

Das Problem ist, dass das DWD keinen maschinenlesbaren Katalog aller Stationen mit Koordinaten bereitstellt, um die richtige Wetterstation finden. Man muss sich die Daten also auf andere Weise beschaffen.

MOSMIX_S als Koordinatenquelle

MOSMIX_S (https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_S/all_stations/kml/MOSMIX_S_LATEST_240.kmz) enthält Vorhersagen für alle 2.698 Stationen in einer einzigen KMZ-Datei und jeder <kml:Placemark>-Block enthält die Stationskoordinaten:

<kml:Placemark>
<kml:name>10382</kml:name>
<kml:description>BERLIN-TEGEL</kml:description>
<kml:Point>
<kml:coordinates>13.32,52.567,36</kml:coordinates>
</kml:Point>

</kml:Placemark>

<kml:Placemark>
<kml:name>10382</kml:name>
<kml:description>BERLIN-TEGEL</kml:description>
<kml:Point>
<kml:coordinates>13.32,52.567,36</kml:coordinates>
</kml:Point>

</kml:Placemark>

Format: Längengrad,Breitengrad,Höhe. Das ist KML-Standard.

Das Problem ist allerdings, dass nicht jede Station, die in der großen MOSMIX_S-Sammeldatei auftaucht, zwingend auch als Einzeldatei mit Vorhersagedaten in MOSMIX_L verfügbar – und umgekehrt. Indem man zuerst das Verzeichnis

https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/single_stations/

https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/single_stations/

ausliest, stellt man sicher, dass die Liste nur IDs enthält, für die man im nächsten Schritt tatsächlich eine detaillierte Vorhersagedatei herunterladen kann.

Das MOSMIX_L-Verzeichnis ist ein Apache-Directory-Listing — eine HTML-Seite mit Links zu allen verfügbaren Stations-Verzeichnissen. Jeder Link sieht so aus:

10382/
10400/
10501/

10382/
10400/
10501/

Das Auslesen der Links erfolgt mit einem Regex, das genau fünfstellige Nummern, gefolgt von einem Schrägstrich findet und ignoriert andere Links im HTML.

/href="([0-9]{5})\//g

/href="([0-9]{5})\//g

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

Damit erhält man ein Array mit den IDs aller Stationen, für die Wetterdaten vorhanden sind. Um die Koordinaten der Station zu erhalten, kann man jetzt die MOSMIX-Datei mit den aktuellen Daten laden (sie hat immer den gleichen Dateinamen):

https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_S/all_stations/kml/MOSMIX_S_LATEST_240.kmz

https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_S/all_stations/kml/MOSMIX_S_LATEST_240.kmz

Die Datei ist allerdings als gepackte ZIP-Datei 38MB und ausgepackt 670MB groß und sie mit xml2js zu parsen wäre speicherintensiv und langsam.
Zur effizienten Verarbeitung wird die KMZ-Datei besser gestreamt. Ein String-Buffer akkumuliert eingehenden Datenpakete (Chunks) so lange, bis ein <kml:Placemark>-Block erkannt wird. Dieser wird dann per Regex geparst und umgehend aus dem Buffer entfernt, um den Speicherbedarf zu minimieren.

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

Das Ergebnis ist ein Objekt mit ~2.700/ Einträgen:

{
'10382': { lat: 52.567, lon: 13.32, elevation: 36, name: 'BERLIN-TEGEL' },
'10400': { lat: 51.8, lon: 10.917, elevation: 350, name: 'BROCKEN' },
// ...
}

{
'10382': { lat: 52.567, lon: 13.32, elevation: 36, name: 'BERLIN-TEGEL' },
'10400': { lat: 51.8, lon: 10.917, elevation: 350, name: 'BROCKEN' },
// ...
}

Den Katalog zusammensetzen und cachen

Mit den IDs aus dem Directory-Scraping und den Koordinaten aus MOSMIX_S wird der Katalog der Stationen, die für die tatsächlich Datensätze vorhanden sind, zusammengesetzt:

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

Diesen Katalog sollte man cachen, denn jedesmal 38 MB zu laden, obwohl sich die Stationen nur selten ändern, wäre nicht sehr sinnvoll. Daher reicht es durachaus, die Daten einmal pro Woche oder noch seltener auf den neuesten Stand zu bringen.

Haversine: Die Standardlösung für die nächste Station

Mit den Koordinaten für alle Stationen kann man die Entfernungsberechnung zur nächsten Station mit der Haversine-Formel ermitteln. Diese Formel berechnet Großkreis-Abstände auf einer Kugel, das ist der Standardansatz für „nächstgelegene Station“-Probleme in der Meteorologie:

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

Für die nächstgelegene Station:

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

Die obige Implementierung ist die einfachste Version. Insbesondere für Standorte in bergigen Gebieten ist ein Funktion mit Berücksichtigung der Höhenmeter sinnvoll, würde aber den Umfang dieses Artikels sprechen.

Geocoding: Ortsnamen zu Koordinaten

Bevor man die nächste Station suchen kann, braucht man die Koordinaten des Ortsnamens. Das übernimmt OpenStreetMaps Nominatim, das kostenlos ist und keinen API-Key benötigt, aber klare Nutzungsbedingungen vorgibt. Drei Punkte sind hier relevant:

User-Agent: Anfragen ohne User-Agent werden blockiert oder gedrosselt. Das ist kein optionaler Header.

Rate-Limiting: Nominatim erlaubt maximal 1 Request pro Sekunde. Der Code sollte daher mindestens 2 Sekunden vor jedem Versuch warten.

Per-Eintrag-TTL (Time To Live): Jeder Geocoding-Eintrag trägt seinen eigenen cachedAt-Timestamp. Damit kann ein frischer München-Eintrag nicht durch einen abgelaufenen Berlin-Eintrag invalidiert werden — Ortsnamen ändern sich selten, aber unregelmäßig genutzte Einträge sollen trotzdem irgendwann ablaufen.

async function geocode(locationName) {
const cacheKey = locationName.toLowerCase().trim();
const cached = readCache(GEOCODE_CACHE_FILE) || {};

// Cache-Treffer: per-Eintrag TTL prüfen
if (cached[cacheKey] && cached[cacheKey].cachedAt) {
const age = Date.now() - cached[cacheKey].cachedAt;
if (age < GEOCODE_CACHE_TTL_MS) { // 30 Tage
const { lat, lon, displayName } = cached[cacheKey];
return { lat, lon, displayName };
}
}

// 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
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) {
const cacheKey = locationName.toLowerCase().trim();
const cached = readCache(GEOCODE_CACHE_FILE) || {};

// Cache-Treffer: per-Eintrag TTL prüfen
if (cached[cacheKey] && cached[cacheKey].cachedAt) {
const age = Date.now() - cached[cacheKey].cachedAt;
if (age < GEOCODE_CACHE_TTL_MS) { // 30 Tage
const { lat, lon, displayName } = cached[cacheKey];
return { lat, lon, displayName };
}
}

// 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
cached[cacheKey] = { ...geocoded, cachedAt: Date.now() };
writeCache(GEOCODE_CACHE_FILE, cached);

return geocoded;
}

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

Das Ergebnis: „München“ → Station 10866

Input: "München"

1. Geocoding (Nominatim, Cache-Miss):
München → { lat: 48.1372, lon: 11.5756 }
Dauer: ~150ms + 2s Wartezeit

2. Stationskatalog (Cache-Hit):
2698 Stationen geladen
Dauer: ~10ms

3. Haversine-Scan:
10865 (MUENCHEN-FLUGHAFEN): 26.1 km
10866 (MUENCHEN-CITY): 4.2 km ← nächste
Dauer: <1ms

4. Ergebnis:
Station 10866, 4.2 km entfernt

Input: "München"

1. Geocoding (Nominatim, Cache-Miss):
München → { lat: 48.1372, lon: 11.5756 }
Dauer: ~150ms + 2s Wartezeit

2. Stationskatalog (Cache-Hit):
2698 Stationen geladen
Dauer: ~10ms

3. Haversine-Scan:
10865 (MUENCHEN-FLUGHAFEN): 26.1 km
10866 (MUENCHEN-CITY): 4.2 km ← nächste
Dauer: <1ms

4. Ergebnis:
Station 10866, 4.2 km entfernt

Im nächsten Teil beschäftigen wir uns mit den eigentlichen Vorhersagedaten, die der DWD bereitstellt und wie man sie auswertet.