← Learning Hub Lesson 2 Duration: ~15 min Topic: Table Design

Asset Hierarchy & Time Series in Delta

This lesson teaches you how to model your domain — parks, turbines, weather time series, power time series — as Delta tables. By the end, you'll be able to sketch table schemas, choose partitioning strategies, and explain how the same tables serve both your production pipeline and your analysts.

The Modelling Problem

You have three interrelated concerns that must coexist in Delta tables:

  1. Asset hierarchy — a wind park contains turbines. You query at both levels.
  2. Time series — every measurement (weather, telemetry, forecasts) is timestamped and repeating.
  3. Dual access — production pipelines read "latest data for asset X" while analysts scan "all data for asset X over the last 90 days."

Getting the table layout right is your single highest-leverage architectural decision. A bad schema means rewriting pipelines later. A good one means your team barely notices the platform.

Pattern 1: The Asset Dimension Table

Separate what things are from what happened to them. The asset registry is a slowly-changing dimension — it describes parks and turbines but doesn't grow with every timestamp.[1]

-- Silver layer: dim_assets
CREATE TABLE silver.dim_assets (
  asset_id        STRING NOT NULL,     -- e.g. "wp-nordsee-1"
  asset_type      STRING NOT NULL,     -- "wind_park" | "turbine"
  parent_asset_id STRING,              -- NULL for parks, park ID for turbines
  name            STRING,
  latitude        DOUBLE NOT NULL,
  longitude       DOUBLE NOT NULL,
  hub_height_m    DOUBLE,              -- turbine-specific
  capacity_kw     DOUBLE,
  commissioned    DATE,
  metadata        MAP<STRING, STRING>  -- flexible attrs
)
USING DELTA
TBLPROPERTIES ('delta.columnMapping.mode' = 'name');
Design Decision: Flat Table with Parent Pointer Parks and turbines live in one table. The parent_asset_id column creates the hierarchy. This is simpler than separate park/turbine tables and lets you query both levels with a single join. A turbine's geo-location is its own (not the park's) — important for precise weather mapping.

Why not nested/struct types?

You could model turbines as an ARRAY<STRUCT> inside a park row. Don't. It makes time series joins painful, breaks per-turbine queries, and complicates the geo-spatial weather mapping. Keep it flat, join when you need hierarchy.

Pattern 2: Time Series Fact Tables

Time series are your fact tables — they grow with every measurement cycle. The design goal: fast writes from the pipeline, fast reads for both "give me the latest" and "give me a range."

Weather time series (mapped to assets)

-- Silver layer: weather time series mapped to asset locations
CREATE TABLE silver.weather_ts (
  asset_id          STRING NOT NULL,
  timestamp         TIMESTAMP NOT NULL,
  forecast_ref_time TIMESTAMP,          -- when the forecast was issued
  wind_speed_ms     DOUBLE,
  wind_direction_deg DOUBLE,
  temperature_c     DOUBLE,
  irradiance_wm2    DOUBLE,
  pressure_hpa      DOUBLE,
  humidity_pct      DOUBLE,
  source_model      STRING              -- "ecmwf_ifs", "gfs", "icon"
)
USING DELTA
PARTITIONED BY (asset_id)
TBLPROPERTIES (
  'delta.autoOptimize.optimizeWrite' = 'true',
  'delta.autoOptimize.autoCompact'   = 'true'
);

Power / telemetry time series

-- Silver layer: power output and telemetry per turbine
CREATE TABLE silver.power_ts (
  asset_id        STRING NOT NULL,      -- turbine-level
  timestamp       TIMESTAMP NOT NULL,
  power_kw        DOUBLE,
  rotor_speed_rpm DOUBLE,
  nacelle_temp_c  DOUBLE,
  availability    DOUBLE,               -- 0.0–1.0
  status_code     STRING
)
USING DELTA
PARTITIONED BY (asset_id)
TBLPROPERTIES (
  'delta.autoOptimize.optimizeWrite' = 'true',
  'delta.autoOptimize.autoCompact'   = 'true'
);

Partitioning Strategy — The Critical Trade-off

