English version

← Learning Hub Lektion 4 Dauer: ca. 15 Min. Thema: Silver-Layer-Transformation

Bronze → Silver: Die geo-räumliche Transformation

Diese Lektion behandelt die architektonisch wichtigste Transformation deiner Pipeline: aus rohen Wetterdaten auf einem Raster werden anlagenbezogene Zeitreihen. Am Ende verstehst du den geo-räumlichen Join, die Berechnung abgeleiteter Größen, die Versionierung von Prognosen und wie du das Ganze als verlässliche, inkrementelle Silver-Pipeline aufbaust.

Was der Silver Layer leisten muss

Der Schritt von Bronze nach Silver löst vier Probleme auf einmal:

  1. Räumliche Zuordnung: Rasterkoordinaten (lat/lon) → Asset-IDs
  2. Ableitung von Größen: Rohkomponenten (Wind u/v) → nutzbare Werte (Windgeschwindigkeit, Windrichtung)
  3. Einheiten-Normalisierung: Kelvin → Celsius, J/m² → W/m² usw.
  4. Prognose-Versionierung: Mehrere init_times prognostizieren dieselbe valid_time — welche gewinnt?

Nach Silver sieht jeder Konsument (ML-Pipeline, Analystin, nachgelagerte Anwendung) saubere, anlagenbezogene und korrekt typisierte Zeitreihen. Mit Rastergeometrie oder Roheinheiten kommt niemand mehr in Berührung.

Schritt 1: Der geo-räumliche Join

Wetterraster und Anlagen leben in unterschiedlichen Koordinatensystemen. Der Join ordnet jede Anlage der nächstgelegenen Rasterzelle zu — oder interpoliert zwischen mehreren Zellen.

Nächster Nachbar (der einfachste Ansatz)

-- Jede Anlage mit dem nächstgelegenen Rasterpunkt verknüpfen
-- Funktioniert gut, wenn die Rasterauflösung (0,25° ≈ 28 km) gröber ist als der Anlagenabstand
CREATE OR REPLACE TABLE silver.weather_ts AS
SELECT
  a.asset_id,
  w.valid_time         AS timestamp,
  w.init_time          AS forecast_ref_time,
  w.wind_u_ms,
  w.wind_v_ms,
  w.temperature_k,
  w.irradiance_jm2,
  w.pressure_pa,
  w.source_model
FROM bronze.weather_raw w
JOIN silver.dim_assets a
  ON ROUND(w.grid_lat / 0.25) * 0.25 = ROUND(a.latitude / 0.25) * 0.25
  AND ROUND(w.grid_lon / 0.25) * 0.25 = ROUND(a.longitude / 0.25) * 0.25
WHERE w.init_time = '2026-07-17T00:00:00Z'  -- immer nur einen Lauf verarbeiten
Warum ROUND auf die Rasterauflösung?

Wenn du Rasterpunkte und Anlagenstandorte auf dieselbe Auflösung (0,25°) einrastest, wird aus dem Join ein simpler Gleichheitsvergleich — ganz ohne teure Distanzberechnungen. Das funktioniert, weil du die nächstgelegene Rasterzelle akzeptierst. Für Genauigkeit unterhalb der Rasterweite nimmst du bilineare Interpolation (siehe unten).

Bilineare Interpolation (höhere Genauigkeit)

Wenn Anlagenstandorte zwischen Rasterzellen liegen und Genauigkeit zählt (z. B. bei Offshore-Windparks mit starken Windgradienten), interpolierst du aus den 4 umgebenden Rasterpunkten:

from pyspark.sql import functions as F
from pyspark.sql.window import Window

