English version

← Learning Hub Lektion 3 Dauer: ca. 15 Min. Thema: Ingestion & Bronze-Schicht

Wetterdaten-Ingestion-Pipeline

Diese Lektion zeigt dir, wie Wetterdaten tatsächlich ankommen, geparst werden und in Bronze landen. Am Ende verstehst du die Dateiformate (GRIB2, NetCDF), das Werkzeug, das sie liest, wie du inkrementelle Ingestion auf Databricks einplanst, und die zentralen Architekturentscheidungen dieser Schicht.

Wie Wetterdaten ankommen

Numerische Wettervorhersagemodelle (NWP) erzeugen Prognosedaten nach festen Zeitplänen. Die drei wichtigsten Auslieferungswege:

QuelleFormatAuslieferungFrequenzTypische Latenz
ECMWF IFS GRIB2 MARS API / CDS API / S3-Verteilung 4× täglich (00, 06, 12, 18 UTC) ca. 4–6 h nach Init-Zeit
GFS (NOAA) GRIB2 HTTPS / AWS Open Data (S3) 4× täglich ca. 3,5 h nach Init-Zeit
ICON (DWD) GRIB2 DWD Open Data (HTTPS) 4× täglich ca. 2–4 h nach Init-Zeit
ERA5-Reanalyse NetCDF / GRIB2 CDS API (Copernicus) Monatlich (5 Tage Verzug) Tage bis Wochen
Kommerzielle Anbieter NetCDF / JSON / Parquet REST API / SFTP / Cloud-Push Unterschiedlich Minuten bis Stunden
Kernerkenntnis: die Prognose-Referenzzeit Jede Wetterprognose hat zwei Zeitdimensionen: die Init-Zeit (wann das Modell gerechnet wurde, z. B. 2026-07-17T00:00Z) und die Gültigkeitszeit (für welchen Zeitpunkt die Prognose gilt, z. B. +24 h = 2026-07-18T00:00Z). Dein Bronze-Schema muss beide erfassen. Ohne Init-Zeit kannst du nicht nachvollziehen, welche Prognoseversion eine bestimmte Vorhersage erzeugt hat.

GRIB2: das dominierende Format

GRIB2 (GRIdded Binary, Edition 2) ist das Standardformat für meteorologische Daten. Es ist kompakt, aber nicht selbstbeschreibend — du brauchst externe Tabellen, um die Bedeutung der Messages zu dekodieren.[1]

Was eine GRIB2-Datei enthält

GRIB2 in Python lesen

# Die Toolchain für GRIB2 → tabellarische Daten
import xarray as xr
import cfgrib

# GRIB2-Datei mit der cfgrib-Engine öffnen
ds = xr.open_dataset(
    "ecmwf_forecast_20260717_00z.grib2",
    engine="cfgrib",
    backend_kwargs={
        "filter_by_keys": {
            "shortName": ["10u", "10v", "2t", "ssrd"],  # Wind u/v, Temp., Einstrahlung
            "typeOfLevel": "heightAboveGround"
        }
    }
)

# Ergebnis: xarray-Dataset mit den Dimensionen (time, latitude, longitude)
# Variablen: u10, v10, t2m, ssrd — jeweils ein 3D-Array
print(ds)
# <xarray.Dataset>
# Dimensions:  (time: 81, latitude: 721, longitude: 1440)
# Coordinates:
#   * time       (time) datetime64[ns]
#   * latitude   (latitude) float64
#   * longitude  (longitude) float64
# Data variables:
#     u10        (time, latitude, longitude) float32
#     v10        (time, latitude, longitude) float32
#     t2m        (time, latitude, longitude) float32
#     ssrd       (time, latitude, longitude) float32

📦 Bibliotheks-Stack

cfgrib dekodiert GRIB2-Messages. xarray liefert die API für beschriftete mehrdimensionale Arrays. eccodes (die C-Bibliothek des ECMWF) ist der zugrunde liegende Decoder. Auf Databricks installierst du sie über ein Cluster-Init-Skript oder per %pip install cfgrib eccodes xarray.