Partitioning determines which files Spark can skip when reading. Get it wrong and every query scans everything. The question for time series is: partition by asset, by date, or both?

StrategyPipeline Query PatternAnalyst Query PatternVerdict
PARTITIONED BY (asset_id) ✅ Fast: "latest weather for asset X" ⚠️ Scans all time for that asset Good if assets have moderate data volumes
PARTITIONED BY (date) ⚠️ Scans all assets for today ✅ Fast: "last 7 days across all assets" Good for fleet-wide analytics
PARTITIONED BY (asset_id, date) ✅ Fast both ways ✅ Fast both ways ⚠️ Risk: too many small partitions
PARTITIONED BY (asset_id) + Z-ORDER BY timestamp ✅ Fast: asset + time range ✅ Fast: asset + time range ✅ Best balance for most cases
Recommended: Partition by asset_id, Z-ORDER by timestamp

For a fleet of ~10–500 wind parks (each with 5–100 turbines), partitioning by asset_id gives you manageable partition counts. Z-ordering by timestamp then co-locates recent data within each partition — making both "latest for pipeline" and "range for analysis" fast without creating thousands of tiny partitions.

What is Z-ORDER?

Z-ordering is Delta Lake's data-skipping optimisation. It physically reorders data within files by the specified column(s), so queries that filter on those columns can skip entire files.[2]

-- Run periodically (or via auto-optimize) to keep data co-located
OPTIMIZE silver.weather_ts ZORDER BY (timestamp);
OPTIMIZE silver.power_ts   ZORDER BY (timestamp);

After Z-ordering, a query like WHERE asset_id = 'turbine-47' AND timestamp > '2026-07-10' skips the partition for other assets (partition pruning) and skips files with older timestamps within that partition (data skipping). Both access paths — pipeline and analyst — benefit.

Enabling Dual Access

The same Silver tables serve two audiences. Here's how each accesses them:

Silver Time Series weather_ts / power_ts Delta Lake (MVCC) Single source of truth Production Pipeline Workflows / Job Clusters "Latest for asset X" Analysts SQL Warehouse / Notebooks "90-day range, all parks" Pipeline Writes Append new time series Readers never block writers. Writers never block readers. Analysts can pin to a version (time travel) for reproducible analysis.

Practical implications for your team

ConcernHow Delta Handles It
Pipeline writes while analyst reads MVCC: each reader sees a consistent snapshot. No locks.
Analyst needs reproducible results SELECT * FROM silver.weather_ts VERSION AS OF 42 — pin to a specific version.
Pipeline needs "only new data since last run" Change Data Feed (CDF) or watermark-based reads on timestamp.
Analyst aggregates park-level from turbines Join power_ts to dim_assets on parent_asset_id, GROUP BY park.
Stale data detected by analyst DESCRIBE HISTORY silver.weather_ts — see last write timestamp and version.

The Geo-Spatial Join: Weather Grid → Asset

Weather models produce data on a grid (e.g., ECMWF at 0.25° ≈ 28 km resolution). Your assets are point locations. The Silver-layer transformation must map grid cells to nearby assets.

-- Simplified: nearest grid point to each asset
-- In practice, use bilinear interpolation for higher accuracy
SELECT
  a.asset_id,
  w.timestamp,
  w.wind_speed_ms,
  w.temperature_c,
  w.irradiance_wm2
FROM bronze.weather_raw w
JOIN silver.dim_assets a
  ON ABS(w.grid_lat - a.latitude)  < 0.125   -- half grid cell
  AND ABS(w.grid_lon - a.longitude) < 0.125
WHERE a.asset_type IN ('wind_park', 'turbine')
Architectural Decision: Where to Map?

The geo-spatial join lives in the Bronze → Silver transformation. After this point, all weather data is keyed by asset_id, not grid coordinates. This means Silver and Gold never need to know about grid geometry — simplifying all downstream logic.

Turbine-level vs. park-level weather

For a compact wind park (turbines within ~2 km), all turbines may map to the same grid cell — so they share weather data. For large offshore clusters spanning multiple grid cells, different turbines get different weather. Your schema handles both: weather is mapped per asset_id, and the join determines granularity.

Putting It Together: Schema Map