def bilinear_interpolate(weather_df, assets_df, grid_res=0.25):
    """
    Für jede Anlage die 4 umgebenden Rasterzellen finden und interpolieren.
    Liefert eine Zeile pro Anlage × valid_time mit interpolierten Wetterwerten.
    """
    # Umschließende Rasterecken für jede Anlage berechnen
    assets_corners = assets_df.select(
        "asset_id", "latitude", "longitude",
        # Auf die Rasterauflösung ab- und aufrunden
        (F.floor(F.col("latitude") / grid_res) * grid_res).alias("lat_lo"),
        (F.ceil(F.col("latitude") / grid_res) * grid_res).alias("lat_hi"),
        (F.floor(F.col("longitude") / grid_res) * grid_res).alias("lon_lo"),
        (F.ceil(F.col("longitude") / grid_res) * grid_res).alias("lon_hi"),
    )

    # Wetterdaten joinen, um alle 4 Ecken zu erhalten
    corners = assets_corners.join(
        weather_df,
        on=(
            (weather_df.grid_lat.isin(
                assets_corners.lat_lo, assets_corners.lat_hi)) &
            (weather_df.grid_lon.isin(
                assets_corners.lon_lo, assets_corners.lon_hi))
        ),
        how="inner"
    )

    # Gewichte anhand der Distanz zu jeder Ecke berechnen
    corners = corners.withColumn(
        "w_lat", 
        1.0 - F.abs(F.col("latitude") - F.col("grid_lat")) / grid_res
    ).withColumn(
        "w_lon",
        1.0 - F.abs(F.col("longitude") - F.col("grid_lon")) / grid_res
    ).withColumn(
        "weight", F.col("w_lat") * F.col("w_lon")
    )

    # Gewichteter Mittelwert je Anlage × valid_time
    return corners.groupBy("asset_id", "valid_time", "init_time", "source_model") \
        .agg(
            F.sum(F.col("wind_u_ms") * F.col("weight")).alias("wind_u_ms"),
            F.sum(F.col("wind_v_ms") * F.col("weight")).alias("wind_v_ms"),
            F.sum(F.col("temperature_k") * F.col("weight")).alias("temperature_k"),
            F.sum(F.col("irradiance_jm2") * F.col("weight")).alias("irradiance_jm2"),
            F.sum(F.col("pressure_pa") * F.col("weight")).alias("pressure_pa"),
        )

💡 Wann nimmst du was?

Nächster Nachbar: Flotte von Onshore-Parks mit mindestens 5 km Abstand, schnell umgesetzt, bei 0,25° Auflösung ist der Genauigkeitsverlust vernachlässigbar.
Bilineare Interpolation: Offshore-Cluster mit starker Windscherung oder hochauflösende Modelle (ICON mit 0,0625°), bei denen die Rasterstruktur ins Gewicht fällt.

Schritt 2: Ableitung der Größen

Wettermodelle liefern rohe physikalische Größen. Deine ML-Modelle und deine Analystinnen brauchen abgeleitete, aussagekräftige Variablen.

Roh (Bronze)Abgeleitet (Silver)Formel
wind_u_ms, wind_v_ms wind_speed_ms $\sqrt{u^2 + v^2}$
wind_u_ms, wind_v_ms wind_direction_deg $\text{atan2}(-u, -v) \times \frac{180}{\pi} \mod 360$
temperature_k temperature_c $T_K - 273.15$
irradiance_jm2 (kumuliert) irradiance_wm2 (momentan) $\frac{\Delta J/m^2}{\Delta t_{Sekunden}}$
wind_speed_ms (in 10 m) wind_speed_hub_height $v_{10} \times \left(\frac{h_{hub}}{10}\right)^{\alpha}$ (Potenzgesetz)
from pyspark.sql import functions as F
import math

