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.
Der Schritt von Bronze nach Silver löst vier Probleme auf einmal:
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.
Wetterraster und Anlagen leben in unterschiedlichen Koordinatensystemen. Der Join ordnet jede Anlage der nächstgelegenen Rasterzelle zu — oder interpoliert zwischen mehreren Zellen.
-- 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
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).
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.
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")
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.
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.
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?
-- 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;
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:
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()
Du verarbeitest nicht bei jedem Lauf ganz Bronze neu. Die Pipeline soll nur neue Daten seit dem letzten Lauf transformieren.
| Ansatz | Funktionsweise | Wann 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}")
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.
Die Windkomponenten u=−3 m/s und v=−4 m/s liegen in Bronze. Welche Windgeschwindigkeit landet in Silver?
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?
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?
Deine Silver-Pipeline ist parametergesteuert: sie bekommt init_time vom Workflow. Der 06Z-Modelllauf trifft 2 Stunden zu spät ein. Was passiert?
📖 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.
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