Dynamical Data API
Dynamical Data Package
Download & process numerical weather predictions from Dynamical.org.
We convert the ECMWF ENS 0.25 degree data to these H3 resolution 5 hexagons:

Note: The generic geospatial logic for mapping latitude/longitude grids to H3 hexagons has been extracted to the
packages/geopackage. This package (dynamical_data) focuses specifically on the ingestion, processing, and storage of time-varying NWP datasets like ECMWF. The H3 grid weights are provided as a Dagster asset from thegeopackage, eliminating the need for precomputed static files.
Data storage experiments
The storage format itself lives in delta_store.nwp (writer properties, sort order, precision);
this section records the measurements behind it. Full before/after detail is in
PR #271; earlier
experiments (UInt8/Int16 affine quantisation, codec and sort-order sweeps) are in this file's
git history.
Current scheme: physical-unit Float32, every continuous variable rounded to a 13-bit
significand (max relative error 2⁻¹³ ≈ 1.2×10⁻⁴ — measured ≤ 0.004 °C for temperature, ≤ 8 Pa
for MSL pressure), rows sorted init_time → ensemble_member → valid_time → h3_index, plain
ZSTD level 3.
How much space does GB-wide ECMWF ENS take? One daily run (1,671 H3 cells × 51 members × 85 lead times, up to ~7.24M rows) averages ~113 MB, so a year is ~41 GB. The full local development table — 810 daily runs (Apr 2024 → Jun 2026, 1.57 billion rows) — is 86 GB.
Storage (9 real partitions spread across every season):
| Config | avg MB/partition | extrapolated GB/yr |
|---|---|---|
| Previous: Int16 12-bit quantisation, ZSTD-14 | 115.3 | 42.1 |
| Float32 + 13-bit significand, ZSTD-3 | 110.6 | 40.4 |
| Adopted: same + member-early sort | 112.9 | 41.2 |
Same + BYTE_STREAM_SPLIT |
133.8 | 48.8 |
BYTE_STREAM_SPLIT makes this table worse (unlike power_forecasts, where it wins):
significand rounding collapses NWP values into repeats that parquet's default dictionary+RLE
encoding captures directly, and BYTE_STREAM_SPLIT scatters that repetition across four byte
planes. Writer properties are data-dependent — measure per table.
Read path — the member-early sort means each approx 1M-row parquet row group spans only a few ensemble members, so a single-member read (every training run reads just the control member) skips most row groups via min/max stats. Measured on a real 29-day, 9-cell, control-member collect: ~5× faster, ~5× less peak memory (0.15 s / ~1 GB → 0.02–0.04 s / approx 205 MB), for a ~2% storage cost.
Per-variable keep_bits: considered and rejected (2026-07). Since Dynamical's upstream precision caps the real information at 7–12 significand bits per variable, budgets matched to upstream would compress better than the uniform 13. Measured on 4 seasonal partitions through the exact production write path (error columns = error added on top of today's stored values; wind speed's power impact is ~3× its relative error because the speed-to-power curve is roughly cubic):
| Config | Size vs today | GB/yr | wind speed rel err | → power (×3) | temp | MSL |
|---|---|---|---|---|---|---|
| Uniform 13 (today) | — | 41.1 | 0 | 0 | 0 | 0 |
| Upstream-matched (temp 8, wind 7, pressure 12, flux 8) | −19.4% | 33.1 | 0.76% | 2.3% | 0.06 °C | 16 Pa |
| Uniform 10 | −15.6% | 34.7 | 0.10% | 0.29% | 0.016 °C | 64 Pa |
| Wind-protected (wind speed stays 13, rest squeezed) | −13.0% | 35.7 | 0 | 0 | 0.06 °C | 16 Pa |
The full squeeze adds ~2.3% power-equivalent wind error — not tolerable. The wind-safe ceiling is −13% ≈ 5.4 GB/yr, and the NWP table is GB-wide so it does not grow with the V2 scale-up to ~2,500 time series: a fixed ~5 GB/yr saving doesn't justify maintaining a dict of per-variable precision budgets. If disk ever becomes a real constraint, the wind-protected config is the one to reach for.
Why round at all, when Dynamical.org already rounds? Dynamical stores ECMWF ENS with
6–11 mantissa bits per variable (their
binary_rounding.py).
But trailing zeros do not survive arithmetic: our H3 aggregation is a weighted mean over grid
points, and wind speed/direction are derived from their u/v via sqrt/arctan2, so by the
time values reach our writer their mantissas are full entropy again (measured: 100% of
Dynamical-style rounded values have zeroed low bits; after a weighted mean, 0.07% do). Our
13-significand-bit rounding restores compressibility while being 1–6 bits finer than the
upstream precision, so it discards almost nothing beyond what Dynamical already dropped.
dynamical_data.ecmwf_ens.download
Classes
NwpRunNotYetAvailable
Bases: Exception
Raised when nwp_init_time isn't in the catalog yet (Dynamical hasn't published it).
Source code in packages/dynamical_data/src/dynamical_data/ecmwf_ens/download.py
13 14 | |
Functions:
open_ecmwf_ens_run(nwp_init_time, h3_grid)
Lazily open the ECMWF ENS Icechunk store and slice it to the requested run and H3 grid.
No data is downloaded: the returned dataset is still backed by lazy Dask/Zarr arrays.
Call :func:download_ecmwf_ens_data to actually fetch the data.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
nwp_init_time
|
datetime
|
The initialization time to open. Must be timezone aware. |
required |
h3_grid
|
DataFrame[H3GridWeights]
|
The H3 grid to use for spatial bounds. |
required |
Source code in packages/dynamical_data/src/dynamical_data/ecmwf_ens/download.py
34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 | |
download_ecmwf_ens_data(ds_sliced)
Download (compute) a lazily-opened, already-sliced ECMWF ENS dataset.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
ds_sliced
|
Dataset
|
A lazy dataset as returned by :func: |
required |
Source code in packages/dynamical_data/src/dynamical_data/ecmwf_ens/download.py
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | |
dynamical_data.ecmwf_ens.convert_to_polars
Attributes
Classes
Functions:
convert_nwp_xarray_dataset_to_polars_dataframe(ds, h3_grid)
Vectorized processing of ECMWF dataset to H3 grid.
Source code in packages/dynamical_data/src/dynamical_data/ecmwf_ens/convert_to_polars.py
13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 | |