def derive_weather_variables(df, hub_height_m=100.0, roughness_alpha=0.14):
    """
    Rohe Wetterkomponenten in nutzbare abgeleitete Größen umwandeln.
    Setzt voraus, dass df enthält: wind_u_ms, wind_v_ms, temperature_k, irradiance_jm2
    """
    return df.withColumn(
        "wind_speed_ms",
        F.sqrt(F.col("wind_u_ms")**2 + F.col("wind_v_ms")**2)
    ).withColumn(
        "wind_direction_deg",
        (F.degrees(F.atan2(-F.col("wind_u_ms"), -F.col("wind_v_ms"))) + 360) % 360
    ).withColumn(
        "temperature_c",
        F.col("temperature_k") - 273.15
    ).withColumn(
        # Extrapolation auf Nabenhöhe über das Potenzgesetz
        # α = 0.14 ist typisch für offenes Gelände / Offshore
        "wind_speed_hub_ms",
        F.col("wind_speed_ms") * F.pow(F.lit(hub_height_m / 10.0), F.lit(roughness_alpha))
    ).drop("wind_u_ms", "wind_v_ms", "temperature_k")
Extrapolation auf Nabenhöhe

Wettermodelle liefern Wind typischerweise in 10 m oder 100 m über Grund. Turbinen haben Nabenhöhen von 80–170 m. Das Potenzgesetz-Profil ($v_h = v_{ref} \times (h/h_{ref})^\alpha$) extrapoliert von der Referenzhöhe aus. Der Exponent α hängt von der Geländerauigkeit ab (0,10 offshore, 0,14 offenes Land, 0,25+ bewaldet). Speichere α je Anlage in dim_assets.metadata.

Schritt 3: Einheiten-Normalisierung

Silver ist der eine Ort, an dem Einheiten vereinheitlicht werden. Dokumentiere sie klar:

-- Konventionen im Silver Layer (in den Unity-Catalog-Tabellenkommentaren dokumentieren)
-- wind_speed_ms:       m/s (auf 10 m Referenzhöhe)
-- wind_speed_hub_ms:   m/s (auf die Nabenhöhe der Anlage extrapoliert)
-- wind_direction_deg:  Grad von Nord, im Uhrzeigersinn (meteorologische Konvention)
-- temperature_c:       Celsius
-- irradiance_wm2:      W/m² (momentane Leistung, keine kumulierte Energie)
-- pressure_hpa:        Hektopascal (= Millibar)
-- timestamp:           UTC, kein Zeitzonen-Offset gespeichert

📐 Einstrahlung: kumuliert → momentan

ECMWF gibt Solarstrahlung als seit dem Modell-Initialisierungszeitpunkt kumulierte J/m² aus. Für momentane W/m² bildest du die Differenz zwischen aufeinanderfolgenden Zeitschritten und teilst durch das Schrittintervall in Sekunden: (ssrd[t] - ssrd[t-1]) / 3600 bei Stundenschritten. Das ist eine klassische Stolperfalle.

Schritt 4: Prognose-Versionierung

Mehrere Modellläufe sagen denselben Zeitpunkt in der Zukunft voraus. Der 00Z-Lauf prognostiziert den morgigen Mittag — und der 06Z-Lauf ebenfalls, nur mit frischeren Daten. Welchen soll Silver ausliefern?

Mehrere init_times prognostizieren dieselbe valid_time 00Z Init 17. Jul 06Z Init 17. Jul 12Z Init 17. Jul valid_time 18. Jul 12:00Z +36 h Vorlaufzeit +30 h Vorlaufzeit +24 h Vorlaufzeit Der 12Z-Lauf hat die kürzeste Vorlaufzeit → frischeste Daten → meist am genauesten Für Modelltraining und Backtesting willst du aber oft alle Versionen behalten

Strategie: alle Versionen behalten, die neueste markieren

-- Die Silver-Tabelle speichert ALLE Prognoseversionen
-- Eine berechnete Spalte oder ein View markiert die "aktuellste verfügbare" je valid_time

CREATE OR REPLACE VIEW silver.weather_ts_latest AS
WITH ranked AS (
  SELECT *,
    ROW_NUMBER() OVER (
      PARTITION BY asset_id, timestamp, source_model
      ORDER BY forecast_ref_time DESC  -- der jüngste Init gewinnt
    ) AS version_rank
  FROM silver.weather_ts
)
SELECT * FROM ranked WHERE version_rank = 1;
Architekturentscheidung: alles speichern, das Neueste ausliefern