Von xarray zum Spark DataFrame

Die Brücke zwischen Meteorologie (xarray) und Databricks (Spark) ist die zentrale technische Herausforderung. So sieht das Muster aus:

import xarray as xr
import pandas as pd
from pyspark.sql import SparkSession
from pyspark.sql.types import *

spark = SparkSession.builder.getOrCreate()

def grib_to_bronze_df(grib_path: str, init_time: str, source_model: str):
    """GRIB2-Datei parsen und ein Spark DataFrame für Bronze zurückgeben."""
    
    # 1. Mit xarray/cfgrib öffnen
    ds = xr.open_dataset(grib_path, engine="cfgrib")
    
    # 2. Nach pandas konvertieren (Gitter → Zeilen flach ziehen)
    #    Jede Zeile = ein Gitterpunkt × ein Zeitschritt
    df_pd = ds.to_dataframe().reset_index()
    
    # 3. Auf Domänenbegriffe umbenennen
    df_pd = df_pd.rename(columns={
        "latitude": "grid_lat",
        "longitude": "grid_lon",
        "time": "valid_time",  # Gültigkeitszeit der Prognose
        "u10": "wind_u_ms",
        "v10": "wind_v_ms",
        "t2m": "temperature_k",
        "ssrd": "irradiance_jm2",
    })
    
    # 4. Metadaten-Spalten ergänzen
    df_pd["init_time"] = pd.Timestamp(init_time)
    df_pd["source_model"] = source_model
    df_pd["ingested_at"] = pd.Timestamp.utcnow()
    
    # 5. In ein Spark DataFrame umwandeln
    return spark.createDataFrame(df_pd)


# Verwendung in einem Databricks-Notebook oder -Job
bronze_df = grib_to_bronze_df(
    "/mnt/raw/ecmwf/20260717_00z.grib2",
    init_time="2026-07-17T00:00:00Z",
    source_model="ecmwf_ifs"
)

# Nach Bronze schreiben — nur anhängend, zur Handhabbarkeit nach Datum partitioniert
bronze_df.write.format("delta") \
    .mode("append") \
    .partitionBy("init_time") \
    .saveAsTable("bronze.weather_raw")

Warum die Bronze-Tabelle Gitterkoordinaten behält

Bronze ist roh und unveränderlich. Die Tabelle speichert grid_lat / grid_lon — nicht asset_id. Der geo-räumliche Join auf Assets passiert in der Transformation Bronze → Silver (behandelt in Lektion 2). Das heißt: Wenn du später neue Assets hinzufügst, kannst du Bronze neu verarbeiten, ohne die Daten erneut herunterzuladen.

Bronze-Schema

-- Bronze-Schicht: rohe Wettergitterdaten unverändert aus der Quelle
CREATE TABLE bronze.weather_raw (
  grid_lat        DOUBLE NOT NULL,
  grid_lon        DOUBLE NOT NULL,
  valid_time      TIMESTAMP NOT NULL,     -- für welchen Zeitpunkt die Prognose gilt
  init_time       TIMESTAMP NOT NULL,     -- wann der Modelllauf startete
  wind_u_ms       DOUBLE,                 -- Windkomponente nach Osten
  wind_v_ms       DOUBLE,                 -- Windkomponente nach Norden
  temperature_k   DOUBLE,                 -- 2-m-Temperatur in Kelvin
  irradiance_jm2  DOUBLE,                 -- solare Einstrahlung an der Oberfläche
  pressure_pa     DOUBLE,
  source_model    STRING NOT NULL,        -- "ecmwf_ifs", "gfs", "icon"
  ingested_at     TIMESTAMP NOT NULL      -- Verarbeitungszeitpunkt der Pipeline
)
USING DELTA
PARTITIONED BY (init_time)
TBLPROPERTIES (
  'delta.autoOptimize.optimizeWrite' = 'true',
  'delta.autoOptimize.autoCompact'   = 'true',
  'delta.logRetentionDuration'       = '30 days'
);
Designentscheidung: in Bronze nach init_time partitionieren

