This lesson covers the most architecturally significant transformation in your pipeline: turning raw gridded weather data into asset-keyed time series. By the end, you'll understand the geo-spatial join, derived variable calculations, forecast versioning, and how to structure this as a reliable, incremental Silver pipeline.
The Bronze → Silver step resolves four problems at once:
After Silver, every consumer (ML pipeline, analyst, downstream app) sees clean, asset-keyed, properly-typed time series. They never touch grid geometry or raw units again.
Weather grids and assets live in different coordinate systems. The join maps each asset to its nearest grid cell (or interpolates between cells).
-- Join each asset to the nearest grid point
-- Works well when grid resolution (0.25° ≈ 28km) is coarser than asset spacing
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' -- process one run at a time
Snapping both grid points and asset locations to the same resolution (0.25°) makes the join a simple equality match — no expensive distance calculations. This works because you accept the nearest grid cell. For sub-grid accuracy, use bilinear interpolation (see below).
When asset locations fall between grid cells and accuracy matters (e.g., offshore wind farms where wind gradients are steep), interpolate from the 4 surrounding grid points:
from pyspark.sql import functions as F
from pyspark.sql.window import Window
def bilinear_interpolate(weather_df, assets_df, grid_res=0.25):
"""
For each asset, find the 4 surrounding grid cells and interpolate.
Returns one row per asset × valid_time with interpolated weather values.
"""
# Calculate bounding grid corners for each asset
assets_corners = assets_df.select(
"asset_id", "latitude", "longitude",
# Floor and ceil to grid resolution
(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"),
)
# Join weather data to get all 4 corners
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"
)
# Compute weights based on distance to each corner
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")
)
# Weighted average per asset × 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"),
)
💡 When to use which?
Nearest-neighbour: Fleet of onshore parks with 5+ km spacing, quick to implement, negligible accuracy loss at 0.25° resolution.
Bilinear interpolation: Offshore clusters where wind shear is high, or high-resolution models (ICON at 0.0625°) where grid structure matters.
Weather models output raw physical quantities. Your ML models and analysts need derived, meaningful variables.
| Raw (Bronze) | Derived (Silver) | Formula |
|---|---|---|
| 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 (accumulated) | irradiance_wm2 (instantaneous) | $\frac{\Delta J/m^2}{\Delta t_{seconds}}$ |
| wind_speed_ms (at 10m) | wind_speed_hub_height | $v_{10} \times \left(\frac{h_{hub}}{10}\right)^{\alpha}$ (power law) |
from pyspark.sql import functions as F
import math
def derive_weather_variables(df, hub_height_m=100.0, roughness_alpha=0.14):
"""
Transform raw weather components into usable derived variables.
Assumes df has: 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(
# Hub-height extrapolation using power law
# α = 0.14 is typical for open terrain / 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")
Weather models typically output wind at 10m or 100m above ground. Turbines have hub heights of 80–170m. The power-law profile ($v_h = v_{ref} \times (h/h_{ref})^\alpha$) extrapolates from the reference height. The exponent α depends on terrain roughness (0.10 offshore, 0.14 open land, 0.25+ forested). Store α per asset in dim_assets.metadata.
Silver is the single place where units are standardised. Document them clearly:
-- Silver layer conventions (document in Unity Catalog table comments)
-- wind_speed_ms: m/s (at 10m reference height)
-- wind_speed_hub_ms: m/s (extrapolated to asset hub height)
-- wind_direction_deg: degrees from North, clockwise (meteorological convention)
-- temperature_c: Celsius
-- irradiance_wm2: W/m² (instantaneous power, not accumulated energy)
-- pressure_hpa: hectopascals (= millibars)
-- timestamp: UTC, no timezone offset stored
📐 Irradiance: Accumulated → Instantaneous
ECMWF outputs solar radiation as accumulated J/m² since the model init time. To get instantaneous W/m², compute the difference between consecutive time steps and divide by the step interval in seconds: (ssrd[t] - ssrd[t-1]) / 3600 for hourly steps. This is a common stumbling block.
Multiple model runs predict the same future time. The 00Z run predicts tomorrow noon, and so does the 06Z run (with fresher data). Which one should Silver expose?
-- Silver table stores ALL forecast versions
-- A computed column or view flags the "latest available" for each 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 -- most recent init wins
) AS version_rank
FROM silver.weather_ts
)
SELECT * FROM ranked WHERE version_rank = 1;
Keep every forecast version in the Silver table (with forecast_ref_time). Create a view or materialized table that exposes only the latest version per valid_time. This gives you:
Putting it all together as a single incremental job:
from pyspark.sql import functions as F
def bronze_to_silver(spark, init_time: str, source_model: str):
"""
Process one model run from Bronze → Silver.
Idempotent: overwrites the partition for this init_time + source_model.
"""
# 1. Read the new Bronze batch
bronze = spark.read.table("bronze.weather_raw") \
.filter(F.col("init_time") == init_time) \
.filter(F.col("source_model") == source_model)
# 2. Load asset dimension (small table — broadcast join)
assets = spark.read.table("silver.dim_assets") \
.filter(F.col("asset_type").isin("wind_park", "turbine"))
# 3. Geo-spatial join (nearest grid point)
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. Derive variables
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. Select final Silver schema
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"), # convert downstream
"pressure_hpa",
"source_model"
)
# 6. Write — MERGE for idempotency
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()
You don't reprocess all of Bronze every run. The pipeline should only transform new data since the last run.
| Approach | How It Works | When to Use |
|---|---|---|
| Parameter-driven | Workflow passes init_time as a parameter to the Silver task |
Simple, predictable batches (one model run per trigger) |
| Watermark-based | Silver tracks its own high watermark (MAX(forecast_ref_time)) and reads only newer Bronze rows |
Catch-up scenarios, late-arriving data |
| Change Data Feed (CDF) | Bronze table has CDF enabled; Silver reads only change records since last checkpoint | Streaming or near-real-time use cases |
# Parameter-driven approach (simplest, recommended to start)
# The Workflow passes init_time as a 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 is where you enforce data quality. If Bronze is "store everything", Silver is "trust but verify."
# Quality expectations — fail the batch if violated
from pyspark.sql import functions as F
def validate_silver_batch(df):
"""Raise if quality thresholds are breached."""
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")
📋 DLT Alternative
If you choose Delta Live Tables for the Silver layer, these checks become @dlt.expect_or_fail("wind_speed_positive", "wind_speed_ms >= 0") decorators — declarative and built into the pipeline definition. The trade-off (discussed in Lesson 1) is less flexibility for custom parsing logic.
Wind components u=−3 m/s and v=−4 m/s are in Bronze. What wind speed goes into Silver?
Three model runs (00Z, 06Z, 12Z) all forecast tomorrow 12:00. The production pipeline needs the best prediction. Which strategy preserves all data while serving the freshest?
The dim_assets table has 200 turbines (~1 KB). Bronze weather data for one model run is 5 GB. How should the geo-spatial join be executed?
Your Silver pipeline is parameter-driven: it receives init_time from the Workflow. The 06Z model run arrives 2 hours late. What happens?
📖 Recommended Reading
MERGE INTO — Databricks Documentation — covers upsert patterns, merge conditions, and performance tuning. This is the workhorse for idempotent Silver writes. Also see Broadcast join hints for understanding when and how to force broadcast.
With weather data now in Silver as clean, asset-keyed time series, the next lesson covers Unity Catalog governance — how to organise catalogs, schemas, and permissions so your team (data engineers, data scientists, analysts) can collaborate safely on these tables.
Questions about interpolation accuracy, forecast versioning strategy, or the MERGE performance at scale? Ask your teacher — I can work through specific scenarios with your fleet size and data volumes.
Rechtliches: Impressum