Behalte jede Prognoseversion in der Silver-Tabelle (mit forecast_ref_time). Lege einen View oder eine materialisierte Tabelle an, die je valid_time nur die neueste Version zeigt. Das gibt dir:

Die vollständige Silver-Pipeline

Alles zusammengesetzt als ein einziger inkrementeller Job:

from pyspark.sql import functions as F

def bronze_to_silver(spark, init_time: str, source_model: str):
    """
    Einen Modelllauf von Bronze nach Silver verarbeiten.
    Idempotent: überschreibt die Partition für diese init_time + source_model.
    """
    # 1. Den neuen Bronze-Batch lesen
    bronze = spark.read.table("bronze.weather_raw") \
        .filter(F.col("init_time") == init_time) \
        .filter(F.col("source_model") == source_model)

    # 2. Asset-Dimension laden (kleine Tabelle — Broadcast Join)
    assets = spark.read.table("silver.dim_assets") \
        .filter(F.col("asset_type").isin("wind_park", "turbine"))

    # 3. Geo-räumlicher Join (nächstgelegener Rasterpunkt)
    grid_res = 0.25
    assets_snapped = assets.withColumn(
        "snap_lat", F.round(F.col("latitude") / grid_res) * grid_res
    ).withColumn(
        "snap_lon", F.round(F.col("longitude") / grid_res) * grid_res
    )
    
    bronze_snapped = bronze.withColumn(
        "snap_lat", F.round(F.col("grid_lat") / grid_res) * grid_res
    ).withColumn(
        "snap_lon", F.round(F.col("grid_lon") / grid_res) * grid_res
    )

    joined = bronze_snapped.join(
        F.broadcast(assets_snapped.select(
            "asset_id", "snap_lat", "snap_lon", "hub_height_m"
        )),
        on=["snap_lat", "snap_lon"],
        how="inner"
    )

    # 4. Größen ableiten
    silver = joined.withColumn(
        "wind_speed_ms",
        F.sqrt(F.col("wind_u_ms")**2 + F.col("wind_v_ms")**2)
    ).withColumn(
        "wind_direction_deg",
        (F.degrees(F.atan2(-F.col("wind_u_ms"), -F.col("wind_v_ms"))) + 360) % 360
    ).withColumn(
        "temperature_c", F.col("temperature_k") - 273.15
    ).withColumn(
        "wind_speed_hub_ms",
        F.col("wind_speed_ms") * F.pow(
            F.col("hub_height_m") / 10.0, F.lit(0.14)
        )
    ).withColumn(
        "pressure_hpa", F.col("pressure_pa") / 100.0
    )

    # 5. Finales Silver-Schema auswählen
    result = silver.select(
        "asset_id",
        F.col("valid_time").alias("timestamp"),
        F.col("init_time").alias("forecast_ref_time"),
        "wind_speed_ms",
        "wind_speed_hub_ms",
        "wind_direction_deg",
        "temperature_c",
        F.col("irradiance_jm2").alias("irradiance_jm2"),  # Umrechnung nachgelagert
        "pressure_hpa",
        "source_model"
    )

    # 6. Schreiben — MERGE für Idempotenz
    result.createOrReplaceTempView("new_silver")
    spark.sql("""
        MERGE INTO silver.weather_ts AS target
        USING new_silver AS source
        ON  target.asset_id         = source.asset_id
        AND target.timestamp        = source.timestamp
        AND target.forecast_ref_time = source.forecast_ref_time
        AND target.source_model     = source.source_model
        WHEN MATCHED THEN UPDATE SET *
        WHEN NOT MATCHED THEN INSERT *
    """)

    return result.count()

Inkrementelle Verarbeitung

Du verarbeitest nicht bei jedem Lauf ganz Bronze neu. Die Pipeline soll nur neue Daten seit dem letzten Lauf transformieren.