Jeder Modelllauf (init_time) erzeugt einen Datenbatch. Die Partitionierung nach init_time bedeutet: (1) Das Anhängen eines neuen Prognoselaufs erzeugt genau eine neue Partition — kein Neuschreiben, (2) das Neuverarbeiten eines bestimmten Laufs ist ein gezielter Lesevorgang, (3) Aufbewahrungsrichtlinien können alte init_times sauber verwerfen.

Die Pipeline orchestrieren

Die Wetter-Ingestion läuft nach einem festen Zeitplan, der an die Verfügbarkeit der Modelle gekoppelt ist. So bildet sich das auf Databricks Workflows ab:

Ingestion-Workflow — durch Zeitplan ausgelöst Task 1: Prüfen Quelle (API/S3) abfragen Neuer Lauf verfügbar? Task 2: Download GRIB2 laden in Cloud-Storage (ADLS/S3) Task 3: Parsen cfgrib/xarray → Spark → Bronze-Tabelle Task 4: Bronze → Silver Geo-Join auf Assets → weather_ts je Asset Konfiguration des Databricks Workflows Zeitplan: cron 0 5,11,17,23 * * * (4× täglich, ca. 5 h Versatz zum Modelllauf wegen Verfügbarkeit) Cluster: Job-Cluster, Single-Node (Parsen ist I/O-gebunden, nicht rechengebunden bei einem Modell)

Idempotenz — nicht verhandelbar

Netzwerkausfälle, API-Timeouts und doppelte Trigger passieren. Deine Ingestion muss idempotent sein: Läuft derselbe Job zweimal für dieselbe init_time, dürfen keine doppelten Daten entstehen.

# Muster: MERGE erzwingt Idempotenz in Bronze
# Existiert init_time + grid_lat + grid_lon + valid_time bereits, wird übersprungen

bronze_df.createOrReplaceTempView("new_weather")

spark.sql("""
  MERGE INTO bronze.weather_raw AS target
  USING new_weather AS source
  ON  target.init_time   = source.init_time
  AND target.grid_lat    = source.grid_lat
  AND target.grid_lon    = source.grid_lon
  AND target.valid_time  = source.valid_time
  AND target.source_model = source.source_model
  WHEN NOT MATCHED THEN INSERT *
""")

💡 Alternative: Anhängen + Deduplizieren in Silver

Manche Teams bevorzugen ein einfacheres Bronze (blindes Anhängen) und behandeln die Deduplizierung in der Silver-Schicht. Der Zielkonflikt: Bronze wächst schneller, dafür wird die Silver-Logik komplexer. Bei Wetterdaten mit klaren natürlichen Schlüsseln (init_time, Gitterpunkt, valid_time) ist MERGE in Bronze sauber und verhindert Verschwendung früh.

Überlegungen zur Skalierung

Wie groß sind Wetterdaten? Schätzen wir das für eine typische ECMWF-Prognose ab:

DimensionWertAnmerkungen
Gitterpunkte (global 0,25°)ca. 1 Million721 Lat × 1440 Lon
Variablen5–15Wind, Temperatur, Einstrahlung, Druck…
Zeitschritte je Laufca. 81Stündlich bis 3–5 Tage voraus
Zeilen je Lauf (flach gezogen)ca. 400 Mio.–1,2 Mrd.Gitter × Zeit × Variable
Größe der GRIB2-Dateica. 2–10 GBKomprimiertes Binärformat
Größe der Delta-Tabelle (ein Lauf)ca. 15–50 GBParquet, Snappy-komprimiert
Du brauchst keine globalen Daten

Wenn deine Windparks in der Nordsee stehen, brauchst du nur einen geografischen Ausschnitt. Ein Filter per Bounding Box beim Download oder Parsen reduziert die Datenmenge um über 95 %. Die meisten APIs unterstützen räumliches Zuschneiden schon bei der Anfrage (MARS/CDS beim ECMWF, GFS via grib_filter). Filtere immer so früh wie möglich.

