← Learning Hub Lesson 3 Duration: ~15 min Topic: Ingestion & Bronze Layer

Weather Ingestion Pipeline

This lesson teaches you how weather data actually arrives, gets parsed, and lands in Bronze. By the end, you'll understand the file formats (GRIB2, NetCDF), the tooling that reads them, how to schedule incremental ingestion on Databricks, and the key architectural decisions at this layer.

How Weather Data Arrives

Numerical Weather Prediction (NWP) models produce forecast data on fixed schedules. The three main delivery mechanisms:

SourceFormatDeliveryFrequencyTypical Latency
ECMWF IFS GRIB2 MARS API / CDS API / S3 dissemination 4× daily (00, 06, 12, 18 UTC) ~4–6 h after init time
GFS (NOAA) GRIB2 HTTPS / AWS Open Data (S3) 4× daily ~3.5 h after init time
ICON (DWD) GRIB2 DWD Open Data (HTTPS) 4× daily ~2–4 h after init time
ERA5 Reanalysis NetCDF / GRIB2 CDS API (Copernicus) Monthly (5-day delay) Days to weeks
Commercial providers NetCDF / JSON / Parquet REST API / SFTP / Cloud push Varies Minutes to hours
Key Insight: Forecast Reference Time Every weather forecast has two time dimensions: the init time (when the model was run, e.g., 2026-07-17T00:00Z) and the valid time (what time the forecast is for, e.g., +24h = 2026-07-18T00:00Z). Your Bronze schema must capture both. Without init time, you can't track which forecast version produced a given prediction.

GRIB2: The Dominant Format

GRIB2 (GRIdded Binary, edition 2) is the standard format for meteorological data. It's compact but not self-describing — you need external tables to decode message meanings.[1]

What a GRIB2 file contains

Reading GRIB2 in Python

# The toolchain for GRIB2 → tabular data
import xarray as xr
import cfgrib

# Open GRIB2 file with cfgrib engine
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, irradiance
            "typeOfLevel": "heightAboveGround"
        }
    }
)

# Result: xarray Dataset with dimensions (time, latitude, longitude)
# Variables: u10, v10, t2m, ssrd — each is a 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

📦 Library Stack

cfgrib decodes GRIB2 messages. xarray provides the labelled multi-dimensional array API. eccodes (ECMWF's C library) is the underlying decoder. On Databricks, install via cluster init script or %pip install cfgrib eccodes xarray.

From xarray to Spark DataFrame

The bridge between meteorology (xarray) and Databricks (Spark) is the key engineering challenge. Here's the pattern:

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):
    """Parse a GRIB2 file and return a Spark DataFrame for Bronze."""
    
    # 1. Open with xarray/cfgrib
    ds = xr.open_dataset(grib_path, engine="cfgrib")
    
    # 2. Convert to pandas (flatten grid → rows)
    #    Each row = one grid point × one time step
    df_pd = ds.to_dataframe().reset_index()
    
    # 3. Rename to domain terms
    df_pd = df_pd.rename(columns={
        "latitude": "grid_lat",
        "longitude": "grid_lon",
        "time": "valid_time",  # forecast valid time
        "u10": "wind_u_ms",
        "v10": "wind_v_ms",
        "t2m": "temperature_k",
        "ssrd": "irradiance_jm2",
    })
    
    # 4. Add metadata columns
    df_pd["init_time"] = pd.Timestamp(init_time)
    df_pd["source_model"] = source_model
    df_pd["ingested_at"] = pd.Timestamp.utcnow()
    
    # 5. Convert to Spark DataFrame
    return spark.createDataFrame(df_pd)


# Usage in a Databricks notebook or job
bronze_df = grib_to_bronze_df(
    "/mnt/raw/ecmwf/20260717_00z.grib2",
    init_time="2026-07-17T00:00:00Z",
    source_model="ecmwf_ifs"
)

# Write to Bronze — append-only, partitioned by date for manageability
bronze_df.write.format("delta") \
    .mode("append") \
    .partitionBy("init_time") \
    .saveAsTable("bronze.weather_raw")

Why the Bronze table keeps grid coordinates