AnsatzFunktionsweiseWann einsetzen
Parametergesteuert Der Workflow übergibt init_time als Parameter an den Silver-Task Einfache, vorhersehbare Batches (ein Modelllauf pro Trigger)
Watermark-basiert Silver führt eine eigene High Watermark (MAX(forecast_ref_time)) und liest nur neuere Bronze-Zeilen Aufholszenarien, verspätet eintreffende Daten
Change Data Feed (CDF) Die Bronze-Tabelle hat CDF aktiviert; Silver liest nur Änderungssätze seit dem letzten Checkpoint Streaming- oder Near-Realtime-Anwendungsfälle
# Parametergesteuerter Ansatz (am einfachsten, empfohlen für den Start)
# Der Workflow übergibt init_time als Widget-Parameter
dbutils.widgets.text("init_time", "")
dbutils.widgets.text("source_model", "ecmwf_ifs")

init_time = dbutils.widgets.get("init_time")
source_model = dbutils.widgets.get("source_model")

rows = bronze_to_silver(spark, init_time, source_model)
print(f"Processed {rows} rows for {source_model} @ {init_time}")

Datenqualitätsprüfungen in Silver

Silver ist die Stelle, an der du Datenqualität durchsetzt. Wenn Bronze "alles speichern" heißt, dann heißt Silver "vertrauen, aber prüfen".

# Qualitätserwartungen — der Batch scheitert bei Verletzung
from pyspark.sql import functions as F

def validate_silver_batch(df):
    """Wirft einen Fehler, wenn Qualitätsschwellen überschritten werden."""
    stats = df.agg(
        F.count("*").alias("total_rows"),
        F.sum(F.when(F.col("wind_speed_ms") < 0, 1).otherwise(0)).alias("negative_wind"),
        F.sum(F.when(F.col("wind_speed_ms") > 60, 1).otherwise(0)).alias("extreme_wind"),
        F.sum(F.when(F.col("temperature_c") < -60, 1).otherwise(0)).alias("extreme_cold"),
        F.sum(F.when(F.col("temperature_c") > 60, 1).otherwise(0)).alias("extreme_hot"),
        F.sum(F.when(F.col("wind_speed_ms").isNull(), 1).otherwise(0)).alias("null_wind"),
    ).collect()[0]
    
    total = stats["total_rows"]
    issues = []
    
    if stats["negative_wind"] > 0:
        issues.append(f"Negative wind speed: {stats['negative_wind']} rows")
    if stats["extreme_wind"] > total * 0.001:
        issues.append(f"Extreme wind (>60 m/s): {stats['extreme_wind']} rows")
    if stats["null_wind"] > total * 0.05:
        issues.append(f"Null wind speed: {stats['null_wind']} rows ({stats['null_wind']/total*100:.1f}%)")
    
    if issues:
        raise ValueError(f"Silver quality check FAILED:\n" + "\n".join(issues))
    
    print(f"✓ Quality check passed: {total} rows, no issues")

📋 Alternative: DLT

Wenn du dich für Delta Live Tables im Silver Layer entscheidest, werden aus diesen Prüfungen @dlt.expect_or_fail("wind_speed_positive", "wind_speed_ms >= 0")-Dekoratoren — deklarativ und direkt in der Pipeline-Definition verankert. Der Preis dafür (besprochen in Lektion 1) ist weniger Flexibilität bei eigener Parsing-Logik.

Verständnis prüfen

Die Windkomponenten u=−3 m/s und v=−4 m/s liegen in Bronze. Welche Windgeschwindigkeit landet in Silver?

Richtig. Windgeschwindigkeit = √(u² + v²) = √(9 + 16) = √25 = 5 m/s. Die Komponenten sind eine Vektorzerlegung (nach Osten und nach Norden). Der Betrag ergibt sich pythagoreisch, unabhängig vom Vorzeichen.
Windgeschwindigkeit = √(u² + v²) = √((-3)² + (-4)²) = √(9 + 16) = √25 = 5 m/s. Die u- und v-Komponenten sind orthogonale Vektoren; ihr Betrag ist immer die euklidische Norm.