# Beim Parsen auf das relevante Gebiet zuschneiden (z. B. Nordsee + Nordeuropa)
ds = xr.open_dataset("forecast.grib2", engine="cfgrib")
ds_subset = ds.sel(
    latitude=slice(62, 48),    # Region Nordsee
    longitude=slice(-5, 15)
)
# Reduziert ca. 1 Mio. Gitterpunkte auf ca. 5.000 — auf einem Single-Node handhabbar

Mehrere Wettermodelle handhaben

Dein Team nutzt vermutlich mehrere NWP-Quellen (ECMWF für Genauigkeit, GFS als Backup, ICON für mitteleuropäische Details). Die Spalte source_model in Bronze deckt das ganz natürlich ab:

-- Abfrage: Prognosen verschiedener Modelle für dieselbe valid_time vergleichen
SELECT
  source_model,
  AVG(wind_u_ms) as mean_wind_u,
  AVG(temperature_k) as mean_temp
FROM bronze.weather_raw
WHERE init_time = '2026-07-17T00:00:00Z'
  AND valid_time = '2026-07-18T12:00:00Z'
  AND grid_lat BETWEEN 54.0 AND 56.0
  AND grid_lon BETWEEN 5.0 AND 8.0
GROUP BY source_model

In der Silver-Schicht entscheidest du, welches Modell die Produktionsprognose speist (meist das aktuellste verfügbare oder eine Ensemble-Mischung). Diese Entscheidung gehört in Silver — Bronze speichert alle Modelle gleichberechtigt.

Cluster-Konfiguration für die Ingestion

AspektEmpfehlungWarum
Cluster-Typ Job-Cluster (kurzlebig) Startet für den Job, beendet sich danach. Keine Leerlaufkosten.
Anzahl Nodes Single-Node (für Teilausschnitte) cfgrib/xarray laufen im Driver. Parallelität kommt aus mehreren Tasks, nicht aus mehreren Nodes.
Instanztyp Speicheroptimiert (z. B. Standard_E4s_v3) xarray lädt das gesamte Gitter zum Zuschneiden in den Arbeitsspeicher.
Bibliotheken Init-Skript: eccodes, cfgrib, xarray installieren Nicht in der Standard-Databricks-Runtime enthalten. Versionen für Reproduzierbarkeit fixieren.
Parallelität Mehrere Workflow-Tasks (einer je Modellquelle) ECMWF, GFS und ICON laufen als parallele Tasks im selben Workflow.
Parallele Ingestion-Tasks in einem Workflow ECMWF einlesen GFS einlesen ICON einlesen Bronze → Silver (Geo-Join)

Monitoring & Alerting

Die Wetter-Ingestion hat strenge Anforderungen an Aktualität — veraltete Prognosen verschlechtern deine Stromvorhersagen. Baue diese Prüfungen in deine Pipeline ein:

# Beispiel: Aktualitätsprüfung in einem Databricks-Notebook-Task
from datetime import datetime, timedelta, timezone

latest = spark.sql("""
  SELECT MAX(init_time) as latest_init FROM bronze.weather_raw
  WHERE source_model = 'ecmwf_ifs'
""").collect()[0]["latest_init"]

threshold = datetime.now(timezone.utc) - timedelta(hours=8)
assert latest >= threshold, f"STALE DATA: latest init_time is {latest}, expected >= {threshold}"

Prüfe dein Verständnis

Eine GRIB2-Datei vom ECMWF enthält Prognosen, initialisiert um 00:00 UTC, mit Gültigkeitszeiten +1 h, +2 h, … +72 h. Welche Spalte erfasst „wann der Modelllauf startete"?

Richtig. init_time (auch Referenz- oder Analysezeit genannt) markiert den Start des Modelllaufs. valid_time ist der Zeitpunkt, für den die Prognose gilt. Du brauchst beide — zusammen sagen sie dir: „die Vorhersage des 00Z-Laufs für Stunde +24".
init_time erfasst, wann der Modelllauf startete. valid_time ist der Zeitpunkt, für den die Prognose gilt. ingested_at ist der Moment, in dem deine Pipeline sie verarbeitet hat. Du brauchst alle drei, aber init_time beantwortet „welcher Modelllauf".