Bronze is raw and immutable. It stores grid_lat / grid_lon — not asset_id. The geo-spatial join to assets happens in the Bronze → Silver transformation (covered in Lesson 2). This means if you add new assets later, you can reprocess Bronze without re-downloading.

Bronze Schema

-- Bronze layer: raw weather grid data as-is from source
CREATE TABLE bronze.weather_raw (
  grid_lat        DOUBLE NOT NULL,
  grid_lon        DOUBLE NOT NULL,
  valid_time      TIMESTAMP NOT NULL,     -- what time the forecast is FOR
  init_time       TIMESTAMP NOT NULL,     -- when the model run started
  wind_u_ms       DOUBLE,                 -- eastward wind component
  wind_v_ms       DOUBLE,                 -- northward wind component
  temperature_k   DOUBLE,                 -- 2m temperature in Kelvin
  irradiance_jm2  DOUBLE,                 -- surface solar radiation
  pressure_pa     DOUBLE,
  source_model    STRING NOT NULL,        -- "ecmwf_ifs", "gfs", "icon"
  ingested_at     TIMESTAMP NOT NULL      -- pipeline processing timestamp
)
USING DELTA
PARTITIONED BY (init_time)
TBLPROPERTIES (
  'delta.autoOptimize.optimizeWrite' = 'true',
  'delta.autoOptimize.autoCompact'   = 'true',
  'delta.logRetentionDuration'       = '30 days'
);
Design Decision: Partition by init_time at Bronze

Each model run (init_time) produces one batch of data. Partitioning by init_time means: (1) appending a new forecast run creates one new partition — no rewrites, (2) reprocessing a specific run is a targeted read, (3) retention policies can drop old init_times cleanly.

Orchestrating the Pipeline

Weather ingestion runs on a fixed schedule tied to model availability. Here's how it maps to Databricks Workflows:

Ingestion Workflow — Triggered by Schedule Task 1: Check Poll source API/S3 New run available? Task 2: Download Fetch GRIB2 to cloud storage (ADLS/S3) Task 3: Parse cfgrib/xarray → Spark → Bronze table Task 4: Bronze → Silver Geo-join to assets → weather_ts per asset Databricks Workflow Configuration Schedule: cron 0 5,11,17,23 * * * (4× daily, offset from model init by ~5h for availability) Cluster: Job cluster, single-node (parse is I/O-bound, not compute-bound for single-model ingestion)

Idempotency — The Non-Negotiable

Network failures, API timeouts, and duplicate triggers happen. Your ingestion must be idempotent: running the same job twice for the same init_time must not create duplicate data.

# Pattern: MERGE to enforce idempotency at Bronze
# If init_time + grid_lat + grid_lon + valid_time already exists, skip it

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: Append + Dedup at Silver

Some teams prefer simpler Bronze (blind append) and handle deduplication at the Silver layer. Trade-off: Bronze grows faster but Silver logic is more complex. For weather data with clear natural keys (init_time, grid point, valid_time), MERGE at Bronze is clean and prevents waste early.

Scaling Considerations

How big is weather data? Let's estimate for a typical ECMWF forecast:

DimensionValueNotes
Grid points (global 0.25°)~1 million721 lat × 1440 lon
Variables5–15wind, temp, irradiance, pressure…
Time steps per run~81Hourly out to 3–5 days
Rows per run (flattened)~400M–1.2Bgrid × time × variable
GRIB2 file size~2–10 GBCompressed binary
Delta table size (one run)~15–50 GBParquet, snappy compressed
You Don't Need Global Data