Drei Modellläufe (00Z, 06Z, 12Z) prognostizieren alle morgen 12:00. Die Produktionspipeline braucht die beste Vorhersage. Welche Strategie erhält alle Daten und liefert trotzdem die frischeste aus?

Richtig. Alle Versionen in Silver zu behalten sichert die Daten für Training, Backtesting und Analysen zur Prognosegüte. Ein View (oder eine materialisierte Schicht) mit ROW_NUMBER, partitioniert nach asset_id + timestamp, liefert der Produktionspipeline die frischeste Prognose, ohne Historie zu verlieren.
Alles speichern, das Neueste ausliefern. Alle Versionen zu behalten ermöglicht Analysen zur Prognosegüte und Backtesting. Ein View (ROW_NUMBER OVER PARTITION BY asset_id, timestamp ORDER BY forecast_ref_time DESC WHERE rank = 1) zeigt der Pipeline nur die frischeste Version.

Die Tabelle dim_assets enthält 200 Turbinen (~1 KB). Die Bronze-Wetterdaten eines Modelllaufs umfassen 5 GB. Wie sollte der geo-räumliche Join ausgeführt werden?

Genau. dim_assets ist winzig (~200 Zeilen). Sie an alle Executors zu broadcasten vermeidet den Shuffle der 5 GB großen Bronze-Tabelle. Spark macht das unterhalb des Broadcast-Schwellwerts (standardmäßig 10 MB) automatisch, aber ein explizites F.broadcast() macht die Absicht klar und ist robust gegenüber Schwellwertänderungen.
Der Broadcast Join ist korrekt. Wenn eine Tabelle winzig ist (200 Zeilen ≈ 1 KB), vermeidet ihre Replikation auf alle Executors jeden Shuffle der großen Tabelle. Bei dieser Größenasymmetrie ist das um Größenordnungen schneller als Sort-Merge oder Shuffle-Hash.

Deine Silver-Pipeline ist parametergesteuert: sie bekommt init_time vom Workflow. Der 06Z-Modelllauf trifft 2 Stunden zu spät ein. Was passiert?

Richtig. Der Prüf-Task (Task 1 aus Lektion 3) hält die Pipeline zurück. Ist der Modelllauf noch nicht verfügbar, endet der Prüf-Task sauber und der nachgelagerte Silver-Task läuft gar nicht erst. Der nächste geplante Trigger versucht es erneut. Das Alerting zur Aktualität läuft davon getrennt.
Der Prüf-Task (aus Lektion 3) verhindert genau dieses Szenario. Er gibt init_time nur dann weiter, wenn die Daten tatsächlich in Bronze verfügbar sind. Andernfalls endet der Workflow sauber und der nächste geplante Trigger versucht es erneut.

Primärquelle

📖 Empfohlene Lektüre

MERGE INTO — Databricks-Dokumentation — behandelt Upsert-Muster, Merge-Bedingungen und Performance-Tuning. Das ist das Arbeitspferd für idempotente Silver-Schreibvorgänge. Sieh dir außerdem Broadcast Join Hints an, um zu verstehen, wann und wie du einen Broadcast erzwingst.

Wie es weitergeht

Nachdem die Wetterdaten jetzt als saubere, anlagenbezogene Zeitreihen in Silver liegen, behandelt die nächste Lektion die Unity-Catalog-Governance — wie du Kataloge, Schemas und Berechtigungen so organisierst, dass dein Team (Data Engineers, Data Scientists, Analystinnen) sicher an diesen Tabellen zusammenarbeiten kann.


Fragen zur Interpolationsgenauigkeit, zur Strategie der Prognose-Versionierung oder zur MERGE-Performance im großen Maßstab? Frag deinen Lehrer — ich kann konkrete Szenarien mit deiner Flottengröße und deinen Datenvolumina durchspielen.

Rechtliches: Impressum