Skip to content

Commit 97ebc22

Browse files
authored
Merge pull request #34 from openclimatefix/ecmwf
Marimo notebook to get ECMWF ENS data from Dynamical
2 parents 2372003 + ed7cf07 commit 97ebc22

14 files changed

Lines changed: 1530 additions & 297 deletions

File tree

packages/contracts/src/contracts/data_schemas.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def validate(
3131
allow_missing_columns: bool = False,
3232
allow_superfluous_columns: bool = False,
3333
drop_superfluous_columns: bool = False,
34-
) -> pt.DataFrame["SubstationFlows"]:
34+
) -> pt.DataFrame["SubstationFlows"]: # type: ignore[invalid-method-override]
3535
"""Validate the given dataframe, ensuring either MW or MVA is present."""
3636
if "MW" not in dataframe.columns and "MVA" not in dataframe.columns:
3737
raise ValueError(
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.14

packages/dynamical_data/README.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
Download & process numerical weather predictions from Dynamical.org.
2+
3+
We convert the ECMWF ENS 0.25 degree data to these H3 resolution 5 hexagons:
4+
5+
![Map of Great Britain using H3 resolution 5 hexagons](map-of-Great-Britain-H3-resolution-5.png)
6+
7+
## Data storage experiments
8+
9+
All these experiments were performed on a single model run of ECMWF ENS (2026-02-23T00), just for Great Britain.
10+
11+
Saving a single ECMWF ENS run using `float32`, and `zstd` compression (with default compression
12+
level) results in Parquet files ranging between about 205 MB to 220 MB.
13+
14+
### Test different sort orders:
15+
(After scaling to `[0, 255]` and saving as `UInt8`, and compressing using zstd with the default level)
16+
17+
```
18+
"init_time", "lead_time", "ensemble_member", "h3_index" = 54 MB (BEST YET)
19+
"init_time", "ensemble_member", "lead_time", "h3_index" = 56 MB
20+
"init_time", "lead_time", "h3_index", "ensemble_member" = 59 MB
21+
"init_time", "h3_index", "lead_time", "ensemble_member" = 60 MB
22+
"init_time", "ensemble_member", "h3_index", "lead_time" = 62 MB
23+
```
24+
25+
### Test compression algorithm
26+
(after sorting by "init_time", "lead_time", "ensemble_member", "h3_index", and scaling to `[0, 255]`
27+
and saving as `UInt8`)
28+
29+
```
30+
compression="zstd", compression_level=12 = 54 MB
31+
compression="zstd", compression_level=13 = 53 MB
32+
compression="zstd", compression_level=14 = 51 MB, 2.26s (BEST MIX OF SPEED & COMPRESSION RATIO)
33+
compression="zstd", compression_level=15 = 51 MB
34+
compression="zstd", compression_level=20 = 51 MB
35+
compression="zstd", compression_level=22 = 51 MB
36+
compression="lz4" = 68 MB
37+
compression="snappy" = 78 MB
38+
compression="gzip" = 56 MB
39+
compression="gzip", compression_level=9 = 53 MB, 1.49s
40+
compression="brotli", compression_level=6 = 54 MB, 1.88s
41+
compression="brotli", compression_level=8 = 54 MB, 2.75s
42+
compression="brotli", compression_level=9 = 53 MB, 3.9s
43+
compression="brotli", compression_level=10 = 48 MB, 12.59s
44+
compression="brotli", compression_level=11 = 48 MB, 17.75s!
45+
```
46+
47+
### Testing different dtypes
48+
(after sorting by "init_time", "lead_time", "ensemble_member", "h3_index", and compressing using zstd level 14)
49+
50+
```
51+
Scale to 2¹⁶ - 1, and save as UInt16 = 145 MB
52+
Scale to 2¹⁰ - 1, and save as UInt16 = 79 MB
53+
Scale to 2⁹ - 1, and save as UInt16 = 62 MB
54+
Scale to 2⁸ - 1, and save as UInt16 = 51 MB
55+
Scale to 2⁸ - 1, and save as UInt8 = 51 MB
56+
```
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
import marimo
2+
3+
__generated_with = "0.20.2"
4+
app = marimo.App(width="full")
5+
6+
with app.setup:
7+
from typing import Final
8+
9+
import h3.api.numpy_int as h3
10+
import icechunk
11+
import marimo as mo
12+
import numpy as np
13+
import polars as pl
14+
import polars_h3 as plh3
15+
import shapely.geometry
16+
import shapely.wkt
17+
import xarray as xr
18+
from lonboard import H3HexagonLayer, Map
19+
20+
21+
@app.cell(hide_code=True)
22+
def _():
23+
mo.md(r"""
24+
## Fetch geometry of GB
25+
""")
26+
return
27+
28+
29+
@app.cell
30+
def _():
31+
# TODO: Move all this geo code into a new `package/geo_utils` package.
32+
33+
# Downloaded from https://onsdigital.github.io/uk-topojson/ for the year 2025.
34+
with open("england_scotland_wales.geojson") as f:
35+
file_contents = f.read()
36+
return (file_contents,)
37+
38+
39+
@app.cell
40+
def _(file_contents):
41+
shape = shapely.from_geojson(file_contents)
42+
43+
# Buffer by 0.25 degrees (approx 20km) to catch islands/coasts.
44+
# This turns the "rough" map into a "safe" container.
45+
# This takes about 20 seconds to compute!
46+
shape = shape.buffer(0.25)
47+
48+
shape
49+
return (shape,)
50+
51+
52+
@app.cell
53+
def _(shape):
54+
# Which H3 resolution to use?
55+
# ECMWF ENS has a horizontal resolutions of 0.25°.
56+
# For GB, a 0.25° grid box is approx 28 km north-south (lat) x 16 km east-west (lon) ~= 450 km².
57+
# H3 average hexagon areas (from https://h3geo.org/docs/core-library/restable/#average-area-in-km2):
58+
# res 4 = 1,770 km² (far too coarse).
59+
# res 5 = 253 km² (the right choice).
60+
H3_RES: Final[int] = 5
61+
62+
cells = h3.geo_to_cells(shape, res=H3_RES)
63+
64+
df = pl.DataFrame({"h3_index": list(cells)}, schema={"h3_index": pl.UInt64}).sort("h3_index")
65+
66+
# Verify we caught the Isles of Scilly:
67+
_scilly_hex = h3.latlng_to_cell(lat=49.9, lng=-6.3, res=H3_RES)
68+
assert _scilly_hex in df["h3_index"]
69+
70+
df
71+
return (df,)
72+
73+
74+
@app.cell
75+
def _(df):
76+
layer = H3HexagonLayer(
77+
df,
78+
get_hexagon=df["h3_index"],
79+
opacity=0.2,
80+
)
81+
Map(layer)
82+
return
83+
84+
85+
@app.cell
86+
def _(df):
87+
df_with_children = df.with_columns(h3_res7=plh3.cell_to_children("h3_index", 7)).explode(
88+
"h3_res7"
89+
)
90+
return (df_with_children,)
91+
92+
93+
@app.cell
94+
def _(df_with_children):
95+
# Instead of spatial join, we compute the NWP Key mathematically
96+
GRID_SIZE = 0.25
97+
98+
# TODO: Need to think if ECMWF's grid boxes are centered on these coords, and how that interacts with our code below:
99+
100+
df_with_grid_x_y = df_with_children.with_columns(
101+
# Bin every child into an NWP box by snapping to the nearest 0.25 degree
102+
nwp_grid_box=pl.struct(
103+
nwp_lat=(plh3.cell_to_lat("h3_res7") / GRID_SIZE).floor() * GRID_SIZE,
104+
nwp_lng=(plh3.cell_to_lng("h3_res7") / GRID_SIZE).floor() * GRID_SIZE,
105+
),
106+
)
107+
108+
df_with_grid_x_y
109+
return (df_with_grid_x_y,)
110+
111+
112+
@app.cell
113+
def _(df_with_grid_x_y):
114+
df_with_counts = (
115+
df_with_grid_x_y.group_by("h3_index")
116+
.agg(grid_cell_counts=pl.col("nwp_grid_box").value_counts())
117+
.with_columns(
118+
total=pl.col("grid_cell_counts").list.agg(pl.element().struct.field("count").sum())
119+
)
120+
.explode("grid_cell_counts")
121+
.unnest("grid_cell_counts")
122+
.unnest("nwp_grid_box")
123+
.with_columns(proportion=pl.col.count / pl.col.total)
124+
)
125+
df_with_counts
126+
return (df_with_counts,)
127+
128+
129+
@app.cell
130+
def _():
131+
storage = icechunk.s3_storage(
132+
bucket="dynamical-ecmwf-ifs-ens",
133+
prefix="ecmwf-ifs-ens-forecast-15-day-0-25-degree/v0.1.0.icechunk/",
134+
region="us-west-2",
135+
anonymous=True,
136+
)
137+
repo = icechunk.Repository.open(storage)
138+
session = repo.readonly_session("main")
139+
140+
ds = xr.open_zarr(
141+
session.store,
142+
chunks=None, # Don't use dask.
143+
)
144+
145+
ds
146+
return (ds,)
147+
148+
149+
@app.cell
150+
def _(df_with_counts):
151+
min_lat, max_lat, min_lng, max_lng = df_with_counts.select(
152+
min_lat=pl.col("nwp_lat").min(),
153+
max_lat=pl.col("nwp_lat").max(),
154+
min_lng=pl.col("nwp_lng").min(),
155+
max_lng=pl.col("nwp_lng").max(),
156+
)
157+
return max_lat, max_lng, min_lat, min_lng
158+
159+
160+
@app.cell
161+
def _(df_with_counts, ds, max_lat, max_lng, min_lat, min_lng):
162+
import concurrent.futures
163+
164+
def download_array(var_name: str) -> dict[str, xr.DataArray]:
165+
return {var_name: ds_cropped[var_name].compute()}
166+
167+
for init_time in ds.init_time.values:
168+
print(init_time)
169+
170+
# Crop the NWP data spatially using the min & max lats and lngs from the Polars H3 dataframe.
171+
ds_cropped = ds.sel(
172+
# Latitude coords are in _descending_ order or the Northern hemisphere!
173+
latitude=slice(max_lat.item(), min_lat.item()),
174+
longitude=slice(min_lng.item(), max_lng.item()),
175+
init_time=init_time,
176+
)
177+
178+
data_arrays: dict[str, xr.DataArray] = {}
179+
with concurrent.futures.ThreadPoolExecutor() as executor:
180+
futures = [executor.submit(download_array, name) for name in ds_cropped.data_vars]
181+
for future in concurrent.futures.as_completed(futures):
182+
data_arrays.update(future.result())
183+
184+
loaded_ds = xr.Dataset(data_arrays)
185+
del data_arrays
186+
loaded_ds
187+
188+
# TODO: Don't compute this every loop
189+
lat_grid, lon_grid = np.meshgrid(
190+
loaded_ds.latitude.values, loaded_ds.longitude.values, indexing="ij"
191+
)
192+
193+
dfs = []
194+
195+
for lead_time in loaded_ds.lead_time.values:
196+
for ensemble_member in loaded_ds.ensemble_member.values:
197+
loaded_cropped_ds = loaded_ds.sel(
198+
lead_time=lead_time, ensemble_member=ensemble_member
199+
)
200+
nwp_data = {
201+
var_name: var_array.values.ravel()
202+
for var_name, var_array in loaded_cropped_ds.items()
203+
}
204+
nwp_data.update(
205+
{
206+
"longitude": lon_grid.ravel(),
207+
"latitude": lat_grid.ravel(),
208+
}
209+
)
210+
_nwp_df = pl.DataFrame(nwp_data)
211+
212+
# - Join h3_res7_grid_cell with the actual NWP data, to end up with a dataframe that has `proportion` and the raw NWP value
213+
joined = df_with_counts.join(
214+
_nwp_df,
215+
left_on=["nwp_lng", "nwp_lat"],
216+
right_on=["longitude", "latitude"],
217+
)
218+
219+
all_nwp_vars: list[str] = list(loaded_cropped_ds.data_vars.keys()) # type: ignore[invalid-assignment]
220+
221+
# We need to handle categorical values differently:
222+
categorical_nwp_vars = ["categorical_precipitation_type_surface"]
223+
numeric_nwp_vars = [var for var in all_nwp_vars if var not in categorical_nwp_vars]
224+
225+
dtypes = {numeric_nwp_var: pl.Float32 for numeric_nwp_var in numeric_nwp_vars}
226+
dtypes.update(
227+
{categorical_nwp_var: pl.UInt8 for categorical_nwp_var in categorical_nwp_vars}
228+
)
229+
230+
joined = (
231+
joined.with_columns(pl.col(numeric_nwp_vars) * pl.col("proportion"))
232+
.group_by("h3_index")
233+
.agg(pl.col(numeric_nwp_vars).sum(), pl.col(categorical_nwp_vars).mode())
234+
.cast(dtypes)
235+
.with_columns(
236+
lead_time=pl.duration(seconds=lead_time / np.timedelta64(1, "s")),
237+
ensemble_member=pl.lit(ensemble_member, dtype=pl.UInt8),
238+
init_time=pl.lit(init_time),
239+
)
240+
)
241+
242+
dfs.append(joined)
243+
244+
nwp_df = pl.concat(dfs)
245+
nwp_df = nwp_df.sort(by=["ensemble_member", "h3_index", "lead_time"])
246+
247+
break # TODO(Jack) REMOVE THIS!
248+
249+
nwp_df.write_parquet(
250+
f"data/{np.datetime_as_string(init_time, unit='h')}.parquet",
251+
compression="zstd",
252+
compression_level=10,
253+
statistics="full",
254+
)
255+
return (nwp_df,)
256+
257+
258+
@app.cell
259+
def _():
260+
d = {"a": 1}
261+
d.update({"b": 2})
262+
d
263+
return
264+
265+
266+
@app.cell
267+
def _(nwp_var_names):
268+
nwp_var_names.remove("categorical_precipitation_type_surface")
269+
return
270+
271+
272+
@app.cell
273+
def _(nwp_var_names):
274+
nwp_var_names
275+
return
276+
277+
278+
@app.cell
279+
def _():
280+
from lonboard.colormap import apply_continuous_cmap
281+
282+
return (apply_continuous_cmap,)
283+
284+
285+
@app.cell
286+
def _(nwp_df):
287+
selection = nwp_df.filter(
288+
pl.col.ensemble_member == 0, pl.col.lead_time == pl.duration(days=4.5)
289+
)
290+
291+
temperature = selection["temperature_2m"]
292+
min_bound = temperature.min()
293+
max_bound = temperature.max() - min_bound
294+
295+
normalized = (temperature - min_bound) / max_bound
296+
return normalized, selection
297+
298+
299+
@app.cell
300+
def _():
301+
from palettable.matplotlib import Viridis_20 # type: ignore[unresolved-import]
302+
303+
return (Viridis_20,)
304+
305+
306+
@app.cell
307+
def _(Viridis_20, apply_continuous_cmap, normalized, selection):
308+
Map(
309+
H3HexagonLayer(
310+
selection,
311+
get_hexagon=selection["h3_index"],
312+
get_fill_color=apply_continuous_cmap(normalized, Viridis_20, alpha=0.7),
313+
opacity=0.8,
314+
)
315+
)
316+
return
317+
318+
319+
@app.cell
320+
def _(selection):
321+
selection
322+
return
323+
324+
325+
@app.cell
326+
def _():
327+
return
328+
329+
330+
if __name__ == "__main__":
331+
app.run()

0 commit comments

Comments
 (0)