Silver Layer — Table Relationships dim_assets PK asset_id asset_type FK parent_asset_id latitude, longitude capacity_kw (slowly changing dim) weather_ts FK asset_id timestamp forecast_ref_time wind_speed_ms, temp_c… source_model PARTITION BY asset_id power_ts FK asset_id (turbine) timestamp power_kw rotor_speed, nacelle_temp availability, status PARTITION BY asset_id 1:N 1:N gold.forecast_ts FK asset_id | timestamp | power_kw | confidence_low | confidence_high Both turbine-level and park-level rows ML model

Anti-Patterns to Avoid

Anti-PatternWhy It HurtsInstead
One wide table with all variables Schema changes (new sensor) require full rewrite; sparse columns waste storage Separate fact tables per domain (weather, power, telemetry)
Partitioning by (asset_id, date) with 500 turbines 500 × 365 = 182,500 partitions/year. File listing becomes the bottleneck Partition by asset_id only, Z-ORDER by timestamp
Duplicating Silver tables for analysts Data drift between copies, double storage cost, governance nightmare Single table + MVCC. Grant analysts read-only via Unity Catalog
Embedding turbine arrays in park rows Can't join time series to individual turbines without explode/unnest Flat dim_assets with parent pointer

Check Your Understanding

Why use a flat table with a parent_asset_id column instead of nesting turbines as arrays inside park rows?

Correct. Time series tables are keyed by asset_id. If turbines are nested inside park rows, you'd need to explode/unnest before every join — expensive, awkward, and error-prone. A flat table with a parent pointer gives you direct FK joins.
The real reason is joinability. Time series (weather_ts, power_ts) reference asset_id directly. Nested structs would force explode/unnest operations for every join — a flat parent-pointer design keeps queries simple and fast.

For 200 turbines with 15-minute interval data, what partitioning strategy best serves both pipeline and analysts?

Right. With 200 turbines, partitioning by asset_id gives 200 manageable partitions. Z-ordering by timestamp within each partition enables efficient time-range queries. The dual partition (asset_id, date) would create 200 × 365 = 73,000 partitions per year — too granular.
Partition by asset_id + Z-ORDER by timestamp is the sweet spot. It gives partition pruning by asset AND data skipping by time range, without the small-file problem of dual-partitioning.

Where in the medallion architecture does the geo-spatial join (weather grid → asset location) belong?

Exactly. Bronze stores raw grid data unchanged. The Bronze → Silver transformation resolves grid coordinates to asset_ids. From Silver onwards, everything is keyed by asset — simplifying all downstream logic (feature engineering, ML, Gold aggregation).
The geo-spatial join belongs in the Bronze → Silver step. Bronze stays raw (grid coords intact). Silver introduces asset-keyed time series. This ensures all downstream layers (Gold, Feature Store, ML) never deal with grid geometry.

How does an analyst aggregate turbine-level power to park-level without a separate park table?

Correct. The parent_asset_id in dim_assets links each turbine to its park. Join power_ts → dim_assets, then GROUP BY parent_asset_id with SUM(power_kw) gives you park-level totals. This is the power of the flat hierarchy model.
The answer uses the parent pointer. Join power_ts to dim_assets (to get parent_asset_id), then GROUP BY parent_asset_id. This aggregates turbine-level data up to park level using the hierarchy built into dim_assets.

Primary Source

📖 Recommended Reading

Delta Lake best practices — Databricks Documentation. Covers partitioning, Z-ordering, auto-optimize, and table design. Focus on the "Choose the right partition column" and "Compact files with optimize" sections — they directly inform the decisions in this lesson.

What's Next

You now know how to model assets and time series in Delta. The next lesson will cover ingestion pipelines — how weather data actually arrives (GRIB2 files, API pulls), gets parsed, and lands in Bronze. We'll look at scheduling, incremental loads, and the tooling (cfgrib, xarray) that makes weather data accessible to Spark.


Questions? Want to dive deeper into partitioning, Z-ordering, or the geo-spatial join? Ask your teacher — I can show you more detailed examples or discuss trade-offs specific to your fleet size.

Rechtliches: Impressum