,

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>
  <!-- ... Vorhersagewerte ... -->
</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/

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:

<a href="10382/">10382/</a>
<a href="10400/">10400/</a>
<a href="10501/">10501/</a>

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

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

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' },
  // ...
}

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

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

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

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

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

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

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