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
Opening and downloading one ECMWF ENS run from the Dynamical.org catalog.
Attributes
ECMWF_ENS_INSTANTANEOUS_VARS = frozenset(_ECMWF_ENS_VARS_TO_DOWNLOAD) - Nwp.deaccumulated_var_names - Nwp.categorical_var_names
module-attribute
The downloaded variables describing conditions at one instant, under their download names.
None of these is ever legitimately null, anywhere in a run, which is why
:func:dynamical_data.ecmwf_ens.upstream_nulls.assess_upstream_grid_point_nulls counts them
separately from the de-accumulated ones and the ecmwf_ens asset gates a check on that count
being zero.
Derived from the download list rather than from Nwp's fields, because the two namespaces differ:
we download wind_u_10m/wind_v_10m (and the 100 m pair), and
:func:dynamical_data.ecmwf_ens.convert_to_polars.convert_nwp_xarray_dataset_to_polars_dataframe
derives wind_speed_*/wind_direction_* from them. A set taken from the contract would name
four variables the downloaded dataset does not carry, and indexing it would raise KeyError.
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
16 17 | |
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
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 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 | |
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
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 | |
dynamical_data.ecmwf_ens.convert_to_polars
Converting a downloaded ECMWF ENS xarray dataset into the Nwp Polars contract.
The regular lat/lon grid is aggregated onto H3 cells on the way.
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
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 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 | |
dynamical_data.ecmwf_ens.upstream_nulls
Measuring upstream corruption on the raw NWP grid, before the H3 aggregation sees it.
The H3 aggregation renormalises each cell over the grid points that supplied a value, so a corrupt grid point costs only its own share of its cell. That is what makes the stored cells robust, and it is also why counting null cells is a poor proxy for how corrupt the feed was. This module counts the nulls where they arrive.
Classes
UpstreamNullRate
dataclass
How much of one ingested NWP run arrived null on the raw grid.
This is the provider channel: the number to quote to Dynamical.org when asking whether their feed is degrading. It counts grid points on the 0.25° lat/lon box we downloaded, before any H3 aggregation.
Read it alongside, never instead of, :class:contracts.weather_schemas.NwpQualityReport, which
counts null H3 cells and answers the different question of how much the model lost. The two
are not comparable as rates: different units over different populations.
See https://openclimatefix.github.io/nged-substation-forecast/architecture/ecmwf-ens-known-issues/.
Source code in packages/dynamical_data/src/dynamical_data/ecmwf_ens/upstream_nulls.py
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 100 101 102 103 104 105 | |
Attributes
per_variable
instance-attribute
One row per counted variable, with its n_null, n_affected_slices and n_total
grid-point counts, sorted by variable name. Every scalar below is derived from it, so a
breakdown and a total cannot disagree.
n_null_nwp_grid_points
property
Null grid points in the counted variables and steps.
n_total_nwp_grid_points
property
The denominator: counted variables × ensemble members × counted steps × grid points.
n_affected_nwp_slices
property
(variable, ensemble_member, lead_time) slices carrying at least one null grid point.
Separates "one bad slice" from "a hundred" at the same overall rate, which the fraction alone cannot.
affected_nwp_variables
property
The counted variables carrying at least one null grid point.
In per_variable's row order, which is sorted by variable name because
:func:assess_upstream_grid_point_nulls builds it that way — filter preserves row
order rather than imposing one.
null_nwp_grid_point_fraction
property
Null grid points as a fraction of those counted; 0.0 when none were counted.
A run with no step left to count has nothing to measure, and a warning path must not raise (rule 7).
is_healthy
property
True when no counted grid point arrived null.
Methods:
__init__(per_variable)
Functions:
assess_upstream_grid_point_nulls(ds, variables, exclude_lead_0)
Count nulls on the raw NWP grid of one downloaded run.
Pure and Dagster-free (unit-testable in isolation); the ecmwf_ens asset calls it twice, once
per null population, and publishes each result on its own WARN check.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
ds
|
Dataset
|
One downloaded ECMWF ENS run, as returned by
:func: |
required |
variables
|
Collection[str]
|
The variables to count over, named as |
required |
exclude_lead_0
|
bool
|
Skip the lead-0 step. True for the de-accumulated variables, which are null there by design, so counting it would report every healthy run as corrupt. False for the instantaneous ones, where lead-0 is an ordinary step and a null in it means what a null in any other step means. |
required |
Source code in packages/dynamical_data/src/dynamical_data/ecmwf_ens/upstream_nulls.py
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 138 139 140 141 142 143 144 145 146 147 148 149 | |