Warum ist die Bronze-Wettertabelle nach init_time partitioniert und nicht nach Gitterkoordinaten?

Genau. Die Ingestion schreibt immer einen vollständigen Modelllauf auf einmal. Die Partitionierung nach init_time bedeutet: Jeder Schreibvorgang erzeugt eine Partition (keine verstreuten Updates), das Neuverarbeiten zielt auf genau einen Lauf, und die Aufbewahrung verwirft alte Läufe sauber. Das entspricht der natürlichen Batch-Grenze der Daten.
Es geht um die Ausrichtung am Schreibmuster. Jeder Modelllauf ist ein atomarer Batch. Mit init_time als Partitionsspalte arbeiten Schreiben, Neuverarbeiten und Aufbewahrung alle auf natürlichen Dateneinheiten — jeweils ein Modelllauf.

Dein ECMWF-Ingestion-Job läuft um 05:00 UTC, aber der Modelllauf ist noch nicht verfügbar. Was sollte Task 1 (Prüfen) tun?

Richtig. Die Auslieferung von Wettermodellen verzögert sich gelegentlich. Der Prüf-Task sollte sauber beenden (nicht fehlschlagen) und den nächsten geplanten Trigger erneut versuchen lassen. Ein separates Alerting greift, wenn die Daten über einen Schwellwert hinaus ausbleiben (z. B. 8 h). Behandle normale Verzögerung nicht als Fehler.
Der richtige Ansatz ist der elegante Wiederholungsversuch. Die Auslieferung von Wetterdaten schwankt naturgemäß. Task 1 beendet sauber („keine neuen Daten"), und der nächste Zeitplan-Trigger versucht es erneut. Ein separater Aktualitätsalarm feuert nur, wenn die Verzögerung einen Schwellwert überschreitet.

Deine Windparks liegen alle in der Nordsee (Lat 52–58°, Lon 2–9°). Das globale ECMWF-Gitter hat ca. 1 Million Punkte. Was ist die wirkungsvollste Optimierung?

Genau. Wenn deine Assets in einer Region liegen, ist das Herunterladen oder Parsen des gesamten globalen Gitters Verschwendung. Der Zuschnitt auf eine Bounding Box zum frühestmöglichen Zeitpunkt (API-Anfrage oder xarray .sel()) reduziert das Datenvolumen um über 95 % und macht die Verarbeitung auf einem Single-Node machbar.
Der geografische Zuschnitt ist die Antwort. Die meisten Wetter-APIs unterstützen Bounding-Box-Filter bereits bei der Anfrage. Und selbst wenn nicht, ist xarrays .sel() auf Breiten-/Längengrad nach dem Laden weit günstiger, als das komplette globale Gitter durch die Pipeline zu schicken.

Primärquelle

📖 Empfohlene Lektüre

Databricks Workflows documentation — behandelt das Anlegen von Jobs, Task-Abhängigkeiten, Zeitpläne (cron), Retries und Cluster-Policies. Lies die Abschnitte „Create and manage jobs" und „Configure compute for jobs". Schau dir danach den Datenzugriffs-Leitfaden des ECMWF an, um zu verstehen, wie Wetterdaten tatsächlich abgerufen werden.

Wie geht es weiter

Du verstehst jetzt, wie Wetterdaten nach Databricks gelangen. Die nächste Lektion behandelt die Transformation Bronze → Silver im Detail — den geo-räumlichen Join, die Berechnung der Windgeschwindigkeit aus u/v-Komponenten, Einheitenumrechnungen und den Umgang mit mehreren Prognoseversionen (Init-Zeiten) für dieselbe Gültigkeitszeit.


Fragen zum GRIB2-Parsing, zu Zeitplänen oder zur Cluster-Dimensionierung? Frag deinen Lehrer — ich kann tiefer in die cfgrib-Konfiguration, Multi-Modell-Ensemble-Strategien oder eine Kostenschätzung für deine Flottengröße einsteigen.

Rechtliches: Impressum