If your wind parks are in the North Sea, you only need a geographic subset. Filtering by bounding box during download or parse reduces data by 95%+. Most APIs support spatial subsetting at request time (ECMWF's MARS/CDS, GFS via grib_filter). Always filter as early as possible.

# Subset to relevant area during parse (e.g., North Sea + Northern Europe)
ds = xr.open_dataset("forecast.grib2", engine="cfgrib")
ds_subset = ds.sel(
    latitude=slice(62, 48),    # North Sea region
    longitude=slice(-5, 15)
)
# Reduces ~1M grid points to ~5,000 — manageable on a single node

Handling Multiple Weather Models

Your team likely consumes multiple NWP sources (ECMWF for accuracy, GFS as backup, ICON for Central European detail). The source_model column in Bronze handles this naturally:

-- Query: compare forecasts from different models for same valid_time
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

At the Silver layer, you choose which model feeds the production forecast (usually the most recent available, or an ensemble blend). This decision is a Silver-layer concern — Bronze stores all models equally.

Cluster Configuration for Ingestion

AspectRecommendationWhy
Cluster type Job cluster (ephemeral) Starts for the job, terminates after. No idle cost.
Node count Single node (for subset ingest) cfgrib/xarray run in the driver. Parallelism comes from multiple tasks, not multiple nodes.
Instance type Memory-optimised (e.g., Standard_E4s_v3) xarray loads full grid into memory for subsetting.
Libraries Init script: install eccodes, cfgrib, xarray Not in default Databricks Runtime. Pin versions for reproducibility.
Parallelism Multiple Workflow tasks (one per model source) ECMWF, GFS, ICON run as parallel tasks in the same Workflow.
Parallel Ingestion Tasks in One Workflow Ingest ECMWF Ingest GFS Ingest ICON Bronze → Silver (geo-join)

Monitoring & Alerting

Weather ingestion has strict timeliness requirements — stale forecasts degrade your power predictions. Build these checks into your pipeline:

# Example: freshness assertion in a 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}"

Check Your Understanding

A GRIB2 file from ECMWF contains forecasts initialised at 00:00 UTC with valid times at +1h, +2h, … +72h. Which column captures "when the model run started"?

Correct. init_time (also called reference time or analysis time) marks when the model run started. valid_time is what the forecast predicts for. Both are needed — together they tell you "the 00Z run's prediction for hour +24."
init_time captures when the model run started. valid_time is what time the forecast is for. ingested_at is when your pipeline processed it. You need all three, but init_time answers "which model run."

Why is the Bronze weather table partitioned by init_time rather than by grid coordinates?

Exactly. Ingestion writes one complete model run at a time. Partitioning by init_time means each write creates one partition (no scattered updates), reprocessing targets one run, and retention drops old runs cleanly. It matches the data's natural batch boundary.
The answer is about write alignment. Each model run is an atomic batch. Partitioning by init_time means writes, reprocessing, and retention all operate on natural units of data — one model run at a time.

Your ECMWF ingestion job runs at 05:00 UTC but the model run isn't available yet. What should Task 1 (Check) do?

Right. Weather model delivery is sometimes delayed. The check task should exit cleanly (not fail) and let the next scheduled trigger retry. Separate alerting fires if data remains unavailable beyond a threshold (e.g., 8h). Don't treat normal delay as failure.
The correct approach is graceful retry. Weather data delivery has natural variability. Task 1 exits cleanly ("no new data"), and the next schedule trigger retries. A separate freshness alert fires only if delay exceeds a threshold.

Your wind parks are all in the North Sea (lat 52–58°, lon 2–9°). The ECMWF global grid has ~1 million points. What's the most impactful optimisation?

Exactly. If your assets are in one region, downloading or parsing the entire global grid is wasteful. Subsetting to a bounding box at the earliest possible point (API request or xarray .sel()) reduces data volume by 95%+ and makes single-node processing viable.
Geographic subsetting is the answer. Most weather APIs support bounding box filtering at request time. Even if they don't, xarray's .sel() on latitude/longitude after load is far cheaper than processing the full global grid through the pipeline.

Primary Source

📖 Recommended Reading

Databricks Workflows documentation — covers job creation, task dependencies, scheduling (cron), retries, and cluster policies. Read the "Create and manage jobs" and "Configure compute for jobs" sections. Then look at ECMWF's data access guide for understanding how weather data is actually retrieved.

What's Next

You now understand how weather data enters Databricks. The next lesson will cover the Bronze → Silver transformation in depth — the geo-spatial join, wind speed calculation from u/v components, unit conversions, and how to handle multiple forecast versions (init times) for the same valid time.


Questions about GRIB2 parsing, scheduling, or cluster sizing? Ask your teacher — I can dive deeper into cfgrib configuration, multi-model ensemble strategies, or cost estimation for your fleet size.

Rechtliches: Impressum