From 2fa62ab176dc1fa65d881831b8a4913218ab9824 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 19 Sep 2025 15:11:22 +0200 Subject: [PATCH 01/51] rewrite era5 package based on ecmwf-datastores-client --- openhexa/toolbox/era5/README.md | 390 +++++++++----- openhexa/toolbox/era5/aggregate.py | 370 ------------- openhexa/toolbox/era5/cds.py | 463 ---------------- openhexa/toolbox/era5/data/variables.toml | 68 +++ openhexa/toolbox/era5/dhis2weeks.py | 105 ++++ openhexa/toolbox/era5/extract.py | 499 ++++++++++++++++++ openhexa/toolbox/era5/google.py | 158 ------ openhexa/toolbox/era5/transform.py | 213 ++++++++ openhexa/toolbox/era5/variables.json | 352 ------------ tests/era5/data/geoms.parquet | Bin 0 -> 8760 bytes tests/era5/data/sample_202503.grib | Bin 0 -> 15840 bytes tests/era5/data/sample_202504.grib | Bin 0 -> 19800 bytes .../data/sample_2m_temperature.zarr.tar.gz | Bin 0 -> 34009 bytes tests/era5/test_cds.py | 158 ------ tests/era5/test_dhis2weeks.py | 52 ++ tests/era5/test_extract.py | 261 +++++++++ tests/era5/test_transform.py | 95 ++++ 17 files changed, 1552 insertions(+), 1632 deletions(-) delete mode 100644 openhexa/toolbox/era5/aggregate.py delete mode 100644 openhexa/toolbox/era5/cds.py create mode 100644 openhexa/toolbox/era5/data/variables.toml create mode 100644 openhexa/toolbox/era5/dhis2weeks.py create mode 100644 openhexa/toolbox/era5/extract.py delete mode 100644 openhexa/toolbox/era5/google.py create mode 100644 openhexa/toolbox/era5/transform.py delete mode 100644 openhexa/toolbox/era5/variables.json create mode 100644 tests/era5/data/geoms.parquet create mode 100644 tests/era5/data/sample_202503.grib create mode 100644 tests/era5/data/sample_202504.grib create mode 100644 tests/era5/data/sample_2m_temperature.zarr.tar.gz delete mode 100644 tests/era5/test_cds.py create mode 100644 tests/era5/test_dhis2weeks.py create mode 100644 tests/era5/test_extract.py create mode 100644 tests/era5/test_transform.py diff --git a/openhexa/toolbox/era5/README.md b/openhexa/toolbox/era5/README.md index 7e6ec0cd..35693452 100644 --- a/openhexa/toolbox/era5/README.md +++ b/openhexa/toolbox/era5/README.md @@ -1,182 +1,310 @@ -# OpenHEXA Toolbox ERA5 +**ERA5 Toolbox** -The package contains ETL classes and functions to acquire and process ERA5-Land data. ERA5-Land -provides hourly information of surface variables from 1950 to 5 days before the current date, with -a ~9 km spatial resolution. See [ERA5-Land: data -documentation](https://confluence.ecmwf.int/display/CKB/ERA5-Land%3A+data+documentation) for more -information. +Package for downloading, processing, and aggregating ERA5-Land reanalysis data from the ECMWF Climate Data Store (CDS). -Available variables include: -* 2 metre temperature -* Wind components -* Leaf area index -* Volumetric soil water layer -* Total precipitation +- [Overview](#overview) +- [Data Source](#data-source) +- [Data Flow](#data-flow) +- [Usage](#usage) + - [Data Acquisition](#data-acquisition) + - [Data Aggregation](#data-aggregation) +- [Supported variables](#supported-variables) +- [Zarr store](#zarr-store) + - [Why Zarr instead of GRIB?](#why-zarr-instead-of-grib) + - [How is the Zarr store managed?](#how-is-the-zarr-store-managed) + - [Reading data from the Zarr store](#reading-data-from-the-zarr-store) -See [ERA5-Land data -documentation](https://confluence.ecmwf.int/display/CKB/ERA5-Land%3A+data+documentation#ERA5Land:datadocumentation-parameterlistingParameterlistings) -for a full list of available parameters. +## Overview -In addition to download clients for the Copernicus [Climate Data Store](https://cds.climate.copernicus.eu/datasets/reanalysis-era5-land?tab=overview) and [Google Public Datasets](https://cloud.google.com/storage/docs/public-datasets/era5), the package includes an `aggregate` module to aggregate ERA5 measurements in space (geographic boundaries) and time (hourly to daily). +This package provides tools to: +- Download ERA5-Land hourly data from ECMWF's Climate Data Store +- Convert GRIB files to analysis-ready Zarr format +- Perform spatial aggregation using geographic boundaries +- Aggregate data temporally across various periods (daily, weekly, monthly, yearly) +- Support DHIS2-compatible weekly periods (standard, Wednesday, Thursday, Saturday, Sunday weeks) -## Usage +## Data Source + +ERA5-Land is a reanalysis dataset providing hourly estimates of land variables from 1950 to present at 9km resolution. Data is accessed via the [ECMWF Climate Data Store](https://cds.climate.copernicus.eu/). + +**Requirements:** +- CDS API account and credentials +- Dataset license accepted in the CDS + +## Data Flow + +```mermaid +flowchart LR + CDS[(ECMWF CDS)] --> GRIB[GRIB Files] --> ZARR[Zarr Store] --> PROCESS[Aggregate] + + style CDS fill:#e1f5fe + style ZARR fill:#f3e5f5 +``` -The package contains 3 modules: -* `openhexa.toolbox.era5.cds`: download ERA5-land products from the Copernicus [Climate Data Store](https://cds.climate.copernicus.eu/datasets/reanalysis-era5-land?tab=overview) -* `openhexa.toolbox.era5.google`: download ERA5 products from Google Cloud [Public Datasets](https://cloud.google.com/storage/docs/public-datasets/era5) -* `openhexa.toolbox.era5.aggregate`: aggregate ERA5 data in space and time +## Usage -### Download from CDS +### Data Acquisition -To download products from the Climate Data Store, you will need to create an account and generate an API key in ECMWF (see [CDS](https://cds.climate.copernicus.eu/)). +Use `prepare_requests()` to build data requests for a specific variable and time range. +If the Zarr store already contains data, only missing data will be requested. If the +Zarr store does not exist, all data in the range will be requested and the store +created. ```python -from openhexa.toolbox.era5.cds import CDS, build_request, bounds_from_file +from pathlib import Path -cds = CDS(key="") +from ecmwf.datastores.client import Client +from era5.extract import prepare_requests, submit_requests, retrieve_requests, grib_to_zarr -request = build_request( +client = Client(url=CDS_API_URL, key=CDS_API_KEY) +zarr_store = Path("data/2m_temperature.zarr") + +# Prepare and chunk data requests +# Existing data in the zarr store will not be requested +requests = prepare_requests( + client, + dataset_id="reanalysis-era5-land", + start_date=date(2025, 3, 1), + end_date=date(2025, 9, 10), variable="2m_temperature", - year=2024, - month=4, - day=[1, 2, 3], - time=[1, 6, 12, 18] + area=[12, -2, 8, 2], # North, West, South, East + zarr_store=zarr_store +) + +raw_dir = Path("data/2m_temperature/raw") +raw_dir.mkdir(parents=True, exist_ok=True) + +# Submit data requests to the CDS API (max 100) +remotes = submit_requests( + client, + collection_id="reanalysis-era5-land", + requests=requests, ) -cds.retrieve( - request=request, - dst_file="data/t2m.grib" +# Retrieve data requests when they are ready +# This will download raw GRIB files to `raw_dir` +retrieve_requests( + client, + dataset_id="reanalysis-era5-land", + requests=requests, + src_dir=raw_dir, ) + +# Convert raw GRIB data to Zarr format +# NB: The zarr store will be created if it does not already existed +grib_to_zarr(raw_dir, zarr_store) ``` -The module also contains helper functions to use bounds from a geoparquet file as an area of -interest. Source bounds are buffered and rounded by default to make sure the required data is -downloaded. +### Data Aggregation + +Use `aggregate_in_space()` to perform spatial aggregation. ```python -bounds = bounds_from_file(fp=Path("data/districts.parquet"), buffer=0.5) - -request = build_request( - variable="total_precipitation", - year=2023, - month=10, - days=[1, 2, 3, 4, 5], - area=bounds +import geopandas as gpd +from era5.transform import create_masks, aggregate_in_space + +boundaries = gpd.read_file("boundaries.geojson") +dataset = xr.open_zarr(zarr_store, decode_timedelta=True) + +# Create spatial masks for aggregation +masks = create_masks( + gdf=boundaries, + id_column="boundary_id", + ds=dataset ) -cds.retrieve( - request=request, - dst_file="data/product.grib" +# Convert from hourly to daily data 1st +daily = dataset.mean(dim="step") + +# Aggregate spatially +results = aggregate_in_space( + ds=daily, + masks=masks, + variable="t2m", + agg="mean" ) +print(results) +``` + +``` +shape: (36, 3) +┌──────────┬────────────┬────────────┐ +│ boundary ┆ time ┆ value │ +│ --- ┆ --- ┆ --- │ +│ str ┆ date ┆ f64 │ +╞══════════╪════════════╪════════════╡ +│ geom1 ┆ 2025-03-28 ┆ 305.402924 │ +│ geom1 ┆ 2025-03-29 ┆ 306.365845 │ +│ geom1 ┆ 2025-03-30 ┆ 306.80304 │ +│ geom1 ┆ 2025-03-31 ┆ 307.176575 │ +│ geom1 ┆ 2025-04-01 ┆ 306.338745 │ +│ … ┆ … ┆ … │ +│ geom4 ┆ 2025-04-01 ┆ 305.957886 │ +│ geom4 ┆ 2025-04-02 ┆ 306.503937 │ +│ geom4 ┆ 2025-04-03 ┆ 305.563995 │ +│ geom4 ┆ 2025-04-04 ┆ 306.381927 │ +│ geom4 ┆ 2025-04-05 ┆ 307.367096 │ +└──────────┴────────────┴────────────┘ ``` -To download multiple products for a given period, use `Client.download_between()`: +Use `aggregate_in_time()` to perform temporal aggregation. ```python -cds.download_between( - variable="2m_temperature", - start=datetime(2020, 1, 1, tzinfo=timezone.utc), - end=datetime(2021, 6, 1, tzinfo=timezone.utc), - dst_dir="data/raw/2m_temperature", - area=bounds +from era5.transform import Period, aggregate_in_time + +# Aggregate to weekly periods +weekly_data = aggregate_in_time( + results, + period=Period.WEEK, + agg="mean" +) + +# DHIS2-compatible Sunday weeks +sunday_weekly = aggregate_in_time( + results, + period=Period.WEEK_SUNDAY, + agg="mean" ) + +print(sunday_weekly) +``` +``` +shape: (8, 3) +┌──────────┬────────────┬────────────┐ +│ boundary ┆ period ┆ value │ +│ --- ┆ --- ┆ --- │ +│ str ┆ str ┆ f64 │ +╞══════════╪════════════╪════════════╡ +│ geom1 ┆ 2025WedW13 ┆ 306.417426 │ +│ geom1 ┆ 2025WedW14 ┆ 307.149551 │ +│ geom2 ┆ 2025WedW13 ┆ 306.327582 │ +│ geom2 ┆ 2025WedW14 ┆ 306.987686 │ +│ geom3 ┆ 2025WedW13 ┆ 306.03266 │ +│ geom3 ┆ 2025WedW14 ┆ 306.774063 │ +│ geom4 ┆ 2025WedW13 ┆ 305.77348 │ +│ geom4 ┆ 2025WedW14 ┆ 306.454239 │ +└──────────┴────────────┴────────────┘ ``` -Checking latest available date in the ERA5-Land dataset: +## Supported variables -```python -cds = CDS("") +The package supports the following ERA5-Land variables: -cds.latest -``` ``` ->>> datetime(2024, 10, 8) +[10m_u_component_of_wind] +name = "10m_u_component_of_wind" +short_name = "u10" +unit = "m s**-1" +time = ["01:00", "07:00", "13:00", "19:00"] + +[10m_v_component_of_wind] +name = "10m_v_component_of_wind" +short_name = "v10" +unit = "m s**-1" +time = ["01:00", "07:00", "13:00", "19:00"] + +[2m_dewpoint_temperature] +name = "2m_dewpoint_temperature" +short_name = "d2m" +unit = "K" +time = ["01:00", "07:00", "13:00", "19:00"] + +[2m_temperature] +name = "2m_temperature" +short_name = "t2m" +unit = "K" +time = ["01:00", "07:00", "13:00", "19:00"] + +[runoff] +name = "runoff" +short_name = "ro" +unit = "m" +time = ["00:00"] + +[soil_temperature_level_1] +name = "soil_temperature_level_1" +short_name = "stl1" +unit = "K" +time = ["01:00", "07:00", "13:00", "19:00"] + +[volumetric_soil_water_layer_1] +name = "volumetric_soil_water_layer_1" +short_name = "swvl1" +unit = "m**3 m**-3" +time = ["01:00", "07:00", "13:00", "19:00"] + +[volumetric_soil_water_layer_2] +name = "volumetric_soil_water_layer_2" +short_name = "swvl2" +unit = "m**3 m**-3" +time = ["01:00", "07:00", "13:00", "19:00"] + +[total_precipitation] +name = "total_precipitation" +short_name = "tp" +unit = "m" +time = ["00:00"] + +[total_evaporation] +name = "total_evaporation" +short_name = "e" +unit = "m" +time = ["00:00"] ``` -NB: End dates in product requests will be automatically replaced by latest available date if they are greater. +See [documentation](https://cds.climate.copernicus.eu/datasets/reanalysis-era5-land) for details. -### Download from Google Cloud +## Zarr store -```python -from openhexa.toolbox.era5.google import Client +### Why Zarr instead of GRIB? -google = Client() +The package converts GRIB files to Zarr format for several reasons: -google.download( - variable="2m_temperature", - date=datetime(2024, 6, 15), - dst_file="data/product.nc" -) -``` +1. **Efficient data access**: Zarr provides chunked, compressed storage that allows reading specific temporal/spatial subsets without loading entire files +2. **Cloud-optimized**: Unlike GRIB files which require sequential reading, Zarr enables parallel and partial reads, ideal for cloud storage +3. **Consolidated metadata**: All metadata is stored in a single `.zmetadata` file, making dataset discovery instant +4. **Append-friendly**: New time steps can be efficiently appended without rewriting existing data +5. **Analysis-ready**: Direct integration with xarray makes the data immediately usable for scientific computing -Or to download all products for a given period: +### How is the Zarr store managed? -```python -# if products are already presents in dst_dir, they will be skipped -google.sync( - variable="2m_temperature", - start_date=datetime(2022, 1, 1), - end_date=datetime(2022, 6, 1), - dst_dir="data" -) -``` +The ERA5 toolbox implements the following data pipeline: + +1. **Initial download**: GRIB files from CDS are treated as temporary artifacts +2. **Conversion**: `grib_to_zarr()` converts GRIB to Zarr, handling: + - Automatic creation of new stores + - Appending to existing stores without duplicating time steps + - Metadata consolidation for optimal performance +3. **Incremental updates**: When requesting new data, the package: + - Checks existing time coverage in the Zarr store + - Only downloads missing time periods + - Appends new data -### Aggregation +### Reading data from the Zarr store ```python -from pathlib import Path +import xarray as xr -import geopandas as gpd -from openhexa.toolbox.era5.aggregate import build_masks, merge, aggregate, get_transform +# Open the zarr store (lazy loading - no data read yet) +ds = xr.open_zarr("data/2m_temperature.zarr", consolidated=True) -boundaries = gpd.read_parquet("districts.parquet") -data_dir = Path("data/era5/total_precipitation") +# Explore the dataset structure +print(ds) # Shows dimensions, coordinates, and variables +print(ds.time.values) # Time range available -ds = merge(data_dir) +# Access specific time ranges +subset = ds.sel(time=slice("2025-01", "2025-03")) -ncols = len(ds.longitude) -nrows = len(ds.latitude) -transform = get_transform(ds) -masks = build_masks(boundaries, nrows, ncols, transform) +# Load only specific variables +temperature = ds["t2m"] # Still lazy +temp_values = temperature.values # Triggers actual data read -df = aggregate( - ds=ds, - var="tp", - masks=masks, - boundaries_id=[uid for uid in boundaries["district_id"]] -) +# Spatial subsetting +region = ds.sel(latitude=slice(10, 5), longitude=slice(-1, 2)) -print(df) -``` +# Time aggregation (hourly to daily) +daily_mean = ds.resample(time="1D").mean() + +# Direct computation without loading everything +monthly_max = ds["t2m"].resample(time="1M").max().compute() ``` -shape: (18_410, 5) -┌─────────────┬────────────┬───────────┬──────────┬───────────┐ -│ boundary_id ┆ date ┆ mean ┆ min ┆ max │ -│ --- ┆ --- ┆ --- ┆ --- ┆ --- │ -│ str ┆ date ┆ f64 ┆ f64 ┆ f64 │ -╞═════════════╪════════════╪═══════════╪══════════╪═══════════╡ -│ mPenE8ZIBFC ┆ 2024-01-01 ┆ 0.000462 ┆ 0.0 ┆ 0.00086 │ -│ TPgpGxUBU9y ┆ 2024-01-01 ┆ 0.000462 ┆ 0.0 ┆ 0.00086 │ -│ AhST5ZpuCDJ ┆ 2024-01-01 ┆ 0.000462 ┆ 0.0 ┆ 0.00086 │ -│ Lp2BjBVT63s ┆ 2024-01-01 ┆ 0.000462 ┆ 0.0 ┆ 0.00086 │ -│ EdfRX9b9vEb ┆ 2024-01-01 ┆ 0.000462 ┆ 0.0 ┆ 0.00086 │ -│ yhs1ecKsLOc ┆ 2024-01-01 ┆ 0.000462 ┆ 0.0 ┆ 0.00086 │ -│ iHSJypSwlo5 ┆ 2024-01-01 ┆ 0.000462 ┆ 0.0 ┆ 0.00086 │ -│ CTtB0TPRvWc ┆ 2024-01-01 ┆ 0.000462 ┆ 0.0 ┆ 0.00086 │ -│ eVFAuZOzogt ┆ 2024-01-01 ┆ 0.000462 ┆ 0.0 ┆ 0.00086 │ -│ WVEJjdJ2S15 ┆ 2024-01-01 ┆ 0.000462 ┆ 0.0 ┆ 0.00086 │ -│ rbYGKFgupK9 ┆ 2024-01-01 ┆ 0.000462 ┆ 0.0 ┆ 0.00086 │ -│ Nml6rVDElLh ┆ 2024-01-01 ┆ 0.000462 ┆ 0.0 ┆ 0.00086 │ -│ E0hd8TD1M0q ┆ 2024-01-01 ┆ 0.000462 ┆ 0.0 ┆ 0.00086 │ -│ PCg4pLGmKSM ┆ 2024-01-01 ┆ 0.000462 ┆ 0.0 ┆ 0.00086 │ -│ C6EBhE8OnfW ┆ 2024-01-01 ┆ 0.000462 ┆ 0.0 ┆ 0.00086 │ -│ … ┆ … ┆ … ┆ … ┆ … │ -│ CkpfOFkMyrd ┆ 2024-10-07 ┆ 1.883121 ┆ 0.001785 ┆ 2.700447 │ -│ tMXsltjzzmR ┆ 2024-10-07 ┆ 3.579136 ┆ 0.105436 ┆ 4.702504 │ -│ F0ytkh0RExg ┆ 2024-10-07 ┆ 8.415455 ┆ 0.838535 ┆ 17.08884 │ -... -│ TTSmaRnHa82 ┆ 2024-10-07 ┆ 1.724243 ┆ 0.007809 ┆ 5.692989 │ -│ jbmw2gdrrTV ┆ 2024-10-07 ┆ 1.176629 ┆ 0.110173 ┆ 1.582995 │ -│ eKYyXbBdvmB ┆ 2024-10-07 ┆ 0.599976 ┆ 0.037771 ┆ 1.189411 │ -└─────────────┴────────────┴───────────┴──────────┴───────────┘ -``` \ No newline at end of file diff --git a/openhexa/toolbox/era5/aggregate.py b/openhexa/toolbox/era5/aggregate.py deleted file mode 100644 index ad0075b4..00000000 --- a/openhexa/toolbox/era5/aggregate.py +++ /dev/null @@ -1,370 +0,0 @@ -"""Module for spatial and temporal aggregation of ERA5 data.""" - -from datetime import datetime -from pathlib import Path - -import geopandas as gpd -import numpy as np -import polars as pl -import rasterio -import xarray as xr -from epiweeks import Week -from rasterio.features import rasterize -from rasterio.transform import Affine, from_bounds - - -def clip_dataset(ds: xr.Dataset, xmin: float, ymin: float, xmax: float, ymax: float) -> xr.Dataset: - """Clip input xarray dataset according to the provided bounding box. - - Assumes lat & lon dimensions are named "latitude" and "longitude". Longitude in the - source dataset is expected to be in the range [0, 360], and will be converted to - [-180, 180]. - - Parameters - ---------- - ds : xr.Dataset - Input xarray dataset. - xmin : float - Minimum longitude. - ymin : float - Minimum latitude. - xmax : float - Maximum longitude. - ymax : float - Maximum latitude. - - Returns - ------- - xr.Dataset - Clipped xarray dataset. - """ - ds = ds.assign_coords(longitude=(((ds.longitude + 180) % 360) - 180)).sortby("longitude") - ds = ds.where((ds.longitude >= xmin) & (ds.longitude <= xmax), drop=True) - ds = ds.where((ds.latitude >= ymin) & (ds.latitude <= ymax), drop=True) - return ds - - -def get_transform(ds: xr.Dataset) -> Affine: - """Get rasterio affine transform from xarray dataset. - - Parameters - ---------- - ds : xr.Dataset - Input xarray dataset. - - Returns - ------- - Affine - Rasterio affine transform. - """ - transform = from_bounds( - ds.longitude.values.min(), - ds.latitude.values.min(), - ds.longitude.values.max(), - ds.latitude.values.max(), - len(ds.longitude), - len(ds.latitude), - ) - return transform - - -def build_masks( - boundaries: gpd.GeoDataFrame, height: int, width: int, transform: rasterio.Affine -) -> tuple[np.ndarray, rasterio.Affine]: - """Build binary masks for all geometries in a dataframe. - - We build a raster of shape (n_boundaries, n_height, n_width) in order to store one binary mask - per boundary. Boundaries shapes cannot be stored in a single array as we want masks to overlap - if needed. - - Parameters - ---------- - boundaries : gpd.GeoDataFrame - Input GeoDataFrame containing the boundaries. - height : int - Height of the raster (number of pixels) - width : int - Width of the raster (number of pixels) - transform : rasterio.Affine - Raster affine transform - - Returns - ------- - np.ndarray - Binary masks as a numpy ndarray of shape (n_boundaries, height, width) - """ - masks = np.ndarray(shape=(len(boundaries), height, width), dtype=np.bool_) - for i, geom in enumerate(boundaries.geometry): - mask = rasterize( - shapes=[geom.__geo_interface__], - out_shape=(height, width), - fill=0, - default_value=1, - all_touched=True, - transform=transform, - ) - masks[i, :, :] = mask == 1 - return masks - - -def merge(data_dir: Path | str) -> xr.Dataset: - """Merge all .grib files in a directory into a single xarray dataset. - - If multiple values are available for a given time, step, longitude & latitude dimensions, the - maximum value is kept. - - Parameters - ---------- - data_dir : Path | str - Directory containing the .grib files. - - Returns - ------- - xr.Dataset - Merged xarray dataset with time, step, longitude and latitude dimensions. - """ - if isinstance(data_dir, str): - data_dir = Path(data_dir) - - files = data_dir.glob("*.grib") - ds = xr.open_dataset(next(files), engine="cfgrib", decode_timedelta=True) - if "time" not in ds.dims and "time" in ds.coords: - # xarray drop the time dimension if it has only one value - ds = ds.expand_dims("time") - - for f in files: - ds2 = xr.open_dataset(f, engine="cfgrib", decode_timedelta=True) - if "time" not in ds2.dims and "time" in ds2.coords: - ds2 = ds2.expand_dims("time") - ds = xr.concat([ds, ds2], dim="tmp_dim").max(dim="tmp_dim") - - return ds - - -def _np_to_datetime(dt64: np.datetime64) -> datetime: - epoch = np.datetime64(0, "s") - one_second = np.timedelta64(1, "s") - seconds_since_epoch = (dt64 - epoch) / one_second - return datetime.fromtimestamp(seconds_since_epoch) - - -def _has_missing_data(da: xr.DataArray) -> bool: - """A DataArray is considered to have missing data if not all hours have measurements.""" - missing = False - - # if da.step.size == 1, da.step is just an int so we cannot iterate over it - # if da.step size > 1, da.step is an array of int (one per step) - if da.step.size > 1: - for step in da.step: - if da.sel(step=step).isnull().all(): - missing = True - else: - missing = da.isnull().all() - - return missing - - -def _week(date: datetime) -> str: - year = date.isocalendar()[0] - week = date.isocalendar()[1] - return f"{year}W{week}" - - -def _epi_week(date: datetime) -> str: - epiweek = Week.fromdate(date) - year = epiweek.year - week = epiweek.week - return f"{year}W{week}" - - -def _month(date: datetime) -> str: - return date.strftime("%Y%m") - - -def aggregate(ds: xr.Dataset, var: str, masks: np.ndarray, boundaries_id: list[str]) -> pl.DataFrame: - """Aggregate hourly measurements in space and time. - - Parameters - ---------- - ds : xr.Dataset - Input xarray dataset with time, step, longitude and latitude dimensions - var : str - Variable to aggregate (ex: "t2m" or "tp") - masks : np.ndarray - Binary masks as a numpy ndarray of shape (n_boundaries, height, width) - boundaries_id : list[str] - List of boundary IDs (same order as n_boundaries dimension in masks) - - Notes - ----- - The function aggregates hourly measurements to daily values for each boundary. - - Temporal aggregation is applied first. 3 statistics are computed for each day: daily mean, - daily min, and daily max. - - Spatial aggregation is then applied. For each boundary, 3 statistics are computed: average of - daily means, average of daily min, and average of daily max. These 3 statistics are stored in - the "mean", "min", and "max" columns of the output dataframe. - """ - rows = [] - - for day in ds.time.values: - da = ds[var].sel(time=day) - - if _has_missing_data(da): - continue - - # if there is a step dimension (= hourly measurements), aggregate to daily - # if not, data is already daily - if "step" in da.dims: - da_mean = da.mean(dim="step").values - da_min = da.min(dim="step").values - da_max = da.max(dim="step").values - else: - da_mean = da.values - da_min = da.values - da_max = da.values - - for i, uid in enumerate(boundaries_id): - v_mean = np.nanmean(da_mean[masks[i, :, :]]) - v_min = np.nanmin(da_min[masks[i, :, :]]) - v_max = np.nanmax(da_max[masks[i, :, :]]) - - rows.append( - { - "boundary_id": uid, - "date": _np_to_datetime(day).date(), - "mean": v_mean, - "min": v_min, - "max": v_max, - } - ) - - SCHEMA = { - "boundary_id": pl.String, - "date": pl.Date, - "mean": pl.Float64, - "min": pl.Float64, - "max": pl.Float64, - } - - df = pl.DataFrame(data=rows, schema=SCHEMA) - - # add week, month, and epi_week period columns - df = df.with_columns( - pl.col("date").map_elements(_week, str).alias("week"), - pl.col("date").map_elements(_month, str).alias("month"), - pl.col("date").map_elements(_epi_week, str).alias("epi_week"), - ) - - return df - - -def aggregate_per_week( - daily: pl.DataFrame, - column_uid: str, - use_epidemiological_weeks: bool = False, - sum_aggregation: bool = False, -) -> pl.DataFrame: - """Aggregate daily data per week. - - Parameters - ---------- - daily : pl.DataFrame - Daily data with a "week" or "epi_week", "mean", "min", and "max" columns - Length of the dataframe should be (n_boundaries * n_days). - column_uid : str - Column containing the boundary ID. - use_epidemiological_weeks : bool, optional - Use epidemiological weeks instead of iso weeks. - sum_aggregation : bool, optional - If True, sum values instead of computing the mean, for example for total precipitation data. - - Returns - ------- - pl.DataFrame - Weekly aggregated data of length (n_boundaries * n_weeks). - """ - if use_epidemiological_weeks: - week_column = "epi_week" - else: - week_column = "week" - - df = daily.select([column_uid, pl.col(week_column).alias("week"), "mean", "min", "max"]) - - if sum_aggregation: - df = df.group_by([column_uid, "week"]).agg( - [ - pl.col("mean").sum().alias("mean"), - pl.col("min").sum().alias("min"), - pl.col("max").sum().alias("max"), - ] - ) - else: - df = df.group_by([column_uid, "week"]).agg( - [ - pl.col("mean").mean().alias("mean"), - pl.col("min").min().alias("min"), - pl.col("max").max().alias("max"), - ] - ) - - # sort per date since dhis2 period format is "2012W9", we need to extract year and week number - # from the period string and cast them to int before sorting, else "2012W9" will be superior to - # "2012W32" - df = df.sort( - by=[ - pl.col("week").str.split("W").list.get(0).cast(int), - pl.col("week").str.split("W").list.get(1).cast(int), - pl.col(column_uid), - ] - ) - - return df - - -def aggregate_per_month(daily: pl.DataFrame, column_uid: str, sum_aggregation: bool = False) -> pl.DataFrame: - """Aggregate daily data per month. - - Parameters - ---------- - daily : pl.DataFrame - Daily data with a "month", "mean", "min", and "max" columns - Length of the dataframe should be (n_boundaries * n_days). - column_uid : str - Column containing the boundary ID. - sum_aggregation : bool, optional - If True, sum values instead of computing the mean, for example for total precipitation data. - - Returns - ------- - pl.DataFrame - Monthly aggregated data of length (n_boundaries * n_months). - """ - df = daily.select([column_uid, "month", "mean", "min", "max"]) - - if sum_aggregation: - df = df.group_by([column_uid, "month"]).agg( - [ - pl.col("mean").sum().alias("mean"), - pl.col("min").sum().alias("min"), - pl.col("max").sum().alias("max"), - ] - ) - else: - df = df.group_by([column_uid, "month"]).agg( - [ - pl.col("mean").mean().alias("mean"), - pl.col("min").min().alias("min"), - pl.col("max").max().alias("max"), - ] - ) - - df = df.sort( - by=[ - pl.col("month").cast(int), - pl.col(column_uid), - ] - ) - - return df diff --git a/openhexa/toolbox/era5/cds.py b/openhexa/toolbox/era5/cds.py deleted file mode 100644 index 92bbcd4f..00000000 --- a/openhexa/toolbox/era5/cds.py +++ /dev/null @@ -1,463 +0,0 @@ -"""Client to download ERA5-Land data products from the climate data store. - -See . -""" - -from __future__ import annotations - -import importlib.resources -import json -import logging -import shutil -import tempfile -import zipfile -from calendar import monthrange -from dataclasses import dataclass -from datetime import datetime, timedelta, timezone -from functools import cached_property -from math import ceil -from pathlib import Path -from time import sleep -from typing import Iterator - -import geopandas as gpd -import xarray as xr -from datapi import ApiClient, Remote -from requests.exceptions import HTTPError - -with importlib.resources.open_text("openhexa.toolbox.era5", "variables.json") as f: - VARIABLES = json.load(f) - -DATASET = "reanalysis-era5-land" - -log = logging.getLogger(__name__) - - -@dataclass -class DataRequest: - """CDS data request as expected by the API.""" - - variable: list[str] - year: str - month: str - day: list[str] - time: list[str] - data_format: str = "grib" - area: list[float] | None = None - - -def bounds_from_file(fp: Path, buffer: float = 0.5) -> list[float]: - """Get bounds from file. - - Parameters - ---------- - fp : Path - File path. - buffer : float, optional - Buffer to add to the bounds (default=0.5). - - Returns - ------- - list[float] - Bounds (north, west, south, east). - """ - boundaries = gpd.read_parquet(fp) - xmin, ymin, xmax, ymax = boundaries.total_bounds - xmin = ceil(xmin - buffer) - ymin = ceil(ymin - buffer) - xmax = ceil(xmax + buffer) - ymax = ceil(ymax + buffer) - return ymax, xmin, ymin, xmax - - -def get_period_chunk(dtimes: list[datetime]) -> dict: - """Get the period chunk for a list of datetimes. - - The period chunk is a dictionary with the "year", "month", "day" and "time" keys as expected by - the CDS API. A period chunk cannot contain more than 1 year and 1 month. However, it can - contain any number of days and times. - - This is the temporal part of a CDS data request. - - Parameters - ---------- - dtimes : list[datetime] - A list of datetimes for which we want data - - Returns - ------- - dict - The period chunk, in other words the temporal part of the request payload - - Raises - ------ - ValueError - If the list of datetimes contains more than 1 year or more than 1 month - """ - years = {dtime.year for dtime in dtimes} - if len(years) > 1: - msg = "Cannot create a period chunk for multiple years" - raise ValueError(msg) - months = {dtime.month for dtime in dtimes} - if len(months) > 1: - msg = "Cannot create a period chunk for multiple months" - raise ValueError(msg) - - year = next(iter(years)) - month = next(iter(months)) - days = [] - - for dtime in sorted(dtimes): - if dtime.day not in days: - days.append(dtime.day) - - return { - "year": str(year), - "month": f"{month:02}", - "day": [f"{day:02}" for day in days], - } - - -def iter_chunks(dtimes: list[datetime]) -> Iterator[dict]: - """Get the period chunks for a list of datetimes. - - The period chunks are a list of dictionaries with the "year", "month", "day" and "time" keys as - expected by the CDS API. A period chunk cannot contain more than 1 year and 1 month. However, - it can contain any number of days and times. - - The function tries its best to generate the minimum amount of chunks to minimize the amount of requests. - - Parameters - ---------- - dtimes : list[datetime] - A list of datetimes for which we want data - - Returns - ------- - Iterator[dict] - The period chunks (one per month max) - """ - for year in range(min(dtimes).year, max(dtimes).year + 1): - for month in range(12): - dtimes_month = [dtime for dtime in dtimes if dtime.year == year and dtime.month == month + 1] - if dtimes_month: - yield get_period_chunk(dtimes_month) - - -def list_datetimes_in_dataset(ds: xr.Dataset) -> list[datetime]: - """List datetimes in input dataset for which data is available. - - It is assumed that the dataset has a `time` dimension, in addition to `latitude` and `longitude` - dimensions. We consider that a datetime is available in a dataset if non-null data values are - present for more than 1 step. - """ - dtimes = [] - data_vars = list(ds.data_vars) - var = data_vars[0] - - for time in ds.time.values: - dtime = datetime.fromtimestamp(time.astype(int) / 1e9, tz=timezone.utc) - if dtime in dtimes: - continue - non_null = ds.sel(time=time)[var].notnull().sum().values.item() - if non_null > 0: - dtimes.append(dtime) - - return dtimes - - -def list_datetimes_in_dir(data_dir: Path) -> list[datetime]: - """List datetimes in datasets that can be found in an input directory.""" - # make sure all grib files are decompressed and index files are removed - decompress_grib_files(data_dir) - remove_index_files(data_dir) - - dtimes = [] - - for f in data_dir.glob("*.grib"): - ds = xr.open_dataset(f, engine="cfgrib", decode_timedelta=True) - # xarray drop the time dimension if it has only one value, so we expand it - # to make sure structure is consistent with other datasets - if "time" not in ds.dims and "time" in ds.coords: - ds = ds.expand_dims("time") - dtimes += list_datetimes_in_dataset(ds) - - dtimes = sorted(set(dtimes)) - - msg = f"Scanned {data_dir.as_posix()}, found data for {len(dtimes)} dates" - log.info(msg) - - return dtimes - - -def date_range(start: datetime, end: datetime) -> list[datetime]: - """Get a range of dates with a 1-day step.""" - drange = [] - dt = start - while dt <= end: - drange.append(dt) - dt += timedelta(days=1) - return drange - - -def build_request( - variable: str, - year: int, - month: int, - day: list[int] | list[str] | None = None, - time: list[int] | list[str] | None = None, - data_format: str = "grib", - area: list[float] | None = None, -) -> DataRequest: - """Build request payload. - - Parameters - ---------- - variable : str - Climate data store variable name (ex: "2m_temperature"). - year : int - Year of interest. - month : int - Month of interest. - day : list[int] | list[str] | None, optional - Days of interest. Defauls to None (all days). - time : list[int] | list[str] | None, optional - Hours of interest (ex: [1, 6, 18]). Defaults to None (all hours). - data_format : str, optional - Output data format ("grib" or "netcdf"). Defaults to "grib". - area : list[float] | None, optional - Area of interest (north, west, south, east). Defaults to None (world). - - Returns - ------- - DataRequest - CDS data equest payload. - - Raises - ------ - ValueError - Request parameters are not valid. - """ - if variable not in VARIABLES: - msg = f"Variable {variable} not supported" - raise ValueError(msg) - - if data_format not in ["grib", "netcdf"]: - msg = f"Data format {data_format} not supported" - raise ValueError(msg) - - # in the CDS data request, area is an array of float or int in the following order: - # [north, west, south, east] - if area: - n, w, s, e = area - msg = "Invalid area of interest" - max_lat = 90 - max_lon = 180 - if ((abs(n) > max_lat) or (abs(s) > max_lat)) or ((abs(w) > max_lon) or (abs(e) > max_lon)): - raise ValueError(msg) - if (n < s) or (e < w): - raise ValueError(msg) - - # in the CDS data request, days must be an array of strings (one string per day) - # ex: ["01", "02", "03"] - if not day: - dmax = monthrange(year, month)[1] - day = list(range(1, dmax + 1)) - - if isinstance(day[0], int): - day = [f"{d:02}" for d in day] - - # in the CDS data request, time must be an array of strings (one string per hour) - # only hours between 00:00 and 23:00 are valid - # ex: ["00:00", "03:00", "06:00"] - if not time: - time = range(24) - - if isinstance(time[0], int): - time = [f"{hour:02}:00" for hour in time] - - return DataRequest( - variable=[variable], - year=str(year), - month=f"{month:02}", - day=day, - time=time, - data_format="grib", - area=list(area) if area else None, - ) - - -class CDS: - """Climate data store API client based on datapi.""" - - def __init__(self, key: str, url: str = "https://cds.climate.copernicus.eu/api") -> None: - """Initialize CDS client.""" - self.client = ApiClient(key=key, url=url) - self.client.check_authentication() - msg = f"Sucessfully authenticated to {url}" - log.info(msg) - - @cached_property - def latest(self) -> datetime: - """Get date of latest available product.""" - collection = self.client.get_collection(DATASET) - return collection.end_datetime - - def get_remote_requests(self) -> list[dict]: - """Fetch list of the last 100 data requests in the CDS account.""" - requests = [] - jobs = self.client.get_jobs(limit=100) - for request_id in jobs.request_uids: - try: - remote = self.client.get_remote(request_id) - if remote.status in ["failed", "dismissed", "deleted"]: - continue - requests.append({"request_id": request_id, "request": remote.request}) - except HTTPError: - continue - return requests - - def get_remote_from_request(self, request: DataRequest, existing_requests: list[dict]) -> Remote | None: - """Look for a remote object that matches the provided request payload. - - Parameters - ---------- - request : DataRequest - Data request payload to look for. - existing_requests : list[dict] - List of existing data requests (as returned by self.get_remote_requests()). - - Returns - ------- - Remote | None - Remote object if found, None otherwise. - """ - if not existing_requests: - return None - - for remote_request in existing_requests: - if remote_request["request"] == request.__dict__: - return self.client.get_remote(remote_request["request_id"]) - - return None - - def submit(self, request: DataRequest) -> Remote: - """Submit an async data request to the CDS API. - - If an identical data request has already been submitted, the Remote object corresponding to - the existing data request is returned instead of submitting a new one. - """ - return self.client.submit(DATASET, request=request.__dict__) - - def retrieve(self, request: DataRequest, dst_file: Path | str) -> None: - """Submit and download a data request to the CDS API.""" - dst_file = Path(dst_file) - dst_file.parent.mkdir(parents=True, exist_ok=True) - self.client.retrieve(collection_id=DATASET, target=dst_file, request=request.__dict__) - - def download_between( - self, - start: datetime, - end: datetime, - variable: str, - area: list[float], - dst_dir: str | Path, - time: list[int] | None = None, - ) -> None: - """Download all ERA5 data files needed to cover the period. - - Data requests are sent asynchronously (max one per month) to the CDS API and fetched when - they are completed. - - Parameters - ---------- - start : datetime - Start date. - end : datetime - End date. - variable : str - Climate data store variable name (ex: "2m_temperature"). - area : list[float] - Area of interest (north, west, south, east). - dst_dir : str | Path - Output directory. - time : list[int] | None, optional - Hours of interest (ex: [1, 6, 18]). Defaults to None (all hours). - """ - dst_dir = Path(dst_dir) - dst_dir.mkdir(parents=True, exist_ok=True) - - if not start.tzinfo: - start = start.astimezone(tz=timezone.utc) - if not end.tzinfo: - end = end.astimezone(tz=timezone.utc) - - if end > self.latest: - end = self.latest - msg = "End date is after latest available product, setting end date to {}".format(end.strftime("%Y-%m-%d")) - log.info(msg) - - # get the list of dates for which we will want to download data, which is the difference - # between the available (already downloaded) and the requested dates - drange = date_range(start, end) - available = [dtime.date() for dtime in list_datetimes_in_dir(dst_dir)] - dates = [d for d in drange if d.date() not in available] - msg = f"Will request data for {len(dates)} dates" - log.info(msg) - - existing_requests = self.get_remote_requests() - remotes: list[Remote] = [] - - for chunk in iter_chunks(dates): - request = build_request(variable=variable, data_format="grib", area=area, time=time, **chunk) - - # has a similar request been submitted recently? if yes, use it instead of submitting - # a new one - remote = self.get_remote_from_request(request, existing_requests) - if remote: - remotes.append(remote) - msg = f"Found existing request for date {request.year}-{request.month}" - log.info(msg) - else: - remote = self.submit(request) - remotes.append(remote) - msg = f"Submitted new data request {remote.request_id} for {request.year}-{request.month}" - - while remotes: - for remote in remotes: - if remote.results_ready: - request = remote.request - fname = f"{request['year']}{request['month']}_{remote.request_id}.grib" - dst_file = Path(dst_dir, fname) - remote.download(dst_file.as_posix()) - msg = f"Downloaded {dst_file.name}" - log.info(msg) - remotes.remove(remote) - remote.delete() - - if remotes: - msg = f"Still {len(remotes)} files to download. Waiting 30s before retrying..." - log.info(msg) - sleep(30) - - -def decompress_grib_files(data_dir: Path) -> None: - """Decompress all grib files in a directory.""" - for fp in data_dir.glob("*.grib"): - if zipfile.is_zipfile(fp): - with ( - tempfile.NamedTemporaryFile(suffix=".grib") as tmp, - zipfile.ZipFile(fp, "r") as zip_file, - ): - tmp.write(zip_file.read("data.grib")) - shutil.copy(tmp.name, fp) - msg = f"Decompressed {fp.name}" - log.info(msg) - - -def remove_index_files(data_dir: Path) -> None: - """Remove all GRIB index files in a directory.""" - for fp in data_dir.glob("*.idx"): - fp.unlink() - msg = f"Removed index file {fp.name}" - log.debug(msg) diff --git a/openhexa/toolbox/era5/data/variables.toml b/openhexa/toolbox/era5/data/variables.toml new file mode 100644 index 00000000..9f04eca5 --- /dev/null +++ b/openhexa/toolbox/era5/data/variables.toml @@ -0,0 +1,68 @@ +# Information about supported ERA5-Land variables. +# +# - name: Name/identifier of the variable, used for requests. +# - short_name: Short name used in data files, used for processing. +# - unit: Scientific unit of the variable. +# - time: A list of hours (HH:MM) to fetch for daily aggregation. +# - Accumulated variables (e.g., precipitation) are fetched at "00:00". +# - Instantaneous variables are sampled at four hours. + +[10m_u_component_of_wind] +name = "10m_u_component_of_wind" +short_name = "u10" +unit = "m s**-1" +time = ["01:00", "07:00", "13:00", "19:00"] + +[10m_v_component_of_wind] +name = "10m_v_component_of_wind" +short_name = "v10" +unit = "m s**-1" +time = ["01:00", "07:00", "13:00", "19:00"] + +[2m_dewpoint_temperature] +name = "2m_dewpoint_temperature" +short_name = "d2m" +unit = "K" +time = ["01:00", "07:00", "13:00", "19:00"] + +[2m_temperature] +name = "2m_temperature" +short_name = "t2m" +unit = "K" +time = ["01:00", "07:00", "13:00", "19:00"] + +[runoff] +name = "runoff" +short_name = "ro" +unit = "m" +time = ["00:00"] + +[soil_temperature_level_1] +name = "soil_temperature_level_1" +short_name = "stl1" +unit = "K" +time = ["01:00", "07:00", "13:00", "19:00"] + +[volumetric_soil_water_layer_1] +name = "volumetric_soil_water_layer_1" +short_name = "swvl1" +unit = "m**3 m**-3" +time = ["01:00", "07:00", "13:00", "19:00"] + +[volumetric_soil_water_layer_2] +name = "volumetric_soil_water_layer_2" +short_name = "swvl2" +unit = "m**3 m**-3" +time = ["01:00", "07:00", "13:00", "19:00"] + +[total_precipitation] +name = "total_precipitation" +short_name = "tp" +unit = "m" +time = ["00:00"] + +[total_evaporation] +name = "total_evaporation" +short_name = "e" +unit = "m" +time = ["00:00"] diff --git a/openhexa/toolbox/era5/dhis2weeks.py b/openhexa/toolbox/era5/dhis2weeks.py new file mode 100644 index 00000000..4a2dca2c --- /dev/null +++ b/openhexa/toolbox/era5/dhis2weeks.py @@ -0,0 +1,105 @@ +"""A set of functions to convert dates to all types of DHIS2 periods.""" + +from datetime import date, timedelta +from enum import StrEnum + + +class WeekType(StrEnum): + """DHIS2 weekly period types.""" + + WEEK = "WEEK" + WEEK_WEDNESDAY = "WEEK_WEDNESDAY" + WEEK_THURSDAY = "WEEK_THURSDAY" + WEEK_SATURDAY = "WEEK_SATURDAY" + WEEK_SUNDAY = "WEEK_SUNDAY" + + +start_days = { + WeekType.WEEK_WEDNESDAY: 3, + WeekType.WEEK_THURSDAY: 4, + WeekType.WEEK_SATURDAY: 6, + WeekType.WEEK_SUNDAY: 7, +} + + +def get_calendar_week(dt: date, week_type: WeekType) -> tuple[int, int]: + """Get week number and year for a given date and week type. + + Args: + dt: The date to convert. + week_type: The type of week period. One of 'WEEK', 'WEEK_WEDNESDAY', + 'WEEK_THURSDAY', 'WEEK_SATURDAY', 'WEEK_SUNDAY'. + + Returns: + A tuple (year, week number). + + """ + # We can use the ISO calendar for standard Monday weeks + if week_type == WeekType.WEEK: + iso_year, iso_week, _ = dt.isocalendar() + return (iso_year, iso_week) + + # 1st week of the year always contain Jan 4th + week_start = adjust_to_week_start(dt, start_days[week_type]) + jan4 = date(week_start.year, 1, 4) + first_week_start = adjust_to_week_start(jan4, start_days[week_type]) + + # Week start is before the 1st week of the year, so it belongs to the last week of + # the previous year + if week_start < first_week_start: + jan4_prev = date(week_start.year - 1, 1, 4) + first_week_start_prev = adjust_to_week_start(jan4_prev, start_days[week_type]) + weeks_from_start = (week_start - first_week_start_prev).days // 7 + return week_start.year - 1, weeks_from_start + 1 + + # If we are in late December, we might belong to next year's first week + if week_start.month == 12: + week_end = week_start + timedelta(days=6) + if week_end.month == 1: + jan4_next = date(week_start.year + 1, 1, 4) + if week_start <= jan4_next <= week_end: + return week_start.year + 1, 1 + + # Happy path: we are in the current year's weeks + weeks_from_start = (week_start - first_week_start).days // 7 + return week_start.year, weeks_from_start + 1 + + +def to_dhis2_week(dt: date, week_type: WeekType) -> str: + """Convert a date to a DHIS2 period string. + + Args: + dt: The date to convert. + week_type: The type of week period. One of 'WEEK', 'WEEK_WEDNESDAY', + 'WEEK_THURSDAY', 'WEEK_SATURDAY', 'WEEK_SUNDAY'. + + Returns: + The DHIS2 period string. + + """ + year, week = get_calendar_week(dt, week_type) + + prefix = { + WeekType.WEEK: "W", + WeekType.WEEK_WEDNESDAY: "WedW", + WeekType.WEEK_THURSDAY: "ThuW", + WeekType.WEEK_SATURDAY: "SatW", + WeekType.WEEK_SUNDAY: "SunW", + }[week_type] + + return f"{year}{prefix}{week}" + + +def adjust_to_week_start(dt: date, start_day: int) -> date: + """Adjust date to the start of the week. + + Args: + dt: The date to adjust. + start_day: The day of the week the week starts on (1=Monday, 7=Sunday). + + Returns: + The adjusted date. + + """ + days_to_adjust = (dt.weekday() - (start_day - 1)) % 7 + return dt - timedelta(days=days_to_adjust) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py new file mode 100644 index 00000000..7f8ea1b6 --- /dev/null +++ b/openhexa/toolbox/era5/extract.py @@ -0,0 +1,499 @@ +"""Download ERA5-Land hourly data from the ECMWF Climate Data Store (CDS). + +Provides functions to build requests, submit them to the CDS API, retrieve results, and +move GRIB data to an analysis-ready Zarr store for further processing. +""" + +import importlib.resources +import logging +import shutil +import tempfile +import tomllib +from collections import defaultdict +from datetime import date +from pathlib import Path +from time import sleep +from typing import Literal, TypedDict + +import numpy as np +import numpy.typing as npt +import xarray as xr +import zarr +from dateutil.relativedelta import relativedelta +from ecmwf.datastores import Remote +from ecmwf.datastores.client import Client + +logger = logging.getLogger(__name__) + + +class Variable(TypedDict): + """Metadata for a single variable in the ERA5-Land dataset.""" + + name: str + short_name: str + unit: str + time: list[str] + + +def _get_variables() -> dict[str, Variable]: + """Load ERA5-Land variables metadata. + + Returns: + A dictionary mapping variable names to their metadata. + + """ + with importlib.resources.path("era5", "data", "variables.toml") as path, path.open("rb") as f: + return tomllib.load(f) + + +class Request(TypedDict): + """Request parameters for the 'reanalysis-era5-land' dataset.""" + + variable: list[str] + year: str + month: str + day: list[str] + time: list[str] + data_format: Literal["grib", "netcdf"] + download_format: Literal["unarchived", "zip"] + area: list[int] + + +def get_date_range( + start_date: date, + end_date: date, +) -> list[date]: + """Get inclusive date range from start and end dates. + + Returns: + A list of dates from start to end, inclusive. + + """ + if start_date > end_date: + msg = "Start date must be before end date" + logger.error(msg) + raise ValueError(msg) + + date_range: list[date] = [] + current_date = start_date + while current_date <= end_date: + date_range.append(current_date) + current_date += relativedelta(days=1) + return date_range + + +def bound_date_range( + start_date: date, + end_date: date, + collection_start_date: date, + collection_end_date: date, +) -> tuple[date, date]: + """Bound input date range to the collection's start and end dates. + + Args: + start_date: Requested start date. + end_date: Requested end date. + collection_start_date: Earliest date in the collection. + collection_end_date: Latest date in the collection. + + Returns: + A new date range tuple (start, end) within the collection's date limits. + + """ + start = max(start_date, collection_start_date) + end = min(end_date, collection_end_date) + return start, end + + +class RequestTemporal(TypedDict): + """Temporal request parameters.""" + + year: str + month: str + day: list[str] + + +def get_temporal_chunks(dates: list[date]) -> list[RequestTemporal]: + """Get monthly temporal request chunks for the given list of dates. + + Args: + dates: A list of dates to chunk. + + Returns: + A list of RequestTemporal objects, one per month. + + """ + by_month: dict[tuple[int, int], list[int]] = defaultdict(list) + for d in dates: + by_month[(d.year, d.month)].append(d.day) + + chunks: list[RequestTemporal] = [] + for (year, month), days in by_month.items(): + chunks.append( + RequestTemporal( + year=f"{year:04d}", + month=f"{month:02d}", + day=[f"{day:02d}" for day in sorted(set(days))], + ), + ) + return chunks + + +def submit_requests( + client: Client, + collection_id: str, + requests: list[Request], +) -> list[Remote]: + """Submit a list of requests to the CDS API. + + Args: + client: CDS API client. + collection_id: ID of the CDS dataset (e.g. "reanalysis-era5-land"). + requests: List of request parameters. + + Returns: + List of Remote objects representing the submitted requests. + + """ + remotes: list[Remote] = [] + for request in requests: + r = client.submit( + collection_id=collection_id, + request=dict(request), + ) + logger.info("Submitted request %s", r.request_id) + remotes.append(r) + return remotes + + +def build_requests( + dates: list[date], + variable: str, + time: list[str], + area: list[int], + data_format: Literal["grib", "netcdf"] = "grib", + download_format: Literal["unarchived", "zip"] = "unarchived", +) -> list[Request]: + """Build requests for the reanalysis-era5-land dataset. + + Args: + dates: Requested dates. + variable: Requested variable (ex: "2m_temperature"). + time: List of times to request (ex: ["00:00", "01:00", ..., "23:00"]). + area: Geographical area to request (north, west, south, east). + data_format: Data format to request ("grib" or "netcdf"). + download_format: Download format ("unarchived" or "zip"). + + Returns: + A list of Request objects to be submitted to the CDS API. + + """ + requests: list[Request] = [] + temporal_chunks = get_temporal_chunks(dates) + for chunk in temporal_chunks: + request = Request( + variable=[variable], + year=chunk["year"], + month=chunk["month"], + day=chunk["day"], + time=time, + data_format=data_format, + download_format=download_format, + area=area, + ) + requests.append(request) + return requests + + +def _get_name(remote: Remote) -> str: + """Create file name from remote request. + + Returns: + File name with format: {year}{month}_{request_id}.{ext} + + """ + request = remote.request + data_format = request["data_format"] + download_format = request["download_format"] + year = request["year"] + month = request["month"] + ext = "zip" if download_format == "zip" else data_format + return f"{year}{month}_{remote.request_id}.{ext}" + + +def retrieve_remotes( + queue: list[Remote], + output_dir: Path, +) -> list[Remote]: + """Retrieve the results of the submitted remotes. + + Args: + queue: List of Remote objects to check and download if ready. + output_dir: Directory to save downloaded files. + + Returns: + List of Remote objects that are still pending (not ready). + + """ + output_dir.mkdir(parents=True, exist_ok=True) + pending: list[Remote] = [] + + for remote in queue: + if remote.results_ready: + name = _get_name(remote) + fp = output_dir / name + remote.download(target=fp.as_posix()) + logger.info("Downloaded %s", name) + else: + pending.append(remote) + return pending + + +def _times_in_zarr(store: Path) -> npt.NDArray[np.datetime64]: + """List time dimensions in the zarr store. + + Args: + store: Path to the zarr store. + + Returns: + Numpy array of datetime64 values in the time dimension of the entire zarr store. + + """ + ds = xr.open_zarr(store, consolidated=True, decode_timedelta=False) + return ds.time.values + + +def create_zarr(ds: xr.Dataset, zarr_store: Path) -> None: + """Create a new zarr store from the dataset. + + Args: + ds: The xarray Dataset to store. + zarr_store: Path to the zarr store to create. + + """ + ds.to_zarr(zarr_store, mode="w", consolidated=True, zarr_format=2) + logger.debug("Created Zarr store at %s", zarr_store) + + +def append_zarr(ds: xr.Dataset, zarr_store: Path) -> None: + """Append new data to an existing zarr store. + + The function checks for overlapping time values and only appends new data. + + Args: + ds: The xarray Dataset to append. + zarr_store: Path to the existing zarr store. + + """ + existing_times = _times_in_zarr(zarr_store) + new_times = ds.time.values + overlap = np.isin(new_times, existing_times) + if overlap.any(): + logger.warning("Time dimension of GRIB file overlaps with existing Zarr store") + ds = ds.sel(time=~overlap) + if len(ds.time) == 0: + logger.debug("No new data to add to Zarr store") + return + ds.to_zarr(zarr_store, mode="a", append_dim="time", zarr_format=2) + logger.debug("Appended %s values to Zarr store", len(ds.time)) + + +def consolidate_zarr(zarr_store: Path) -> None: + """Consolidate metadata and ensure dimensions are properly sorted. + + The function consolidates the metadata of the zarr store and checks if the time + dimension is sorted. If not, it sorts the time dimension and rewrites the zarr + store. + + Args: + zarr_store: Path to the zarr store to consolidate. + + """ + zarr.consolidate_metadata(zarr_store) + ds = xr.open_zarr(zarr_store, consolidated=True, decode_timedelta=False) + ds_sorted = ds.sortby("time") + if not np.array_equal(ds.time.values, ds_sorted.time.values): + logger.debug("Sorting time dimension in Zarr store") + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_zarr_store = Path(tmp_dir) / zarr_store.name + ds_sorted.to_zarr(tmp_zarr_store, mode="w", consolidated=True, zarr_format=2) + shutil.rmtree(zarr_store) + shutil.move(tmp_zarr_store, zarr_store) + else: + zarr.consolidate_metadata(zarr_store, zarr_format=2) + + +def grib_to_zarr( + src_dir: Path, + zarr_store: Path, +) -> None: + """Move data in multiple GRIB files to a zarr store. + + The function processes all GRIB files in the source directory and moves the data + to the specified Zarr store (creating or appending as necessary). + + Args: + src_dir: Directory containing the GRIB files. + zarr_store: Path to the zarr store to create or update. + + """ + for fp in src_dir.glob("*.grib"): + ds = xr.open_dataset(fp, engine="cfgrib", decode_timedelta=False) + ds = ds.assign_coords( + { + "latitude": np.round(ds.latitude.values, 1), + "longitude": np.round(ds.longitude.values, 1), + }, + ) + if not zarr_store.exists(): + create_zarr(ds, zarr_store) + else: + append_zarr(ds, zarr_store) + consolidate_zarr(zarr_store) + + +def diff_zarr( + start_date: date, + end_date: date, + zarr_store: Path, +) -> list[date]: + """Get dates between start and end dates that are not in the zarr store. + + Args: + start_date: Start date for data retrieval. + end_date: End date for data retrieval. + zarr_store: The Zarr store to check for existing data. + + Returns: + The list of dates that are not in the Zarr store. + + """ + if not zarr_store.exists(): + return get_date_range(start_date, end_date) + + zarr_dtimes = _times_in_zarr(zarr_store) + zarr_dates = zarr_dtimes.astype("datetime64[D]").astype(date).tolist() + + date_range = get_date_range(start_date, end_date) + return [d for d in date_range if d not in zarr_dates] + + +def get_missing_dates( + client: Client, + dataset_id: str, + start_date: date, + end_date: date, + zarr_store: Path, +) -> list[date]: + """Get the list of dates between start_date and end_date that are not in the Zarr store. + + Args: + client: The CDS API client. + dataset_id: The ID of the dataset to check. + start_date: Start date for data retrieval. + end_date: End date for data retrieval. + zarr_store: The Zarr store to check for existing data. + + Returns: + A list of dates that are not in the Zarr store. + + """ + collection = client.get_collection(dataset_id) + if not collection.begin_datetime or not collection.end_datetime: + msg = f"Dataset {dataset_id} does not have a defined date range" + raise ValueError(msg) + start_date, end_date = bound_date_range( + start_date, + end_date, + collection.begin_datetime.date(), + collection.end_datetime.date(), + ) + dates = diff_zarr(start_date, end_date, zarr_store) + logger.debug("Missing dates: %s", dates) + return dates + + +def prepare_requests( + client: Client, + dataset_id: str, + start_date: date, + end_date: date, + variable: str, + area: list[int], + zarr_store: Path, +) -> list[Request]: + """Prepare requests for data retrieval from the CDS API. + + This function checks the available dates in the Zarr store and prepares + requests for the missing dates. + + Args: + client: The CDS API client. + dataset_id: ID of the CDS dataset (e.g. "reanalysis-era5-land"). + start_date: Start date for data synchronization. + end_date: End date for data synchronization. + variable: The variable to synchronize (e.g. "2m_temperature"). + area: The geographical area to synchronize (north, west, south, east). + zarr_store: The Zarr store to update or create. + + Returns: + A list of requests to be submitted to the CDS API. + + """ + variables = _get_variables() + if variable not in variables: + msg = f"Variable '{variable}' not supported" + raise ValueError(msg) + + dates = get_missing_dates( + client=client, + dataset_id=dataset_id, + start_date=start_date, + end_date=end_date, + zarr_store=zarr_store, + ) + + requests = build_requests( + dates=dates, + variable=variable, + time=variables[variable]["time"], + area=area, + data_format="grib", + download_format="unarchived", + ) + + max_requests = 100 + if len(requests) > max_requests: + msg = f"Too many data requests ({len(requests)}), max is {max_requests}" + logger.error(msg) + raise ValueError(msg) + + return requests + + +def retrieve_requests( + client: Client, + dataset_id: str, + requests: list[Request], + src_dir: Path, + wait: int = 30, +) -> None: + """Retrieve the results of the submitted requests. + + Args: + client: The CDS API client. + dataset_id: The ID of the dataset to retrieve. + requests: The list of requests to retrieve. + src_dir: The directory containing the source data files. + wait: Time in seconds to wait between checking for completed requests. + + """ + logger.debug("Submitting %s requests", len(requests)) + remotes = submit_requests( + client=client, + collection_id=dataset_id, + requests=requests, + ) + while remotes: + remotes = retrieve_remotes(remotes, src_dir) + sleep(wait) diff --git a/openhexa/toolbox/era5/google.py b/openhexa/toolbox/era5/google.py deleted file mode 100644 index 884dbbab..00000000 --- a/openhexa/toolbox/era5/google.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Download raw historical Era5 products from Google Cloud: -https://console.cloud.google.com/storage/browser/gcp-public-data-arco-era5 - -Products are provided as raw NetCDF files and are usually available with a ~3 month lag. -""" - -from __future__ import annotations - -import importlib.resources -import json -import logging -import shutil -import tempfile -from datetime import datetime, timedelta -from functools import cached_property -from pathlib import Path - -import requests -from google.cloud import storage - -with importlib.resources.open_text("openhexa.toolbox.era5", "variables.json") as f: - VARIABLES = json.load(f) - -log = logging.getLogger(__name__) - - -class NotFoundError(Exception): - pass - - -class ParameterError(ValueError): - pass - - -class Client: - def __init__(self): - self.client = storage.Client.create_anonymous_client() - self.bucket = self.client.bucket("gcp-public-data-arco-era5") - - @staticmethod - def prefix(variable: str, date: datetime) -> str: - """Build key prefix for a given product.""" - return f"raw/date-variable-single_level/{date.year}/{date.month:02}/{date.day:02}/{variable}/surface.nc" - - def _subdirs(self, prefix: str) -> list[str]: - """List subdirs.""" - blobs = self.client.list_blobs(self.bucket, prefix=prefix, delimiter="/") - prefixes = [] - for page in blobs.pages: - prefixes += page.prefixes - return prefixes - - @cached_property - def latest(self) -> datetime: - """Get date of latest available product.""" - root = "raw/date-variable-single_level/" - subdirs = self._subdirs(root) # years - subdirs = self._subdirs(max(subdirs)) # months - subdirs = self._subdirs(max(subdirs)) # days - subdir = max(subdirs).split("/") - year = int(subdir[-4]) - month = int(subdir[-3]) - day = int(subdir[-2]) - return datetime(year, month, day) - - def find(self, variable: str, date: datetime) -> str | None: - """Find public URL of product. Return None if not found.""" - prefix = self.prefix(variable, date) - blobs = self.client.list_blobs(self.bucket, prefix=prefix, max_results=1) - blobs = list(blobs) - if blobs: - return blobs[0].public_url - else: - return None - - def download(self, variable: str, date: datetime, dst_file: str | Path, overwrite=False): - """Download an Era5 NetCDF product for a given day. - - Parameters - ---------- - variable : str - Climate data store variable name (ex: "2m_temperature"). - date : datetime - Product date (year, month, day). - dst_file : str | Path - Output file. - overwrite : bool, optional - Overwrite existing file (default=False). - - Raises - ------ - ParameterError - Product request parameters are invalid. - NotFoundError - Product not found in bucket. - """ - dst_file = Path(dst_file) - dst_file.parent.mkdir(parents=True, exist_ok=True) - - if dst_file.exists() and not overwrite: - log.debug("Skipping download of %s because file already exists", str(dst_file.absolute())) - return - - if variable not in VARIABLES: - raise ParameterError("%s is not a valid climate data store variable name", variable) - - url = self.find(variable, date) - if not url: - raise NotFoundError("%s product not found for date %s", variable, date.strftime("%Y-%m-%d")) - - with tempfile.NamedTemporaryFile() as tmp: - with open(tmp.name, "wb") as f: - with requests.get(url, stream=True) as r: - for chunk in r.iter_content(chunk_size=1024**2): - if chunk: - f.write(chunk) - - shutil.copy(tmp.name, dst_file) - - log.debug("Downloaded %s", str(dst_file.absolute())) - - def sync(self, variable: str, start_date: datetime, end_date: datetime, dst_dir: str | Path): - """Download all products for a given variable and date range. - - If products are already present in the destination directory, they will be skipped. - Expects file names to be formatted as "YYYY-MM-DD_VARIABLE.nc". - - Parameters - ---------- - variable : str - Climate data store variable name (ex: "2m_temperature"). - start_date : datetime - Start date (year, month, day). - end_date : datetime - End date (year, month, day). - dst_dir : str | Path - Output directory. - """ - dst_dir = Path(dst_dir) - dst_dir.mkdir(parents=True, exist_ok=True) - - if start_date > end_date: - raise ParameterError("`start_date` must be before `end_date`") - - date = start_date - if end_date > self.latest: - log.info("Setting `end_date` to the latest available date: %s" % date.strftime("%Y-%m-%d")) - end_date = self.latest - - while date <= end_date: - expected_filename = f"{date.strftime('%Y-%m-%d')}_{variable}.nc" - fpath = Path(dst_dir, expected_filename) - fpath_grib = Path(dst_dir, expected_filename.replace(".nc", ".grib")) - if fpath.exists() or fpath_grib.exists(): - log.debug("%s already exists, skipping download" % expected_filename) - else: - self.download(variable=variable, date=date, dst_file=fpath, overwrite=False) - date += timedelta(days=1) diff --git a/openhexa/toolbox/era5/transform.py b/openhexa/toolbox/era5/transform.py new file mode 100644 index 00000000..75b60247 --- /dev/null +++ b/openhexa/toolbox/era5/transform.py @@ -0,0 +1,213 @@ +"""Spatial aggregation of ERA5-Land data.""" + +from enum import StrEnum +from typing import Literal + +import geopandas as gpd +import numpy as np +import polars as pl +import rasterio.features +import rasterio.transform +import xarray as xr + +from era5.dhis2weeks import WeekType, to_dhis2_week + + +def create_masks(gdf: gpd.GeoDataFrame, id_column: str, ds: xr.Dataset) -> xr.DataArray: + """Create masks for each boundary in the GeoDataFrame. + + Input polygons are rasterized into a grid matching the spatial dimensions of the + dataset. + We use the `all_touched=True` option, so that any pixel touched by a polygon is included in the + mask. This is because we don't want small geometries ending up with zero pixel. As a result, + each polygon has its own mask because some pixels may belong to multiple polygons. + + Args: + gdf: A GeoDataFrame containing the boundaries, with a 'geometry' column + id_column: Column in the GeoDataFrame that contains unique identifiers for each + boundary + ds: An xarray Dataset containing the spatial dimensions (latitude and longitude) + + Returns: + An xarray DataArray with dimensions ['boundary', 'latitude', 'longitude'] + containing the masks. Each mask corresponds to a boundary in the GeoDataFrame. + + """ + lat = ds.latitude.values + lon = ds.longitude.values + lat_res = abs(lat[1] - lat[0]) + lon_res = abs(lon[1] - lon[0]) + transform = rasterio.transform.from_bounds( # type: ignore + west=lon.min() - lon_res / 2, + east=lon.max() + lon_res / 2, + north=lat.max() + lat_res / 2, + south=lat.min() - lat_res / 2, + width=len(lon), + height=len(lat), + ) + + masks: list[np.ndarray] = [] + names: list[str] = [] + + for _, row in gdf.iterrows(): + mask = rasterio.features.rasterize( # type: ignore + [row.geometry], + out_shape=(len(lat), len(lon)), + transform=transform, # type: ignore + fill=0, + all_touched=True, + dtype=np.uint8, + ) + masks.append(mask) # type: ignore + names.append(row[id_column]) # type: ignore + + return xr.DataArray( + np.stack(masks), + dims=["boundary", "latitude", "longitude"], + coords={ + "boundary": names, + "latitude": lat, + "longitude": lon, + }, + ) + + +def aggregate_in_space( + ds: xr.Dataset, + masks: xr.DataArray, + variable: str, + agg: Literal["mean", "sum", "min", "max"], +) -> pl.DataFrame: + """Perform spatial aggregation on the dataset using the provided masks. + + Args: + ds: The data containing the variable to aggregate. Dataset is expected to have + 'latitude' and 'longitude' coordinates, and daily data. + masks: An xarray DataArray containing the masks for spatial aggregation, as returned by the + `create_masks()` function. + variable: Name of the variable to aggregate (e.g. "t2m") + agg: Spatial aggregation method (one of "mean", "sum", "min", "max"). + + Returns: + A Polars DataFrame of shape (n_boundaries, n_days) with columns: "boundary", "time", and + "value". + + Raises: + ValueError: If the specified variable is not found in the dataset. + ValueError: If the dataset still contains the 'step' dimension (i.e. data is not daily). + ValueError: If an unsupported aggregation method is specified. + + """ + if variable not in ds.data_vars: + msg = f"Variable '{variable}' not found in dataset" + raise ValueError(msg) + if "step" in ds.dims: + msg = "Dataset still contains 'step' dimension. Please aggregate to daily data first." + raise ValueError(msg) + da = ds[variable] + area_weights = np.cos(np.deg2rad(ds.latitude)) + results: list[xr.DataArray] = [] + for boundary in masks.boundary: + mask = masks.sel(boundary=boundary) + if agg == "mean": + weights = area_weights * mask + result = da.weighted(weights).mean(["latitude", "longitude"]) + elif agg == "sum": + result = da.where(mask > 0).sum(["latitude", "longitude"]) + elif agg == "min": + result = da.where(mask > 0).min(["latitude", "longitude"]) + elif agg == "max": + result = da.where(mask > 0).max(["latitude", "longitude"]) + else: + msg = f"Unsupported aggregation method: {agg}" + raise ValueError(msg) + results.append(result) + result = xr.concat(results, dim="boundary").assign_coords(boundary=masks.boundary, time=ds.time) + + n_boundaries = len(result.boundary) + n_times = len(result.time) + + schema = { + "boundary": pl.String, + "time": pl.Date, + "value": pl.Float64, + } + data = { + "boundary": np.repeat(result.boundary.values, n_times), + "time": np.tile(result.time.values, n_boundaries), + "value": result.values.flatten(order="C"), + } + return pl.DataFrame(data, schema=schema) + + +class Period(StrEnum): + """Temporal aggregation periods.""" + + DAY = "DAY" + WEEK = "WEEK" + MONTH = "MONTH" + YEAR = "YEAR" + WEEK_WEDNESDAY = "WEEK_WEDNESDAY" + WEEK_THURSDAY = "WEEK_THURSDAY" + WEEK_SATURDAY = "WEEK_SATURDAY" + WEEK_SUNDAY = "WEEK_SUNDAY" + + +def aggregate_in_time( + dataframe: pl.DataFrame, + period: Period, + agg: Literal["mean", "sum", "min", "max"] = "mean", +) -> pl.DataFrame: + """Aggregate the dataframe over the specified temporal period. + + Args: + dataframe: The dataframe to aggregate. + period: The temporal period to aggregate over. + agg: Temporal aggregation method (one of "mean", "sum", "min", "max"). + + Returns: + The aggregated dataframe. + + """ + # We 1st create a "period" column to be able to group by it + if period == Period.DAY: + df = dataframe.with_columns( + pl.col("time").dt.strftime("%Y%m%d").alias("period"), + ) + elif period == Period.MONTH: + df = dataframe.with_columns( + pl.col("time").dt.strftime("%Y%m").alias("period"), + ) + elif period == Period.YEAR: + df = dataframe.with_columns( + pl.col("time").dt.strftime("%Y").alias("period"), + ) + elif period in ( + Period.WEEK, + Period.WEEK_WEDNESDAY, + Period.WEEK_THURSDAY, + Period.WEEK_SATURDAY, + Period.WEEK_SUNDAY, + ): + df = dataframe.with_columns( + pl.col("time") + .map_elements(lambda dt: to_dhis2_week(dt, WeekType(period)), return_dtype=pl.String) + .alias("period"), + ) + else: + msg = f"Unsupported period: {period}" + raise NotImplementedError(msg) + + if agg == "mean": + df = df.group_by(["boundary", "period"]).agg(pl.col("value").mean().alias("value")) + elif agg == "sum": + df = df.group_by(["boundary", "period"]).agg(pl.col("value").sum().alias("value")) + elif agg == "min": + df = df.group_by(["boundary", "period"]).agg(pl.col("value").min().alias("value")) + elif agg == "max": + df = df.group_by(["boundary", "period"]).agg(pl.col("value").max().alias("value")) + else: + msg = f"Unsupported aggregation method: {agg}" + raise ValueError(msg) + + return df.select(["boundary", "period", "value"]).sort(["boundary", "period"]) diff --git a/openhexa/toolbox/era5/variables.json b/openhexa/toolbox/era5/variables.json deleted file mode 100644 index e659ef8f..00000000 --- a/openhexa/toolbox/era5/variables.json +++ /dev/null @@ -1,352 +0,0 @@ -{ - "lake_mix_layer_temperature": { - "name": "Lake mix-layer temperature", - "shortname": "lmlt", - "units": "K", - "grib1": true, - "grib2": false - }, - "lake_mix_layer_depth": { - "name": "Lake mix-layer depth", - "shortname": "lmld", - "units": "m", - "grib1": true, - "grib2": false - }, - "lake_bottom_temperature": { - "name": "Lake bottom temperature", - "shortname": "lblt", - "units": "K", - "grib1": true, - "grib2": false - }, - "lake_total_layer_temperature": { - "name": "Lake total layer temperature", - "shortname": "ltlt", - "units": "K", - "grib1": true, - "grib2": false - }, - "lake_shape_factor": { - "name": "Lake shape factor", - "shortname": "lshf", - "units": "dimensionless", - "grib1": true, - "grib2": false - }, - "lake_ice_temperature": { - "name": "Lake ice temperature", - "shortname": "lict", - "units": "K", - "grib1": true, - "grib2": false - }, - "lake_ice_depth": { - "name": "Lake ice depth", - "shortname": "licd", - "units": "m", - "grib1": true, - "grib2": false - }, - "snow_cover": { - "name": "Snow cover", - "shortname": "snowc", - "units": "%", - "grib1": false, - "grib2": true - }, - "snow_depth": { - "name": "Snow depth", - "shortname": "sde", - "units": "m", - "grib1": false, - "grib2": true - }, - "snow_albedo": { - "name": "Snow albedo", - "shortname": "asn", - "units": "(0 - 1)", - "grib1": true, - "grib2": false - }, - "snow_density": { - "name": "Snow density", - "shortname": "rsn", - "units": "kg m**-3", - "grib1": true, - "grib2": false - }, - "volumetric_soil_water_layer_1": { - "name": "Volumetric soil water layer 11", - "shortname": "swvl1", - "units": "m**3 m**-3", - "grib1": true, - "grib2": false - }, - "volumetric_soil_water_layer_2": { - "name": "Volumetric soil water layer 21", - "shortname": "swvl2", - "units": "m**3 m**-3", - "grib1": true, - "grib2": false - }, - "volumetric_soil_water_layer_3": { - "name": "Volumetric soil water layer 31", - "shortname": "swvl3", - "units": "m**3 m**-3", - "grib1": true, - "grib2": false - }, - "volumetric_soil_water_layer_4": { - "name": "Volumetric soil water layer 41", - "shortname": "swvl4", - "units": "m**3 m**-3", - "grib1": true, - "grib2": false - }, - "leaf_area_index_low_vegetation": { - "name": "Leaf area index, low vegetation2", - "shortname": "lai_lv", - "units": "m**2 m**-2", - "grib1": true, - "grib2": false - }, - "leaf_area_index_high_vegetation": { - "name": "Leaf area index, high vegetation2", - "shortname": "lai_hv", - "units": "m**2 m**-2", - "grib1": true, - "grib2": false - }, - "surface_pressure": { - "name": "Surface pressure", - "shortname": "sp", - "units": "Pa", - "grib1": true, - "grib2": false - }, - "soil_temperature_level_1": { - "name": "Soil temperature level 11", - "shortname": "stl1", - "units": "K", - "grib1": true, - "grib2": false - }, - "snow_depth_water_equivalent": { - "name": "Snow depth water equivalent", - "shortname": "sd", - "units": "m of water equivalent", - "grib1": true, - "grib2": false - }, - "10m_u_component_of_wind": { - "name": "10 metre U wind component", - "shortname": "u10", - "units": "m s**-1", - "grib1": true, - "grib2": false - }, - "10m_v_component_of_wind": { - "name": "10 metre V wind component", - "shortname": "v10", - "units": "m s**-1", - "grib1": true, - "grib2": false - }, - "2m_temperature": { - "name": "2 metre temperature", - "shortname": "t2m", - "units": "K", - "grib1": true, - "grib2": false - }, - "2m_dewpoint_temperature": { - "name": "2 metre dewpoint temperature", - "shortname": "2d", - "units": "K", - "grib1": true, - "grib2": false - }, - "soil_temperature_level_2": { - "name": "Soil temperature level 21", - "shortname": "stl2", - "units": "K", - "grib1": true, - "grib2": false - }, - "soil_temperature_level_3": { - "name": "Soil temperature level 31", - "shortname": "stl3", - "units": "K", - "grib1": true, - "grib2": false - }, - "skin_reservoir_content": { - "name": "Skin reservoir content", - "shortname": "src", - "units": "m of water equivalent", - "grib1": false, - "grib2": false - }, - "skin_temperature": { - "name": "Skin temperature", - "shortname": "skt", - "units": "K", - "grib1": true, - "grib2": false - }, - "soil_temperature_level_4": { - "name": "Soil temperature level 41", - "shortname": "stl4", - "units": "K", - "grib1": true, - "grib2": false - }, - "temperature_of_snow_layer": { - "name": "Temperature of snow layer", - "shortname": "tsn", - "units": "K", - "grib1": true, - "grib2": false - }, - "forecast_albedo": { - "name": "Forecast albedo", - "shortname": "fal", - "units": "(0 - 1)", - "grib1": true, - "grib2": false - }, - "surface_runoff": { - "name": "Surface runoff", - "shortname": "sro", - "units": "m", - "grib1": true, - "grib2": false - }, - "sub_surface_runoff": { - "name": "Sub-surface runoff", - "shortname": "ssro", - "units": "m", - "grib1": true, - "grib2": false - }, - "\u00a0snow_evaporation": { - "name": "Snow evaporation", - "shortname": "es", - "units": "m of water equivalent", - "grib1": true, - "grib2": false - }, - "snowmelt": { - "name": "Snowmelt", - "shortname": "smlt", - "units": "m of water equivalent", - "grib1": true, - "grib2": false - }, - "snowfall": { - "name": "Snowfall", - "shortname": "sf", - "units": "m of water equivalent", - "grib1": true, - "grib2": false - }, - "surface_sensible_heat_flux": { - "name": "Surface sensible heat flux", - "shortname": "sshf", - "units": "J m**-2", - "grib1": true, - "grib2": false - }, - "surface_latent_heat_flux": { - "name": "Surface latent heat flux", - "shortname": "slhf", - "units": "J m**-2", - "grib1": true, - "grib2": false - }, - "surface_solar_radiation_downwards": { - "name": "Surface solar radiation downwards", - "shortname": "ssrd", - "units": "J m**-2", - "grib1": true, - "grib2": false - }, - "surface_thermal_radiation_downwards": { - "name": "Surface thermal radiation downwards", - "shortname": "strd", - "units": "J m**-2", - "grib1": true, - "grib2": false - }, - "surface_net_solar_radiation": { - "name": "Surface net solar radiation", - "shortname": "ssr", - "units": "J m**-2", - "grib1": true, - "grib2": false - }, - "surface_net_thermal_radiation": { - "name": "Surface net thermal radiation", - "shortname": "str", - "units": "J m**-2", - "grib1": true, - "grib2": false - }, - "total_evaporation": { - "name": "Total Evaporation", - "shortname": "e", - "units": "m of water equivalent", - "grib1": true, - "grib2": false - }, - "runoff": { - "name": "Runoff", - "shortname": "ro", - "units": "m", - "grib1": true, - "grib2": false - }, - "total_precipitation": { - "name": "Total precipitation", - "shortname": "tp", - "units": "m", - "grib1": true, - "grib2": false - }, - "evaporation_from_the_top_of_canopy": { - "name": "Evaporation from the top of canopy", - "shortname": "evatc", - "units": "m of water equivalent", - "grib1": false, - "grib2": true - }, - "evaporation_from_bare_soil": { - "name": "Evaporation from bare soil", - "shortname": "evabs", - "units": "m of water equivalent", - "grib1": false, - "grib2": true - }, - "evaporation_from_open_water_surfaces_excluding_oceans": { - "name": "Evaporation from open water surfaces excluding oceans", - "shortname": "evaow", - "units": "m of water equivalent", - "grib1": false, - "grib2": true - }, - "evaporation_from_vegetation_transpiration": { - "name": "Evaporation from vegetation transpiration", - "shortname": "evavt", - "units": "m of water equivalent", - "grib1": false, - "grib2": true - }, - "potential_evaporation": { - "name": "Potential evaporation", - "shortname": "pev", - "units": "m", - "grib1": true, - "grib2": false - } -} \ No newline at end of file diff --git a/tests/era5/data/geoms.parquet b/tests/era5/data/geoms.parquet new file mode 100644 index 0000000000000000000000000000000000000000..4f8d6b5214f8fed61977ef000f5bd99e8ef96234 GIT binary patch literal 8760 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 NUym}(b@)Fc{|)whXZHXA literal 0 HcmV?d00001 diff --git a/tests/era5/data/sample_202503.grib b/tests/era5/data/sample_202503.grib new file mode 100644 index 0000000000000000000000000000000000000000..65b58d443572f76d95d957bb5c30372645d4e99e GIT binary patch literal 15840 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 bQ@&U8c6d{KkFWi5`4>7c@TBJ7z5D+Kle*Sf literal 0 HcmV?d00001 diff --git a/tests/era5/data/sample_202504.grib b/tests/era5/data/sample_202504.grib new file mode 100644 index 0000000000000000000000000000000000000000..76beca869978772b280a789323be08156385a934 GIT binary patch literal 19800 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 literal 0 HcmV?d00001 diff --git a/tests/era5/test_cds.py b/tests/era5/test_cds.py deleted file mode 100644 index c8bb2f51..00000000 --- a/tests/era5/test_cds.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Unit tests for the ERA5 Climate Data Store client.""" - -from __future__ import annotations - -from datetime import datetime -from unittest.mock import Mock, patch - -import datapi -import pytest - -from openhexa.toolbox.era5.cds import ( - CDS, - DataRequest, -) - - -class TestCollection(datapi.catalogue.Collection): - """Datapi Collection object with mocked end_datetime property.""" - - __test__ = False - - def __init__(self, end_datetime: datetime) -> None: - self._end_datetime = end_datetime - - @property - def end_datetime(self): - return self._end_datetime - - -@patch("datapi.ApiClient.check_authentication") -def test_cds_init(mock_check_authentication: Mock): - """Test CDS class initialization.""" - mock_check_authentication.return_value = True - CDS(key="xxx") - - -@pytest.fixture -@patch("datapi.ApiClient.check_authentication") -def fake_cds(mock_check_authentication: Mock): - mock_check_authentication.return_value = True - return CDS(key="xxx") - - -@patch("datapi.ApiClient.get_collection") -def test_latest(mock_get_collection: Mock, fake_cds: CDS): - mock_get_collection.return_value = TestCollection(end_datetime=datetime(2023, 1, 1).astimezone()) - assert fake_cds.latest == datetime(2023, 1, 1).astimezone() - - -class TestJobs(datapi.processing.Jobs): - """Datapi Jobs class with mocked request_ids property.""" - - __test__ = False - - def __init__(self, request_ids: list[str]) -> None: - self._request_ids = request_ids - - @property - def request_ids(self): - return self._request_ids - - -class TestRemote(datapi.processing.Remote): - """Datapi Remote class with mocked properties.""" - - __test__ = False - - def __init__(self, request_id: str, status: str, results_ready: bool, request: dict) -> None: - self._request_id = request_id - self._status = status - self._results_ready = results_ready - self._request = request - self.cleanup = False - - @property - def status(self): - return self._status - - @property - def request_id(self): - return self._request_id - - @property - def results_ready(self): - return self._results_ready - - @property - def request(self): - return self._request - - -@patch("datapi.ApiClient.get_jobs") -@patch("datapi.ApiClient.get_remote") -def test_cds_get_remote_requests(mock_get_remote: Mock, mock_get_jobs: Mock, fake_cds: CDS): - mock_get_jobs.return_value = TestJobs( - request_ids=[ - "73dc0d2d-8288-4041-a84d-87e70772d5a8", - "3973ec55-4b38-449b-b7f1-5edd1034f663", - "a5c7093d-56d9-40a4-a363-c60cd242ce66", - ] - ) - - mock_get_remote.return_value = TestRemote( - request_id="73dc0d2d-8288-4041-a84d-87e70772d5a8", status="successful", results_ready=True, request={} - ) - - remote_requests = fake_cds.get_remote_requests() - - assert len(remote_requests) == 3 - assert remote_requests[0]["request_id"] == "73dc0d2d-8288-4041-a84d-87e70772d5a8" - assert isinstance(remote_requests[0]["request"], dict) - - -@pytest.fixture -def tp_request() -> DataRequest: - return DataRequest( - variable=["total_precipitation"], - year="2024", - month="12", - day=["01", "02", "03", "04", "05"], - time=["01:00", "06:00", "18:00"], - data_format="grib", - area=[16, -6, 9, 3], - ) - - -@pytest.fixture -def tp_request_remote() -> dict: - return { - "request_id": "73dc0d2d-8288-4041-a84d-87e70772d5a8", - "request": { - "day": ["01", "02", "03", "04", "05"], - "area": [16, -6, 9, 3], - "time": ["01:00", "06:00", "18:00"], - "year": "2024", - "month": "12", - "variable": ["total_precipitation"], - "data_format": "grib", - }, - } - - -@patch("datapi.ApiClient.get_remote") -def test_cds_get_remote_from_request( - mock_get_remote: Mock, fake_cds: CDS, tp_request: DataRequest, tp_request_remote: dict -): - mock_get_remote.return_value = TestRemote( - request_id="73dc0d2d-8288-4041-a84d-87e70772d5a8", - status="successful", - results_ready=True, - request=tp_request_remote, - ) - - existing_requests = [tp_request_remote] - remote = fake_cds.get_remote_from_request(tp_request, existing_requests=existing_requests) - assert remote - assert remote.request_id == "73dc0d2d-8288-4041-a84d-87e70772d5a8" - assert remote.request["request"] == tp_request.__dict__ diff --git a/tests/era5/test_dhis2weeks.py b/tests/era5/test_dhis2weeks.py new file mode 100644 index 00000000..66a77363 --- /dev/null +++ b/tests/era5/test_dhis2weeks.py @@ -0,0 +1,52 @@ +"""Test dhis2weeks module.""" + +from datetime import date + +from era5.dhis2weeks import WeekType, get_calendar_week + + +def test_standard_iso_week(): + # 2024 Jan 1 is a Monday, so we expect it to be week 1 of 2024 + dt = date(2024, 1, 1) + assert get_calendar_week(dt, WeekType.WEEK) == (2024, 1) + + # 2023 Dec 31 is a Sunday, so it belongs to week 52 of 2023 + dt = date(2023, 12, 31) + assert get_calendar_week(dt, WeekType.WEEK) == (2023, 52) + + +def test_sunday_week_year_boundary(): + """Test Sunday weeks crossing year boundaries.""" + # 2023 Dec 31 is a Sunday starting a week containing Jan 4th, so it should belong + # to week 1 of 2024 for Sunday weeks + dt = date(2023, 12, 31) + assert get_calendar_week(dt, WeekType.WEEK_SUNDAY) == (2024, 1) + + # Next Sunday should be in Week 2 + dt = date(2024, 1, 7) + assert get_calendar_week(dt, WeekType.WEEK_SUNDAY) == (2024, 2) + + +def test_saturday_week_year_start(): + """Test Saturday weeks when year starts on Saturday.""" + # Jan 1 2022 is a Saturday so it should be week 1 of 2022 for Saturday weeks + # However, for Sunday weeks it should belong to the last week of 2021 + dt = date(2022, 1, 1) + assert get_calendar_week(dt, WeekType.WEEK_SATURDAY) == (2022, 1) + assert get_calendar_week(dt, WeekType.WEEK_SUNDAY) == (2021, 52) + + +def test_different_week_types_same_date(): + """Test that the same date can belong to different year/weeks.""" + # 2022 Jan 1 is a Saturday and is expected to be: + # - Week 52 of 2021 for standard ISO weeks (Monday start) + # - Week 1 of 2022 for Wednesday weeks + # - Week 1 of 2022 for Thursday weeks + # - Week 1 of 2022 for Saturday weeks + # - Week 52 of 2021 for Sunday weeks + dt = date(2022, 1, 1) + assert get_calendar_week(dt, WeekType.WEEK) == (2021, 52) + assert get_calendar_week(dt, WeekType.WEEK_WEDNESDAY) == (2022, 1) + assert get_calendar_week(dt, WeekType.WEEK_THURSDAY) == (2022, 1) + assert get_calendar_week(dt, WeekType.WEEK_SATURDAY) == (2022, 1) + assert get_calendar_week(dt, WeekType.WEEK_SUNDAY) == (2021, 52) diff --git a/tests/era5/test_extract.py b/tests/era5/test_extract.py new file mode 100644 index 00000000..c3200f0b --- /dev/null +++ b/tests/era5/test_extract.py @@ -0,0 +1,261 @@ +"""Test requests to the CDS API and handling of responses.""" + +import shutil +import tarfile +import tempfile +from datetime import date, datetime +from pathlib import Path +from unittest.mock import Mock + +import numpy as np +import pytest +import xarray as xr + +from era5.extract import ( + Client, + Remote, + Request, + bound_date_range, + get_date_range, + get_temporal_chunks, + grib_to_zarr, + prepare_requests, + retrieve_requests, + submit_requests, +) + + +@pytest.fixture +def mock_client() -> Mock: + client = Mock(spec=Client) + collection = Mock() + collection.begin_datetime = datetime(2020, 1, 1) + collection.end_datetime = datetime(2025, 4, 4) + client.get_collection.return_value = collection + return client + + +@pytest.fixture +def mock_request() -> Request: + return { + "variable": ["2m_temperature"], + "year": "2025", + "month": "03", + "day": ["28", "29", "30", "31"], + "time": ["01:00", "07:00", "13:00", "19:00"], + "data_format": "grib", + "download_format": "unarchived", + "area": [10, -1, 8, 1], + } + + +@pytest.fixture +def sample_grib_file_march() -> Path: + """Small sample GRIB file with 2m_temperature data for March.""" + return Path(__file__).parent / "data" / "sample_202503.grib" + + +@pytest.fixture +def sample_grib_file_april() -> Path: + """Small sample GRIB file with 2m_temperature data for April.""" + return Path(__file__).parent / "data" / "sample_202504.grib" + + +@pytest.fixture +def sample_zarr_store() -> Path: + """Path to a sample Zarr store with data from sample GRIB files.""" + return Path(__file__).parent / "data" / "sample_2m_temperature.zarr.tar.gz" + + +def test_prepare_requests(mock_client): + requests = prepare_requests( + client=mock_client, + dataset_id="reanalysis-era5-land", + start_date=date(2025, 3, 28), + end_date=date(2025, 4, 5), + variable="2m_temperature", + area=[10, -1, 8, 1], + zarr_store=Path("/tmp/do-not-exist.zarr"), + ) + + # The mock client has collection end date of 2025-04-04 + # So we expect requests only up to 2025-04-04 despire the requested end date + # We also expect 2 prepared requests: one for March and one for April + assert len(requests) == 2 + assert requests[0] == { + "variable": ["2m_temperature"], + "year": "2025", + "month": "03", + "day": ["28", "29", "30", "31"], + "time": ["01:00", "07:00", "13:00", "19:00"], + "data_format": "grib", + "download_format": "unarchived", + "area": [10, -1, 8, 1], + } + assert requests[1] == { + "variable": ["2m_temperature"], + "year": "2025", + "month": "04", + "day": ["01", "02", "03", "04"], + "time": ["01:00", "07:00", "13:00", "19:00"], + "data_format": "grib", + "download_format": "unarchived", + "area": [10, -1, 8, 1], + } + + +def test_prepare_requests_with_existing_data(sample_zarr_store, mock_client): + # Sample zarr store has data from 2025-03-28 to 2025-04-05 + with tempfile.TemporaryDirectory() as tmpdir, tarfile.open(sample_zarr_store, "r:gz") as tar: + tar.extractall(path=tmpdir, filter="data") + zarr_store = Path(tmpdir) / "2m_temperature.zarr" + requests = prepare_requests( + client=mock_client, + dataset_id="reanalysis-era5-land", + start_date=date(2025, 3, 27), + end_date=date(2025, 4, 6), + variable="2m_temperature", + area=[10, -1, 8, 1], + zarr_store=zarr_store, + ) + + # In the sample zarr store, we already have data between 2025-03-28 and 2025-04-05 + # In the mock client, the end date of the collection is 2025-04-04 + # As a result, we expect only 1 request to be prepared: for 2025-03-27 + assert len(requests) == 1 + assert requests[0] == { + "variable": ["2m_temperature"], + "year": "2025", + "month": "03", + "day": ["27"], + "time": ["01:00", "07:00", "13:00", "19:00"], + "data_format": "grib", + "download_format": "unarchived", + "area": [10, -1, 8, 1], + } + + +def test_submit_requests(mock_client, mock_request): + remote = Mock(spec=Remote) + mock_client.submit.return_value = remote + remotes = submit_requests( + client=mock_client, + collection_id="reanalysis-era5-land", + requests=[mock_request, mock_request], + ) + # We expect 1 remote per request here + assert len(remotes) == 2 + + +def test_retrieve_requests(mock_client, mock_request): + remote1 = Mock(spec=Remote) + remote1.request_id = "remote1" + remote1.request = mock_request + remote1.status = "successful" + remote1.results_ready = True + remote1.download = Mock(side_effect=lambda target: Path(target).touch()) + + remote2 = Mock(spec=Remote) + remote2.request_id = "remote2" + remote2.request = mock_request + remote2.status = "successful" + remote2.results_ready = True + remote2.download = Mock(side_effect=lambda target: Path(target).touch()) + + mock_client.submit.side_effect = [remote1, remote2] + + with tempfile.TemporaryDirectory() as tmpdir: + retrieve_requests( + client=mock_client, + dataset_id="reanalysis-era5-land", + requests=[mock_request, mock_request], + src_dir=Path(tmpdir), + wait=0, + ) + # We expect 2 grib files to have been downloaded + assert len(list(Path(tmpdir).glob("*.grib"))) == 2 + + +def test_get_date_range(): + start = date(2024, 12, 27) + end = date(2025, 1, 3) + result = get_date_range(start, end) + assert result == [ + date(2024, 12, 27), + date(2024, 12, 28), + date(2024, 12, 29), + date(2024, 12, 30), + date(2024, 12, 31), + date(2025, 1, 1), + date(2025, 1, 2), + date(2025, 1, 3), + ] + + +def test_get_date_range_single_day(): + start = date(2025, 3, 15) + end = date(2025, 3, 15) + result = get_date_range(start, end) + assert result == [date(2025, 3, 15)] + + +def test_get_date_range_invalid(): + start = date(2025, 3, 15) + end = date(2025, 3, 14) + with pytest.raises(ValueError, match="Start date must be before end date"): + get_date_range(start, end) + + +def test_bound_date_range(): + start = date(2024, 12, 27) + end = date(2025, 1, 3) + collection_start = date(2024, 1, 1) + collection_end = date(2024, 12, 31) + bounded_start, bounded_end = bound_date_range(start, end, collection_start, collection_end) + assert bounded_start == date(2024, 12, 27) + assert bounded_end == date(2024, 12, 31) + + +def test_get_temporal_chunks(): + dates = [ + date(2024, 1, 31), + date(2024, 2, 1), + date(2024, 2, 15), + date(2024, 3, 1), + ] + result = get_temporal_chunks(dates) + + # We expect 3 chunks: one per month + assert len(result) == 3 + assert result[0]["year"] == "2024" + assert result[0]["month"] == "01" + assert result[0]["day"] == ["31"] + assert result[1]["year"] == "2024" + assert result[1]["month"] == "02" + assert result[1]["day"] == ["01", "15"] + assert result[2]["year"] == "2024" + assert result[2]["month"] == "03" + assert result[2]["day"] == ["01"] + + +def test_grib_to_zarr(sample_grib_file_march, sample_grib_file_april): + def _move_grib_to_tmp_dir(grib_file: Path, dst_dir: Path): + dst_file = dst_dir / grib_file.name + shutil.copy(grib_file, dst_file) + + with tempfile.TemporaryDirectory() as tmpdir: + grib_dir = Path(tmpdir) / "grib_files" + grib_dir.mkdir() + _move_grib_to_tmp_dir(sample_grib_file_march, grib_dir) + _move_grib_to_tmp_dir(sample_grib_file_april, grib_dir) + zarr_store = Path(tmpdir) / "store.zarr" + grib_to_zarr(grib_dir, zarr_store) + # We expect the Zarr store have been created and contains data from both GRIB files + # Sample GRIB files contains data from 2025-03-28 to 2025-04-05 (9 days) + assert zarr_store.exists() + ds = xr.open_zarr(zarr_store, decode_timedelta=True) + assert "t2m" in ds + times = np.array(ds["time"]) + assert np.datetime_as_string(times[0], unit="D") == "2025-03-28" + assert np.datetime_as_string(times[-1], unit="D") == "2025-04-05" + assert len(times) == 9 diff --git a/tests/era5/test_transform.py b/tests/era5/test_transform.py new file mode 100644 index 00000000..aed6f93d --- /dev/null +++ b/tests/era5/test_transform.py @@ -0,0 +1,95 @@ +"""Test transform module.""" + +import tarfile +import tempfile +from pathlib import Path + +import geopandas as gpd +import numpy as np +import polars as pl +import pytest +import xarray as xr + +from era5.transform import Period, aggregate_in_space, aggregate_in_time, create_masks + + +@pytest.fixture +def sample_boundaries() -> gpd.GeoDataFrame: + fp = Path(__file__).parent / "data" / "geoms.parquet" + return gpd.read_parquet(fp) + + +@pytest.fixture +def sample_dataset() -> xr.Dataset: + archive = Path(__file__).parent / "data" / "sample_2m_temperature.zarr.tar.gz" + with tempfile.TemporaryDirectory() as tmp_dir: + with tarfile.open(archive, "r:gz") as tar: + tar.extractall(path=tmp_dir, filter="data") + ds = xr.open_zarr(Path(tmp_dir) / "2m_temperature.zarr") + ds.load() + return ds + + +def test_create_masks(sample_boundaries, sample_dataset): + masks = create_masks(gdf=sample_boundaries, id_column="boundary_id", ds=sample_dataset) + # We have 4 boundaries in the sample data and the dataset has 21x21 lat/lon points + assert masks.shape == (4, 21, 21) + assert masks.dims == ("boundary", "latitude", "longitude") + # Each boolean mask should contain only 0s and 1s, and at least some 1s + assert sorted(np.unique(masks.data).tolist()) == [0, 1] + assert np.count_nonzero(masks.data) > 100 + + +@pytest.fixture +def sample_masks(sample_boundaries, sample_dataset) -> xr.DataArray: + return create_masks(gdf=sample_boundaries, id_column="boundary_id", ds=sample_dataset) + + +def test_aggregate_in_space(sample_dataset, sample_masks): + ds = sample_dataset.mean(dim="step") + df = aggregate_in_space(ds=ds, masks=sample_masks, variable="t2m", agg="mean") + + # We have 4 boundaries and 9 days in the sample data, so shape should be 9*4=36 rows + # and 3 columns (boundary, time, value) + assert df.shape == (36, 3) + + expected = pl.Schema({"boundary": pl.String, "time": pl.Date, "value": pl.Float64}) + assert df.schema == expected + + assert df["boundary"].n_unique() == 4 + assert df["time"].n_unique() == 9 + + assert pytest.approx(df["value"].min(), 0.1) == 302.38 + assert pytest.approx(df["value"].max(), 0.1) == 307.44 + + # The following aggregation methods do not make sense for 2m_temperature, + # but values should match expected results nonetheless + df = aggregate_in_space(ds=ds, masks=sample_masks, variable="t2m", agg="sum") + assert df.shape == (36, 3) + assert pytest.approx(df["value"].min(), 0.1) == 11589.94 + assert pytest.approx(df["value"].max(), 0.1) == 68461.84 + df = aggregate_in_space(ds=ds, masks=sample_masks, variable="t2m", agg="max") + assert df.shape == (36, 3) + assert pytest.approx(df["value"].min(), 0.1) == 305.08 + assert pytest.approx(df["value"].max(), 0.1) == 308.07 + + +def test_aggregate_in_time(sample_masks, sample_dataset): + ds = sample_dataset.mean(dim="step") + df = aggregate_in_space(ds=ds, masks=sample_masks, variable="t2m", agg="mean") + + weekly = aggregate_in_time(df, Period.WEEK, agg="mean") + # 4 boundaries * 2 weeks = 8 rows + assert weekly.shape[0] == 8 + assert weekly.schema == pl.Schema( + {"boundary": pl.String, "period": pl.String, "value": pl.Float64}, + ) + assert set(weekly.columns) == {"boundary", "period", "value"} + assert weekly["period"].str.starts_with("2025W").all() + + sunday_weekly = aggregate_in_time(df, Period.WEEK_SUNDAY, agg="mean") + assert sunday_weekly["period"].str.starts_with("2025SunW").all() + + monthly = aggregate_in_time(df, Period.MONTH, agg="mean") + assert monthly.shape[0] == 8 # 4 boundaries * 2 months + assert "202503" in monthly["period"].to_list() From 2254c631bb1d54cc893666df6d69d9665260ec94 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Wed, 1 Oct 2025 15:43:32 +0200 Subject: [PATCH 02/51] fix imports --- openhexa/toolbox/era5/extract.py | 4 ++-- openhexa/toolbox/era5/transform.py | 2 +- tests/era5/test_dhis2weeks.py | 2 +- tests/era5/test_extract.py | 2 +- tests/era5/test_transform.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index 7f8ea1b6..80aefea3 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -8,7 +8,6 @@ import logging import shutil import tempfile -import tomllib from collections import defaultdict from datetime import date from pathlib import Path @@ -17,6 +16,7 @@ import numpy as np import numpy.typing as npt +import tomllib import xarray as xr import zarr from dateutil.relativedelta import relativedelta @@ -42,7 +42,7 @@ def _get_variables() -> dict[str, Variable]: A dictionary mapping variable names to their metadata. """ - with importlib.resources.path("era5", "data", "variables.toml") as path, path.open("rb") as f: + with importlib.resources.files("openhexa.toolbox.era5").joinpath("data/variables.toml").open("rb") as f: return tomllib.load(f) diff --git a/openhexa/toolbox/era5/transform.py b/openhexa/toolbox/era5/transform.py index 75b60247..6dfa8ef7 100644 --- a/openhexa/toolbox/era5/transform.py +++ b/openhexa/toolbox/era5/transform.py @@ -10,7 +10,7 @@ import rasterio.transform import xarray as xr -from era5.dhis2weeks import WeekType, to_dhis2_week +from openhexa.toolbox.era5.dhis2weeks import WeekType, to_dhis2_week def create_masks(gdf: gpd.GeoDataFrame, id_column: str, ds: xr.Dataset) -> xr.DataArray: diff --git a/tests/era5/test_dhis2weeks.py b/tests/era5/test_dhis2weeks.py index 66a77363..64b7be0f 100644 --- a/tests/era5/test_dhis2weeks.py +++ b/tests/era5/test_dhis2weeks.py @@ -2,7 +2,7 @@ from datetime import date -from era5.dhis2weeks import WeekType, get_calendar_week +from openhexa.toolbox.era5.dhis2weeks import WeekType, get_calendar_week def test_standard_iso_week(): diff --git a/tests/era5/test_extract.py b/tests/era5/test_extract.py index c3200f0b..d434e358 100644 --- a/tests/era5/test_extract.py +++ b/tests/era5/test_extract.py @@ -11,7 +11,7 @@ import pytest import xarray as xr -from era5.extract import ( +from openhexa.toolbox.era5.extract import ( Client, Remote, Request, diff --git a/tests/era5/test_transform.py b/tests/era5/test_transform.py index aed6f93d..a90bbb36 100644 --- a/tests/era5/test_transform.py +++ b/tests/era5/test_transform.py @@ -10,7 +10,7 @@ import pytest import xarray as xr -from era5.transform import Period, aggregate_in_space, aggregate_in_time, create_masks +from openhexa.toolbox.era5.transform import Period, aggregate_in_space, aggregate_in_time, create_masks @pytest.fixture From 34012bff861e81f334587c3dcb6f74bceabd4819 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Wed, 1 Oct 2025 18:24:38 +0200 Subject: [PATCH 03/51] add missing era5 dependencies --- pyproject.toml | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2050596b..de7e4987 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] -requires-python = ">=3.8" +requires-python = ">=3.12" dependencies = [ "requests", "python-dateutil", @@ -30,7 +30,7 @@ dependencies = [ "epiweeks", "openhexa.sdk", "humanize", - "rich" + "rich", ] [project.optional-dependencies] @@ -49,24 +49,22 @@ test = [ "pytest~=8.4.0", "pytest-cov~=7.0.0", "responses", - "cdsapi >=0.7.3", - "cads-api-client >=1.4.0", - "cfgrib", "xarray", - "datapi >=0.3.0", "rasterio", "epiweeks", - "openlineage-python >=1.33.0" + "openlineage-python >=1.33.0", + "ecmwf-datastores-client>=0.4.0", + "pyarrow>=21.0.0", + "xarray>=2025.9.1", + "zarr>=3.1.3", ] era5 = [ - "cdsapi >=0.7.3", - "cads-api-client >=1.4.0", - "cfgrib", - "xarray", - "datapi >=0.3.0", - "rasterio", - "epiweeks" + "cfgrib>=0.9.15.1", + "ecmwf-datastores-client>=0.4.0", + "pyarrow>=21.0.0", + "xarray>=2025.9.1", + "zarr>=3.1.3", ] lineage = [ @@ -91,7 +89,7 @@ include = [ namespaces = true [tool.setuptools.package-data] -"openhexa.toolbox.era5" = ["*.json"] +"openhexa.toolbox.era5.data" = ["*.json", "*.toml"] [project.urls] "Homepage" = "https://github.com/blsq/openhexa-toolbox" From fbcce1207919020ebb705727ed0099ea70361e1b Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Wed, 1 Oct 2025 19:00:35 +0200 Subject: [PATCH 04/51] install era5 dependencies in CI --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09d4918e..853c8f8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: python-version: 3.13 - name: "Install dependencies" - run: pip install ".[test]" + run: pip install ".[test,era5,lineage]" - name: Run tests run: pytest --cov=. --cov-report html --cov-report term From 420c6db5a4d3212ec9a61d21aa2d9026e67ae04f Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Wed, 1 Oct 2025 19:18:16 +0200 Subject: [PATCH 05/51] Fix package data --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index de7e4987..03e6efaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,7 +89,7 @@ include = [ namespaces = true [tool.setuptools.package-data] -"openhexa.toolbox.era5.data" = ["*.json", "*.toml"] +"openhexa.toolbox.era5" = ["data/*.json", "data/*.toml"] [project.urls] "Homepage" = "https://github.com/blsq/openhexa-toolbox" From ed2afaf1678a65168bc98070145858b70d299e24 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 3 Oct 2025 14:41:09 +0200 Subject: [PATCH 06/51] Store all variables in the same Zarr store --- openhexa/toolbox/era5/extract.py | 77 +++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 12 deletions(-) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index 80aefea3..a299b56c 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -8,6 +8,7 @@ import logging import shutil import tempfile +import tomllib from collections import defaultdict from datetime import date from pathlib import Path @@ -16,7 +17,6 @@ import numpy as np import numpy.typing as npt -import tomllib import xarray as xr import zarr from dateutil.relativedelta import relativedelta @@ -263,19 +263,24 @@ def _times_in_zarr(store: Path) -> npt.NDArray[np.datetime64]: return ds.time.values -def create_zarr(ds: xr.Dataset, zarr_store: Path) -> None: +def create_zarr(ds: xr.Dataset, zarr_store: Path, variable: str) -> None: """Create a new zarr store from the dataset. Args: ds: The xarray Dataset to store. zarr_store: Path to the zarr store to create. + variable: Name of the variable to store. """ - ds.to_zarr(zarr_store, mode="w", consolidated=True, zarr_format=2) - logger.debug("Created Zarr store at %s", zarr_store) + if not zarr_store.exists(): + ds.to_zarr(zarr_store, mode="w", consolidated=True, zarr_format=2) + logger.debug("Created Zarr store at %s with variable %s", zarr_store, variable) + else: + ds.to_zarr(zarr_store, mode="a", consolidated=True, zarr_format=2) + logger.debug("Added variable %s to existing Zarr store", variable) -def append_zarr(ds: xr.Dataset, zarr_store: Path) -> None: +def append_zarr(ds: xr.Dataset, zarr_store: Path, variable: str) -> None: """Append new data to an existing zarr store. The function checks for overlapping time values and only appends new data. @@ -283,6 +288,7 @@ def append_zarr(ds: xr.Dataset, zarr_store: Path) -> None: Args: ds: The xarray Dataset to append. zarr_store: Path to the existing zarr store. + variable: Name of the variable to append. """ existing_times = _times_in_zarr(zarr_store) @@ -295,7 +301,43 @@ def append_zarr(ds: xr.Dataset, zarr_store: Path) -> None: logger.debug("No new data to add to Zarr store") return ds.to_zarr(zarr_store, mode="a", append_dim="time", zarr_format=2) - logger.debug("Appended %s values to Zarr store", len(ds.time)) + logger.debug("Appended %s values to Zarr store for variable %s", len(ds.time), variable) + + +def _variable_is_in_zarr(zarr_store: Path, variable: str) -> bool: + """Check if a variable exists in a zarr store. + + Args: + zarr_store: Path to the zarr store. + variable: Name of the variable to check. + + Returns: + True if the variable exists in the zarr store, False otherwise. + + """ + if not zarr_store.exists(): + raise ValueError(f"Zarr store {zarr_store} does not exist") + ds = xr.open_zarr(zarr_store, consolidated=True, decode_timedelta=False) + return variable in ds.data_vars + + +def _list_times_in_zarr(store: Path, variable: str) -> npt.NDArray[np.datetime64]: + """List time dimensions for a specific variable in the zarr store. + + Args: + store: Path to the zarr store. + variable: Name of the variable to check. + + Returns: + Numpy array of datetime64 values in the time dimension of the specified variable. + + """ + if not store.exists(): + raise ValueError(f"Zarr store {store} does not exist") + ds = xr.open_zarr(store, consolidated=True, decode_timedelta=False) + if variable not in ds.data_vars: + raise ValueError(f"Variable {variable} not found in Zarr store {store}") + return ds[variable].time.values def consolidate_zarr(zarr_store: Path) -> None: @@ -326,6 +368,7 @@ def consolidate_zarr(zarr_store: Path) -> None: def grib_to_zarr( src_dir: Path, zarr_store: Path, + variable: str, ) -> None: """Move data in multiple GRIB files to a zarr store. @@ -335,6 +378,7 @@ def grib_to_zarr( Args: src_dir: Directory containing the GRIB files. zarr_store: Path to the zarr store to create or update. + variable: Name of the variable to process. """ for fp in src_dir.glob("*.grib"): @@ -345,10 +389,11 @@ def grib_to_zarr( "longitude": np.round(ds.longitude.values, 1), }, ) - if not zarr_store.exists(): - create_zarr(ds, zarr_store) + variable_exists = _variable_is_in_zarr(zarr_store, variable) + if not variable_exists: + create_zarr(ds, zarr_store, variable) else: - append_zarr(ds, zarr_store) + append_zarr(ds, zarr_store, variable) consolidate_zarr(zarr_store) @@ -356,6 +401,7 @@ def diff_zarr( start_date: date, end_date: date, zarr_store: Path, + variable: str, ) -> list[date]: """Get dates between start and end dates that are not in the zarr store. @@ -363,6 +409,7 @@ def diff_zarr( start_date: Start date for data retrieval. end_date: End date for data retrieval. zarr_store: The Zarr store to check for existing data. + variable: Name of the variable to check in the Zarr store. Returns: The list of dates that are not in the Zarr store. @@ -371,7 +418,10 @@ def diff_zarr( if not zarr_store.exists(): return get_date_range(start_date, end_date) - zarr_dtimes = _times_in_zarr(zarr_store) + if not _variable_is_in_zarr(zarr_store, variable): + return get_date_range(start_date, end_date) + + zarr_dtimes = _list_times_in_zarr(zarr_store, variable) zarr_dates = zarr_dtimes.astype("datetime64[D]").astype(date).tolist() date_range = get_date_range(start_date, end_date) @@ -384,6 +434,7 @@ def get_missing_dates( start_date: date, end_date: date, zarr_store: Path, + variable: str, ) -> list[date]: """Get the list of dates between start_date and end_date that are not in the Zarr store. @@ -393,6 +444,7 @@ def get_missing_dates( start_date: Start date for data retrieval. end_date: End date for data retrieval. zarr_store: The Zarr store to check for existing data. + variable: Name of the variable to check in the Zarr store. Returns: A list of dates that are not in the Zarr store. @@ -408,8 +460,8 @@ def get_missing_dates( collection.begin_datetime.date(), collection.end_datetime.date(), ) - dates = diff_zarr(start_date, end_date, zarr_store) - logger.debug("Missing dates: %s", dates) + dates = diff_zarr(start_date, end_date, zarr_store, variable) + logger.debug("Missing dates for variable '%s': %s", variable, dates) return dates @@ -451,6 +503,7 @@ def prepare_requests( start_date=start_date, end_date=end_date, zarr_store=zarr_store, + variable=variable, ) requests = build_requests( From 4afdc5a07bf21038fa09d54ad5a88dd7cf7c2398 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 3 Oct 2025 16:43:02 +0200 Subject: [PATCH 07/51] Flatten step dimension into time --- openhexa/toolbox/era5/extract.py | 65 ++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index a299b56c..be2967c4 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -249,20 +249,6 @@ def retrieve_remotes( return pending -def _times_in_zarr(store: Path) -> npt.NDArray[np.datetime64]: - """List time dimensions in the zarr store. - - Args: - store: Path to the zarr store. - - Returns: - Numpy array of datetime64 values in the time dimension of the entire zarr store. - - """ - ds = xr.open_zarr(store, consolidated=True, decode_timedelta=False) - return ds.time.values - - def create_zarr(ds: xr.Dataset, zarr_store: Path, variable: str) -> None: """Create a new zarr store from the dataset. @@ -291,7 +277,7 @@ def append_zarr(ds: xr.Dataset, zarr_store: Path, variable: str) -> None: variable: Name of the variable to append. """ - existing_times = _times_in_zarr(zarr_store) + existing_times = _list_times_in_zarr(zarr_store, variable) new_times = ds.time.values overlap = np.isin(new_times, existing_times) if overlap.any(): @@ -365,6 +351,53 @@ def consolidate_zarr(zarr_store: Path) -> None: zarr.consolidate_metadata(zarr_store, zarr_format=2) +def drop_incomplete_days(ds: xr.Dataset, data_var: str) -> xr.Dataset: + """Drop days with incomplete data from the dataset. + + Days at the boundaries of the data request might have incomplete data. Ex: 1st day + with data only for the last step, or last day with missing data for the last step. + We only keep days with complete data to avoid having to deal with missing values & + partial appends. + + Args: + ds: The xarray dataset to process. + data_var: The name of the data variable to check for completeness. + + Returns: + The xarray dataset with incomplete days removed. + """ + data_var = list(ds.data_vars)[0] + complete_times = ~ds[data_var].isnull().any(dim="step") + return ds.sel(time=complete_times) + + +def flatten_time_dimension(ds: xr.Dataset) -> xr.Dataset: + """Flatten the time dimension of the dataset. + + Flatten step dimension into time. Meaning, instead of having time (n=n_days) and + step (n=n_hours) dimensions, we only have one (n=n_days*n_hours). This makes + analysis easier. + + NB: Unused dimensions (number, surface) are also dropped because they are not + relevant to the variables we currently support. + + Args: + ds: The xarray dataset to flatten. + + Returns: + The flattened xarray dataset. + + """ + valid_times = ds.valid_time.values.flatten() + ds = ds.stack(new_time=("time", "step")) + ds = ds.reset_index("new_time", drop=True) + ds = ds.assign_coords(new_time=valid_times) + ds = ds.drop_vars(["valid_time", "number", "surface"]) + ds = ds.rename({"new_time": "time"}) + + return ds + + def grib_to_zarr( src_dir: Path, zarr_store: Path, @@ -378,7 +411,7 @@ def grib_to_zarr( Args: src_dir: Directory containing the GRIB files. zarr_store: Path to the zarr store to create or update. - variable: Name of the variable to process. + variable: Short name of the variable to process (e.g. "t2m", "tp", "swvl1"). """ for fp in src_dir.glob("*.grib"): From c9eaf0997918d5106ec81955697f4153eb794130 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 3 Oct 2025 16:45:46 +0200 Subject: [PATCH 08/51] Use ds.isel instead of ds.sel --- openhexa/toolbox/era5/extract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index be2967c4..e93d6d4a 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -282,7 +282,7 @@ def append_zarr(ds: xr.Dataset, zarr_store: Path, variable: str) -> None: overlap = np.isin(new_times, existing_times) if overlap.any(): logger.warning("Time dimension of GRIB file overlaps with existing Zarr store") - ds = ds.sel(time=~overlap) + ds = ds.isel(time=~overlap) if len(ds.time) == 0: logger.debug("No new data to add to Zarr store") return From c2f1e09a5ea5bc8f9fdc36aea475ebda11f18354 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 3 Oct 2025 16:46:15 +0200 Subject: [PATCH 09/51] Use parameter for variable short name --- openhexa/toolbox/era5/extract.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index e93d6d4a..9a0f89df 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -366,7 +366,6 @@ def drop_incomplete_days(ds: xr.Dataset, data_var: str) -> xr.Dataset: Returns: The xarray dataset with incomplete days removed. """ - data_var = list(ds.data_vars)[0] complete_times = ~ds[data_var].isnull().any(dim="step") return ds.sel(time=complete_times) From 696f7e59a19010de1838bd96a39ce059416a423c Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 3 Oct 2025 16:47:40 +0200 Subject: [PATCH 10/51] Flatten time dimension & drop incomplete days --- openhexa/toolbox/era5/extract.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index 9a0f89df..29b70b5d 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -421,6 +421,8 @@ def grib_to_zarr( "longitude": np.round(ds.longitude.values, 1), }, ) + ds = drop_incomplete_days(ds, data_var=variable) + ds = flatten_time_dimension(ds) variable_exists = _variable_is_in_zarr(zarr_store, variable) if not variable_exists: create_zarr(ds, zarr_store, variable) From a994d252a1fa97a42863533883dd5a587ef96091 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 3 Oct 2025 16:54:12 +0200 Subject: [PATCH 11/51] Fix create/append logic for the zarr store --- openhexa/toolbox/era5/extract.py | 38 ++++++++++++++++---------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index 29b70b5d..b03dcaff 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -249,21 +249,18 @@ def retrieve_remotes( return pending -def create_zarr(ds: xr.Dataset, zarr_store: Path, variable: str) -> None: +def create_zarr(ds: xr.Dataset, zarr_store: Path) -> None: """Create a new zarr store from the dataset. Args: ds: The xarray Dataset to store. zarr_store: Path to the zarr store to create. - variable: Name of the variable to store. """ - if not zarr_store.exists(): - ds.to_zarr(zarr_store, mode="w", consolidated=True, zarr_format=2) - logger.debug("Created Zarr store at %s with variable %s", zarr_store, variable) - else: - ds.to_zarr(zarr_store, mode="a", consolidated=True, zarr_format=2) - logger.debug("Added variable %s to existing Zarr store", variable) + if zarr_store.exists(): + raise ValueError(f"Zarr store {zarr_store} already exists") + ds.to_zarr(zarr_store, mode="w", consolidated=True, zarr_format=2) + logger.debug("Created Zarr store at %s", zarr_store) def append_zarr(ds: xr.Dataset, zarr_store: Path, variable: str) -> None: @@ -277,17 +274,20 @@ def append_zarr(ds: xr.Dataset, zarr_store: Path, variable: str) -> None: variable: Name of the variable to append. """ - existing_times = _list_times_in_zarr(zarr_store, variable) - new_times = ds.time.values - overlap = np.isin(new_times, existing_times) - if overlap.any(): - logger.warning("Time dimension of GRIB file overlaps with existing Zarr store") - ds = ds.isel(time=~overlap) - if len(ds.time) == 0: - logger.debug("No new data to add to Zarr store") - return - ds.to_zarr(zarr_store, mode="a", append_dim="time", zarr_format=2) - logger.debug("Appended %s values to Zarr store for variable %s", len(ds.time), variable) + if variable in xr.open_zarr(zarr_store).data_vars: + existing_times = _list_times_in_zarr(zarr_store, variable) + new_times = ds.time.values + overlap = np.isin(new_times, existing_times) + if overlap.any(): + logger.warning("Time dimension of GRIB file overlaps with existing Zarr store") + ds = ds.isel(time=~overlap) + if len(ds.time) == 0: + logger.debug("No new data to add to Zarr store") + return + ds.to_zarr(zarr_store, mode="a", append_dim="time", zarr_format=2) + else: + ds.to_zarr(zarr_store, mode="a", zarr_format=2) + logger.debug("Added data to Zarr store for variable %s", variable) def _variable_is_in_zarr(zarr_store: Path, variable: str) -> bool: From 983dcb640b8864b887ae1edc70e46f23583afb65 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 3 Oct 2025 16:55:59 +0200 Subject: [PATCH 12/51] Remove wrong parameter --- openhexa/toolbox/era5/extract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index b03dcaff..5d7c21ae 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -425,7 +425,7 @@ def grib_to_zarr( ds = flatten_time_dimension(ds) variable_exists = _variable_is_in_zarr(zarr_store, variable) if not variable_exists: - create_zarr(ds, zarr_store, variable) + create_zarr(ds, zarr_store) else: append_zarr(ds, zarr_store, variable) consolidate_zarr(zarr_store) From e5553f6670001b7851d900a97dbda173fdee5e1a Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 3 Oct 2025 17:00:41 +0200 Subject: [PATCH 13/51] Consistency between data_var and variable parameters --- openhexa/toolbox/era5/extract.py | 54 ++++++++++++++++---------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index 5d7c21ae..b11fa54d 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -263,7 +263,7 @@ def create_zarr(ds: xr.Dataset, zarr_store: Path) -> None: logger.debug("Created Zarr store at %s", zarr_store) -def append_zarr(ds: xr.Dataset, zarr_store: Path, variable: str) -> None: +def append_zarr(ds: xr.Dataset, zarr_store: Path, data_var: str) -> None: """Append new data to an existing zarr store. The function checks for overlapping time values and only appends new data. @@ -271,11 +271,11 @@ def append_zarr(ds: xr.Dataset, zarr_store: Path, variable: str) -> None: Args: ds: The xarray Dataset to append. zarr_store: Path to the existing zarr store. - variable: Name of the variable to append. + data_var: Name of the variable to append. """ - if variable in xr.open_zarr(zarr_store).data_vars: - existing_times = _list_times_in_zarr(zarr_store, variable) + if data_var in xr.open_zarr(zarr_store).data_vars: + existing_times = _list_times_in_zarr(zarr_store, data_var) new_times = ds.time.values overlap = np.isin(new_times, existing_times) if overlap.any(): @@ -287,15 +287,15 @@ def append_zarr(ds: xr.Dataset, zarr_store: Path, variable: str) -> None: ds.to_zarr(zarr_store, mode="a", append_dim="time", zarr_format=2) else: ds.to_zarr(zarr_store, mode="a", zarr_format=2) - logger.debug("Added data to Zarr store for variable %s", variable) + logger.debug("Added data to Zarr store for variable %s", data_var) -def _variable_is_in_zarr(zarr_store: Path, variable: str) -> bool: +def _variable_is_in_zarr(zarr_store: Path, data_var: str) -> bool: """Check if a variable exists in a zarr store. Args: zarr_store: Path to the zarr store. - variable: Name of the variable to check. + data_var: Name of the variable to check. Returns: True if the variable exists in the zarr store, False otherwise. @@ -304,15 +304,15 @@ def _variable_is_in_zarr(zarr_store: Path, variable: str) -> bool: if not zarr_store.exists(): raise ValueError(f"Zarr store {zarr_store} does not exist") ds = xr.open_zarr(zarr_store, consolidated=True, decode_timedelta=False) - return variable in ds.data_vars + return data_var in ds.data_vars -def _list_times_in_zarr(store: Path, variable: str) -> npt.NDArray[np.datetime64]: +def _list_times_in_zarr(store: Path, data_var: str) -> npt.NDArray[np.datetime64]: """List time dimensions for a specific variable in the zarr store. Args: store: Path to the zarr store. - variable: Name of the variable to check. + data_var: Name of the variable to check. Returns: Numpy array of datetime64 values in the time dimension of the specified variable. @@ -321,9 +321,9 @@ def _list_times_in_zarr(store: Path, variable: str) -> npt.NDArray[np.datetime64 if not store.exists(): raise ValueError(f"Zarr store {store} does not exist") ds = xr.open_zarr(store, consolidated=True, decode_timedelta=False) - if variable not in ds.data_vars: - raise ValueError(f"Variable {variable} not found in Zarr store {store}") - return ds[variable].time.values + if data_var not in ds.data_vars: + raise ValueError(f"Variable {data_var} not found in Zarr store {store}") + return ds[data_var].time.values def consolidate_zarr(zarr_store: Path) -> None: @@ -400,7 +400,7 @@ def flatten_time_dimension(ds: xr.Dataset) -> xr.Dataset: def grib_to_zarr( src_dir: Path, zarr_store: Path, - variable: str, + data_var: str, ) -> None: """Move data in multiple GRIB files to a zarr store. @@ -410,7 +410,7 @@ def grib_to_zarr( Args: src_dir: Directory containing the GRIB files. zarr_store: Path to the zarr store to create or update. - variable: Short name of the variable to process (e.g. "t2m", "tp", "swvl1"). + data_var: Short name of the variable to process (e.g. "t2m", "tp", "swvl1"). """ for fp in src_dir.glob("*.grib"): @@ -421,13 +421,13 @@ def grib_to_zarr( "longitude": np.round(ds.longitude.values, 1), }, ) - ds = drop_incomplete_days(ds, data_var=variable) + ds = drop_incomplete_days(ds, data_var=data_var) ds = flatten_time_dimension(ds) - variable_exists = _variable_is_in_zarr(zarr_store, variable) + variable_exists = _variable_is_in_zarr(zarr_store, data_var) if not variable_exists: create_zarr(ds, zarr_store) else: - append_zarr(ds, zarr_store, variable) + append_zarr(ds, zarr_store, data_var) consolidate_zarr(zarr_store) @@ -435,7 +435,7 @@ def diff_zarr( start_date: date, end_date: date, zarr_store: Path, - variable: str, + data_var: str, ) -> list[date]: """Get dates between start and end dates that are not in the zarr store. @@ -443,7 +443,7 @@ def diff_zarr( start_date: Start date for data retrieval. end_date: End date for data retrieval. zarr_store: The Zarr store to check for existing data. - variable: Name of the variable to check in the Zarr store. + data_var: Name of the variable to check in the Zarr store. Returns: The list of dates that are not in the Zarr store. @@ -452,10 +452,10 @@ def diff_zarr( if not zarr_store.exists(): return get_date_range(start_date, end_date) - if not _variable_is_in_zarr(zarr_store, variable): + if not _variable_is_in_zarr(zarr_store, data_var): return get_date_range(start_date, end_date) - zarr_dtimes = _list_times_in_zarr(zarr_store, variable) + zarr_dtimes = _list_times_in_zarr(zarr_store, data_var) zarr_dates = zarr_dtimes.astype("datetime64[D]").astype(date).tolist() date_range = get_date_range(start_date, end_date) @@ -468,7 +468,7 @@ def get_missing_dates( start_date: date, end_date: date, zarr_store: Path, - variable: str, + data_var: str, ) -> list[date]: """Get the list of dates between start_date and end_date that are not in the Zarr store. @@ -478,7 +478,7 @@ def get_missing_dates( start_date: Start date for data retrieval. end_date: End date for data retrieval. zarr_store: The Zarr store to check for existing data. - variable: Name of the variable to check in the Zarr store. + data_var: Name of the variable to check in the Zarr store. Returns: A list of dates that are not in the Zarr store. @@ -494,8 +494,8 @@ def get_missing_dates( collection.begin_datetime.date(), collection.end_datetime.date(), ) - dates = diff_zarr(start_date, end_date, zarr_store, variable) - logger.debug("Missing dates for variable '%s': %s", variable, dates) + dates = diff_zarr(start_date, end_date, zarr_store, data_var) + logger.debug("Missing dates for variable '%s': %s", data_var, dates) return dates @@ -537,7 +537,7 @@ def prepare_requests( start_date=start_date, end_date=end_date, zarr_store=zarr_store, - variable=variable, + data_var=variables[variable]["short_name"], ) requests = build_requests( From 669a97d71dd8a9ee16db023963116efa8468b712 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 3 Oct 2025 18:37:31 +0200 Subject: [PATCH 14/51] Validate time consistency of Zarr store --- openhexa/toolbox/era5/extract.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index b11fa54d..d40054bc 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -351,6 +351,29 @@ def consolidate_zarr(zarr_store: Path) -> None: zarr.consolidate_metadata(zarr_store, zarr_format=2) +def validate_zarr(zarr_store: Path) -> None: + """Validate the zarr store by checking for duplicate or missing time values. + + Args: + zarr_store: Path to the zarr store to validate. + + Raises: + RuntimeError: If duplicate or inconsistent time values are found. + """ + ds = xr.open_zarr(zarr_store, consolidated=True, decode_timedelta=False) + for data_var in ds.data_vars: + times = ds.time.values + if len(times) != len(np.unique(times)): + msg = f"Zarr store {zarr_store} has duplicate time values for variable {data_var}" + raise RuntimeError(msg) + dates = times.astype("datetime64[D]") + _, counts = np.unique(dates, return_counts=True) + if not np.all(counts == counts[0]): + unique_counts = np.unique(counts) + msg = f"Inconsistent steps per day found: {unique_counts}\nExpected all days to have {counts[0]} steps" + raise RuntimeError(msg) + + def drop_incomplete_days(ds: xr.Dataset, data_var: str) -> xr.Dataset: """Drop days with incomplete data from the dataset. @@ -366,7 +389,7 @@ def drop_incomplete_days(ds: xr.Dataset, data_var: str) -> xr.Dataset: Returns: The xarray dataset with incomplete days removed. """ - complete_times = ~ds[data_var].isnull().any(dim="step") + complete_times = ~ds[data_var].isnull().any(dim=["step", "latitude", "longitude"]) return ds.sel(time=complete_times) @@ -423,12 +446,12 @@ def grib_to_zarr( ) ds = drop_incomplete_days(ds, data_var=data_var) ds = flatten_time_dimension(ds) - variable_exists = _variable_is_in_zarr(zarr_store, data_var) - if not variable_exists: + if not zarr_store.exists(): create_zarr(ds, zarr_store) else: append_zarr(ds, zarr_store, data_var) consolidate_zarr(zarr_store) + validate_zarr(zarr_store) def diff_zarr( From 79adfc1b3c01055b3d93b334c4124f6c524f4c76 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 3 Oct 2025 18:41:19 +0200 Subject: [PATCH 15/51] Log GRIB file being processed --- openhexa/toolbox/era5/extract.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index d40054bc..1489dd31 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -437,6 +437,7 @@ def grib_to_zarr( """ for fp in src_dir.glob("*.grib"): + logger.debug("Processing GRIB file %s", fp.name) ds = xr.open_dataset(fp, engine="cfgrib", decode_timedelta=False) ds = ds.assign_coords( { From 583a715aafd223052871db346d26bd0c03b7d6f8 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 3 Oct 2025 18:42:59 +0200 Subject: [PATCH 16/51] Update tests for grib_to_zarr --- tests/era5/test_extract.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/era5/test_extract.py b/tests/era5/test_extract.py index d434e358..0d080fd2 100644 --- a/tests/era5/test_extract.py +++ b/tests/era5/test_extract.py @@ -249,7 +249,7 @@ def _move_grib_to_tmp_dir(grib_file: Path, dst_dir: Path): _move_grib_to_tmp_dir(sample_grib_file_march, grib_dir) _move_grib_to_tmp_dir(sample_grib_file_april, grib_dir) zarr_store = Path(tmpdir) / "store.zarr" - grib_to_zarr(grib_dir, zarr_store) + grib_to_zarr(grib_dir, zarr_store, "t2m") # We expect the Zarr store have been created and contains data from both GRIB files # Sample GRIB files contains data from 2025-03-28 to 2025-04-05 (9 days) assert zarr_store.exists() @@ -258,4 +258,4 @@ def _move_grib_to_tmp_dir(grib_file: Path, dst_dir: Path): times = np.array(ds["time"]) assert np.datetime_as_string(times[0], unit="D") == "2025-03-28" assert np.datetime_as_string(times[-1], unit="D") == "2025-04-05" - assert len(times) == 9 + assert len(times) == 9 * 4 # 9 days, 4 time steps per day From b91146ab44eae8122fa52d4e8fb9579c6bd3d8ec Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Wed, 8 Oct 2025 17:21:26 +0200 Subject: [PATCH 17/51] Change log level --- openhexa/toolbox/era5/extract.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index 1489dd31..25e6ae9e 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -279,7 +279,7 @@ def append_zarr(ds: xr.Dataset, zarr_store: Path, data_var: str) -> None: new_times = ds.time.values overlap = np.isin(new_times, existing_times) if overlap.any(): - logger.warning("Time dimension of GRIB file overlaps with existing Zarr store") + logger.debug("Time dimension of GRIB file overlaps with existing Zarr store") ds = ds.isel(time=~overlap) if len(ds.time) == 0: logger.debug("No new data to add to Zarr store") @@ -437,7 +437,7 @@ def grib_to_zarr( """ for fp in src_dir.glob("*.grib"): - logger.debug("Processing GRIB file %s", fp.name) + logger.info("Processing GRIB file %s", fp.name) ds = xr.open_dataset(fp, engine="cfgrib", decode_timedelta=False) ds = ds.assign_coords( { From eb6b62bf9595e0983ab11c155bcaa09f16029c3d Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Wed, 8 Oct 2025 17:52:22 +0200 Subject: [PATCH 18/51] Reorganize private & public functions --- openhexa/toolbox/era5/extract.py | 510 +++++++++++++++---------------- tests/era5/test_extract.py | 12 +- 2 files changed, 261 insertions(+), 261 deletions(-) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index 25e6ae9e..23f9fdf7 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -35,6 +35,27 @@ class Variable(TypedDict): time: list[str] +class Request(TypedDict): + """Request parameters for the 'reanalysis-era5-land' dataset.""" + + variable: list[str] + year: str + month: str + day: list[str] + time: list[str] + data_format: Literal["grib", "netcdf"] + download_format: Literal["unarchived", "zip"] + area: list[int] + + +class RequestTemporal(TypedDict): + """Temporal request parameters.""" + + year: str + month: str + day: list[str] + + def _get_variables() -> dict[str, Variable]: """Load ERA5-Land variables metadata. @@ -46,17 +67,20 @@ def _get_variables() -> dict[str, Variable]: return tomllib.load(f) -class Request(TypedDict): - """Request parameters for the 'reanalysis-era5-land' dataset.""" +def _get_name(remote: Remote) -> str: + """Create file name from remote request. - variable: list[str] - year: str - month: str - day: list[str] - time: list[str] - data_format: Literal["grib", "netcdf"] - download_format: Literal["unarchived", "zip"] - area: list[int] + Returns: + File name with format: {year}{month}_{request_id}.{ext} + + """ + request = remote.request + data_format = request["data_format"] + download_format = request["download_format"] + year = request["year"] + month = request["month"] + ext = "zip" if download_format == "zip" else data_format + return f"{year}{month}_{remote.request_id}.{ext}" def get_date_range( @@ -82,7 +106,7 @@ def get_date_range( return date_range -def bound_date_range( +def _bound_date_range( start_date: date, end_date: date, collection_start_date: date, @@ -105,15 +129,7 @@ def bound_date_range( return start, end -class RequestTemporal(TypedDict): - """Temporal request parameters.""" - - year: str - month: str - day: list[str] - - -def get_temporal_chunks(dates: list[date]) -> list[RequestTemporal]: +def _get_temporal_chunks(dates: list[date]) -> list[RequestTemporal]: """Get monthly temporal request chunks for the given list of dates. Args: @@ -139,34 +155,7 @@ def get_temporal_chunks(dates: list[date]) -> list[RequestTemporal]: return chunks -def submit_requests( - client: Client, - collection_id: str, - requests: list[Request], -) -> list[Remote]: - """Submit a list of requests to the CDS API. - - Args: - client: CDS API client. - collection_id: ID of the CDS dataset (e.g. "reanalysis-era5-land"). - requests: List of request parameters. - - Returns: - List of Remote objects representing the submitted requests. - - """ - remotes: list[Remote] = [] - for request in requests: - r = client.submit( - collection_id=collection_id, - request=dict(request), - ) - logger.info("Submitted request %s", r.request_id) - remotes.append(r) - return remotes - - -def build_requests( +def _build_requests( dates: list[date], variable: str, time: list[str], @@ -189,7 +178,7 @@ def build_requests( """ requests: list[Request] = [] - temporal_chunks = get_temporal_chunks(dates) + temporal_chunks = _get_temporal_chunks(dates) for chunk in temporal_chunks: request = Request( variable=[variable], @@ -205,23 +194,93 @@ def build_requests( return requests -def _get_name(remote: Remote) -> str: - """Create file name from remote request. +def prepare_requests( + client: Client, + dataset_id: str, + start_date: date, + end_date: date, + variable: str, + area: list[int], + zarr_store: Path, +) -> list[Request]: + """Prepare requests for data retrieval from the CDS API. + + This function checks the available dates in the Zarr store and prepares + requests for the missing dates. + + Args: + client: The CDS API client. + dataset_id: ID of the CDS dataset (e.g. "reanalysis-era5-land"). + start_date: Start date for data synchronization. + end_date: End date for data synchronization. + variable: The variable to synchronize (e.g. "2m_temperature"). + area: The geographical area to synchronize (north, west, south, east). + zarr_store: The Zarr store to update or create. Returns: - File name with format: {year}{month}_{request_id}.{ext} + A list of requests to be submitted to the CDS API. """ - request = remote.request - data_format = request["data_format"] - download_format = request["download_format"] - year = request["year"] - month = request["month"] - ext = "zip" if download_format == "zip" else data_format - return f"{year}{month}_{remote.request_id}.{ext}" + variables = _get_variables() + if variable not in variables: + msg = f"Variable '{variable}' not supported" + raise ValueError(msg) + dates = get_missing_dates( + client=client, + dataset_id=dataset_id, + start_date=start_date, + end_date=end_date, + zarr_store=zarr_store, + data_var=variables[variable]["short_name"], + ) + + requests = _build_requests( + dates=dates, + variable=variable, + time=variables[variable]["time"], + area=area, + data_format="grib", + download_format="unarchived", + ) + + max_requests = 100 + if len(requests) > max_requests: + msg = f"Too many data requests ({len(requests)}), max is {max_requests}" + logger.error(msg) + raise ValueError(msg) + + return requests + + +def _submit_requests( + client: Client, + collection_id: str, + requests: list[Request], +) -> list[Remote]: + """Submit a list of requests to the CDS API. + + Args: + client: CDS API client. + collection_id: ID of the CDS dataset (e.g. "reanalysis-era5-land"). + requests: List of request parameters. -def retrieve_remotes( + Returns: + List of Remote objects representing the submitted requests. + + """ + remotes: list[Remote] = [] + for request in requests: + r = client.submit( + collection_id=collection_id, + request=dict(request), + ) + logger.info("Submitted request %s", r.request_id) + remotes.append(r) + return remotes + + +def _retrieve_remotes( queue: list[Remote], output_dir: Path, ) -> list[Remote]: @@ -249,45 +308,32 @@ def retrieve_remotes( return pending -def create_zarr(ds: xr.Dataset, zarr_store: Path) -> None: - """Create a new zarr store from the dataset. - - Args: - ds: The xarray Dataset to store. - zarr_store: Path to the zarr store to create. - - """ - if zarr_store.exists(): - raise ValueError(f"Zarr store {zarr_store} already exists") - ds.to_zarr(zarr_store, mode="w", consolidated=True, zarr_format=2) - logger.debug("Created Zarr store at %s", zarr_store) - - -def append_zarr(ds: xr.Dataset, zarr_store: Path, data_var: str) -> None: - """Append new data to an existing zarr store. - - The function checks for overlapping time values and only appends new data. +def retrieve_requests( + client: Client, + dataset_id: str, + requests: list[Request], + src_dir: Path, + wait: int = 30, +) -> None: + """Retrieve the results of the submitted requests. Args: - ds: The xarray Dataset to append. - zarr_store: Path to the existing zarr store. - data_var: Name of the variable to append. + client: The CDS API client. + dataset_id: The ID of the dataset to retrieve. + requests: The list of requests to retrieve. + src_dir: The directory containing the source data files. + wait: Time in seconds to wait between checking for completed requests. """ - if data_var in xr.open_zarr(zarr_store).data_vars: - existing_times = _list_times_in_zarr(zarr_store, data_var) - new_times = ds.time.values - overlap = np.isin(new_times, existing_times) - if overlap.any(): - logger.debug("Time dimension of GRIB file overlaps with existing Zarr store") - ds = ds.isel(time=~overlap) - if len(ds.time) == 0: - logger.debug("No new data to add to Zarr store") - return - ds.to_zarr(zarr_store, mode="a", append_dim="time", zarr_format=2) - else: - ds.to_zarr(zarr_store, mode="a", zarr_format=2) - logger.debug("Added data to Zarr store for variable %s", data_var) + logger.debug("Submitting %s requests", len(requests)) + remotes = _submit_requests( + client=client, + collection_id=dataset_id, + requests=requests, + ) + while remotes: + remotes = _retrieve_remotes(remotes, src_dir) + sleep(wait) def _variable_is_in_zarr(zarr_store: Path, data_var: str) -> bool: @@ -326,55 +372,7 @@ def _list_times_in_zarr(store: Path, data_var: str) -> npt.NDArray[np.datetime64 return ds[data_var].time.values -def consolidate_zarr(zarr_store: Path) -> None: - """Consolidate metadata and ensure dimensions are properly sorted. - - The function consolidates the metadata of the zarr store and checks if the time - dimension is sorted. If not, it sorts the time dimension and rewrites the zarr - store. - - Args: - zarr_store: Path to the zarr store to consolidate. - - """ - zarr.consolidate_metadata(zarr_store) - ds = xr.open_zarr(zarr_store, consolidated=True, decode_timedelta=False) - ds_sorted = ds.sortby("time") - if not np.array_equal(ds.time.values, ds_sorted.time.values): - logger.debug("Sorting time dimension in Zarr store") - with tempfile.TemporaryDirectory() as tmp_dir: - tmp_zarr_store = Path(tmp_dir) / zarr_store.name - ds_sorted.to_zarr(tmp_zarr_store, mode="w", consolidated=True, zarr_format=2) - shutil.rmtree(zarr_store) - shutil.move(tmp_zarr_store, zarr_store) - else: - zarr.consolidate_metadata(zarr_store, zarr_format=2) - - -def validate_zarr(zarr_store: Path) -> None: - """Validate the zarr store by checking for duplicate or missing time values. - - Args: - zarr_store: Path to the zarr store to validate. - - Raises: - RuntimeError: If duplicate or inconsistent time values are found. - """ - ds = xr.open_zarr(zarr_store, consolidated=True, decode_timedelta=False) - for data_var in ds.data_vars: - times = ds.time.values - if len(times) != len(np.unique(times)): - msg = f"Zarr store {zarr_store} has duplicate time values for variable {data_var}" - raise RuntimeError(msg) - dates = times.astype("datetime64[D]") - _, counts = np.unique(dates, return_counts=True) - if not np.all(counts == counts[0]): - unique_counts = np.unique(counts) - msg = f"Inconsistent steps per day found: {unique_counts}\nExpected all days to have {counts[0]} steps" - raise RuntimeError(msg) - - -def drop_incomplete_days(ds: xr.Dataset, data_var: str) -> xr.Dataset: +def _drop_incomplete_days(ds: xr.Dataset, data_var: str) -> xr.Dataset: """Drop days with incomplete data from the dataset. Days at the boundaries of the data request might have incomplete data. Ex: 1st day @@ -393,7 +391,7 @@ def drop_incomplete_days(ds: xr.Dataset, data_var: str) -> xr.Dataset: return ds.sel(time=complete_times) -def flatten_time_dimension(ds: xr.Dataset) -> xr.Dataset: +def _flatten_time_dimension(ds: xr.Dataset) -> xr.Dataset: """Flatten the time dimension of the dataset. Flatten step dimension into time. Meaning, instead of having time (n=n_days) and @@ -420,42 +418,96 @@ def flatten_time_dimension(ds: xr.Dataset) -> xr.Dataset: return ds -def grib_to_zarr( - src_dir: Path, - zarr_store: Path, - data_var: str, -) -> None: - """Move data in multiple GRIB files to a zarr store. +def _create_zarr(ds: xr.Dataset, zarr_store: Path) -> None: + """Create a new zarr store from the dataset. - The function processes all GRIB files in the source directory and moves the data - to the specified Zarr store (creating or appending as necessary). + Args: + ds: The xarray Dataset to store. + zarr_store: Path to the zarr store to create. + + """ + if zarr_store.exists(): + raise ValueError(f"Zarr store {zarr_store} already exists") + ds.to_zarr(zarr_store, mode="w", consolidated=True, zarr_format=2) + logger.debug("Created Zarr store at %s", zarr_store) + + +def _append_zarr(ds: xr.Dataset, zarr_store: Path, data_var: str) -> None: + """Append new data to an existing zarr store. + + The function checks for overlapping time values and only appends new data. Args: - src_dir: Directory containing the GRIB files. - zarr_store: Path to the zarr store to create or update. - data_var: Short name of the variable to process (e.g. "t2m", "tp", "swvl1"). + ds: The xarray Dataset to append. + zarr_store: Path to the existing zarr store. + data_var: Name of the variable to append. """ - for fp in src_dir.glob("*.grib"): - logger.info("Processing GRIB file %s", fp.name) - ds = xr.open_dataset(fp, engine="cfgrib", decode_timedelta=False) - ds = ds.assign_coords( - { - "latitude": np.round(ds.latitude.values, 1), - "longitude": np.round(ds.longitude.values, 1), - }, - ) - ds = drop_incomplete_days(ds, data_var=data_var) - ds = flatten_time_dimension(ds) - if not zarr_store.exists(): - create_zarr(ds, zarr_store) - else: - append_zarr(ds, zarr_store, data_var) - consolidate_zarr(zarr_store) - validate_zarr(zarr_store) + if data_var in xr.open_zarr(zarr_store).data_vars: + existing_times = _list_times_in_zarr(zarr_store, data_var) + new_times = ds.time.values + overlap = np.isin(new_times, existing_times) + if overlap.any(): + logger.debug("Time dimension of GRIB file overlaps with existing Zarr store") + ds = ds.isel(time=~overlap) + if len(ds.time) == 0: + logger.debug("No new data to add to Zarr store") + return + ds.to_zarr(zarr_store, mode="a", append_dim="time", zarr_format=2) + else: + ds.to_zarr(zarr_store, mode="a", zarr_format=2) + logger.debug("Added data to Zarr store for variable %s", data_var) + + +def _consolidate_zarr(zarr_store: Path) -> None: + """Consolidate metadata and ensure dimensions are properly sorted. + The function consolidates the metadata of the zarr store and checks if the time + dimension is sorted. If not, it sorts the time dimension and rewrites the zarr + store. -def diff_zarr( + Args: + zarr_store: Path to the zarr store to consolidate. + + """ + zarr.consolidate_metadata(zarr_store) + ds = xr.open_zarr(zarr_store, consolidated=True, decode_timedelta=False) + ds_sorted = ds.sortby("time") + if not np.array_equal(ds.time.values, ds_sorted.time.values): + logger.debug("Sorting time dimension in Zarr store") + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_zarr_store = Path(tmp_dir) / zarr_store.name + ds_sorted.to_zarr(tmp_zarr_store, mode="w", consolidated=True, zarr_format=2) + shutil.rmtree(zarr_store) + shutil.move(tmp_zarr_store, zarr_store) + else: + zarr.consolidate_metadata(zarr_store, zarr_format=2) + + +def _validate_zarr(zarr_store: Path) -> None: + """Validate the zarr store by checking for duplicate or missing time values. + + Args: + zarr_store: Path to the zarr store to validate. + + Raises: + RuntimeError: If duplicate or inconsistent time values are found. + """ + ds = xr.open_zarr(zarr_store, consolidated=True, decode_timedelta=False) + for data_var in ds.data_vars: + times = ds.time.values + if len(times) != len(np.unique(times)): + msg = f"Zarr store {zarr_store} has duplicate time values for variable {data_var}" + raise RuntimeError(msg) + dates = times.astype("datetime64[D]") + _, counts = np.unique(dates, return_counts=True) + if not np.all(counts == counts[0]): + unique_counts = np.unique(counts) + msg = f"Inconsistent steps per day found: {unique_counts}\nExpected all days to have {counts[0]} steps" + raise RuntimeError(msg) + + +def _diff_zarr( start_date: date, end_date: date, zarr_store: Path, @@ -512,99 +564,47 @@ def get_missing_dates( if not collection.begin_datetime or not collection.end_datetime: msg = f"Dataset {dataset_id} does not have a defined date range" raise ValueError(msg) - start_date, end_date = bound_date_range( + start_date, end_date = _bound_date_range( start_date, end_date, collection.begin_datetime.date(), collection.end_datetime.date(), ) - dates = diff_zarr(start_date, end_date, zarr_store, data_var) + dates = _diff_zarr(start_date, end_date, zarr_store, data_var) logger.debug("Missing dates for variable '%s': %s", data_var, dates) return dates -def prepare_requests( - client: Client, - dataset_id: str, - start_date: date, - end_date: date, - variable: str, - area: list[int], - zarr_store: Path, -) -> list[Request]: - """Prepare requests for data retrieval from the CDS API. - - This function checks the available dates in the Zarr store and prepares - requests for the missing dates. - - Args: - client: The CDS API client. - dataset_id: ID of the CDS dataset (e.g. "reanalysis-era5-land"). - start_date: Start date for data synchronization. - end_date: End date for data synchronization. - variable: The variable to synchronize (e.g. "2m_temperature"). - area: The geographical area to synchronize (north, west, south, east). - zarr_store: The Zarr store to update or create. - - Returns: - A list of requests to be submitted to the CDS API. - - """ - variables = _get_variables() - if variable not in variables: - msg = f"Variable '{variable}' not supported" - raise ValueError(msg) - - dates = get_missing_dates( - client=client, - dataset_id=dataset_id, - start_date=start_date, - end_date=end_date, - zarr_store=zarr_store, - data_var=variables[variable]["short_name"], - ) - - requests = build_requests( - dates=dates, - variable=variable, - time=variables[variable]["time"], - area=area, - data_format="grib", - download_format="unarchived", - ) - - max_requests = 100 - if len(requests) > max_requests: - msg = f"Too many data requests ({len(requests)}), max is {max_requests}" - logger.error(msg) - raise ValueError(msg) - - return requests - - -def retrieve_requests( - client: Client, - dataset_id: str, - requests: list[Request], +def grib_to_zarr( src_dir: Path, - wait: int = 30, + zarr_store: Path, + data_var: str, ) -> None: - """Retrieve the results of the submitted requests. + """Move data in multiple GRIB files to a zarr store. + + The function processes all GRIB files in the source directory and moves the data + to the specified Zarr store (creating or appending as necessary). Args: - client: The CDS API client. - dataset_id: The ID of the dataset to retrieve. - requests: The list of requests to retrieve. - src_dir: The directory containing the source data files. - wait: Time in seconds to wait between checking for completed requests. + src_dir: Directory containing the GRIB files. + zarr_store: Path to the zarr store to create or update. + data_var: Short name of the variable to process (e.g. "t2m", "tp", "swvl1"). """ - logger.debug("Submitting %s requests", len(requests)) - remotes = submit_requests( - client=client, - collection_id=dataset_id, - requests=requests, - ) - while remotes: - remotes = retrieve_remotes(remotes, src_dir) - sleep(wait) + for fp in src_dir.glob("*.grib"): + logger.info("Processing GRIB file %s", fp.name) + ds = xr.open_dataset(fp, engine="cfgrib", decode_timedelta=False) + ds = ds.assign_coords( + { + "latitude": np.round(ds.latitude.values, 1), + "longitude": np.round(ds.longitude.values, 1), + }, + ) + ds = _drop_incomplete_days(ds, data_var=data_var) + ds = _flatten_time_dimension(ds) + if not zarr_store.exists(): + _create_zarr(ds, zarr_store) + else: + _append_zarr(ds, zarr_store, data_var) + _consolidate_zarr(zarr_store) + _validate_zarr(zarr_store) diff --git a/tests/era5/test_extract.py b/tests/era5/test_extract.py index 0d080fd2..d68e683a 100644 --- a/tests/era5/test_extract.py +++ b/tests/era5/test_extract.py @@ -15,13 +15,13 @@ Client, Remote, Request, - bound_date_range, + _bound_date_range, + _get_temporal_chunks, + _submit_requests, get_date_range, - get_temporal_chunks, grib_to_zarr, prepare_requests, retrieve_requests, - submit_requests, ) @@ -138,7 +138,7 @@ def test_prepare_requests_with_existing_data(sample_zarr_store, mock_client): def test_submit_requests(mock_client, mock_request): remote = Mock(spec=Remote) mock_client.submit.return_value = remote - remotes = submit_requests( + remotes = _submit_requests( client=mock_client, collection_id="reanalysis-era5-land", requests=[mock_request, mock_request], @@ -211,7 +211,7 @@ def test_bound_date_range(): end = date(2025, 1, 3) collection_start = date(2024, 1, 1) collection_end = date(2024, 12, 31) - bounded_start, bounded_end = bound_date_range(start, end, collection_start, collection_end) + bounded_start, bounded_end = _bound_date_range(start, end, collection_start, collection_end) assert bounded_start == date(2024, 12, 27) assert bounded_end == date(2024, 12, 31) @@ -223,7 +223,7 @@ def test_get_temporal_chunks(): date(2024, 2, 15), date(2024, 3, 1), ] - result = get_temporal_chunks(dates) + result = _get_temporal_chunks(dates) # We expect 3 chunks: one per month assert len(result) == 3 From 2ecb7422af29b8a281521b110ef87fef5fc3d99d Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Wed, 8 Oct 2025 17:57:04 +0200 Subject: [PATCH 19/51] Update README --- openhexa/toolbox/era5/README.md | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/openhexa/toolbox/era5/README.md b/openhexa/toolbox/era5/README.md index 35693452..0ed324d0 100644 --- a/openhexa/toolbox/era5/README.md +++ b/openhexa/toolbox/era5/README.md @@ -74,20 +74,13 @@ requests = prepare_requests( raw_dir = Path("data/2m_temperature/raw") raw_dir.mkdir(parents=True, exist_ok=True) -# Submit data requests to the CDS API (max 100) -remotes = submit_requests( - client, - collection_id="reanalysis-era5-land", - requests=requests, -) - # Retrieve data requests when they are ready # This will download raw GRIB files to `raw_dir` retrieve_requests( client, dataset_id="reanalysis-era5-land", requests=requests, - src_dir=raw_dir, + dst_dir=raw_dir, ) # Convert raw GRIB data to Zarr format From f342fb32abdd6fab98ffff2c3cd7f5b98a28c6e3 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Thu, 9 Oct 2025 15:43:47 +0200 Subject: [PATCH 20/51] Rename src_dir to dst_dir --- openhexa/toolbox/era5/extract.py | 6 +++--- tests/era5/test_extract.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index 23f9fdf7..fa87dfcb 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -312,7 +312,7 @@ def retrieve_requests( client: Client, dataset_id: str, requests: list[Request], - src_dir: Path, + dst_dir: Path, wait: int = 30, ) -> None: """Retrieve the results of the submitted requests. @@ -321,7 +321,7 @@ def retrieve_requests( client: The CDS API client. dataset_id: The ID of the dataset to retrieve. requests: The list of requests to retrieve. - src_dir: The directory containing the source data files. + dst_dir: The directory containing the source data files. wait: Time in seconds to wait between checking for completed requests. """ @@ -332,7 +332,7 @@ def retrieve_requests( requests=requests, ) while remotes: - remotes = _retrieve_remotes(remotes, src_dir) + remotes = _retrieve_remotes(remotes, dst_dir) sleep(wait) diff --git a/tests/era5/test_extract.py b/tests/era5/test_extract.py index d68e683a..26438d5f 100644 --- a/tests/era5/test_extract.py +++ b/tests/era5/test_extract.py @@ -169,7 +169,7 @@ def test_retrieve_requests(mock_client, mock_request): client=mock_client, dataset_id="reanalysis-era5-land", requests=[mock_request, mock_request], - src_dir=Path(tmpdir), + dst_dir=Path(tmpdir), wait=0, ) # We expect 2 grib files to have been downloaded From dc4acdb146d09966a5b5556d39e943f1b74a5cd9 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Tue, 14 Oct 2025 12:44:34 +0200 Subject: [PATCH 21/51] Don't specify zarr format when consolidating metadata --- openhexa/toolbox/era5/extract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index fa87dfcb..65f5683f 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -481,7 +481,7 @@ def _consolidate_zarr(zarr_store: Path) -> None: shutil.rmtree(zarr_store) shutil.move(tmp_zarr_store, zarr_store) else: - zarr.consolidate_metadata(zarr_store, zarr_format=2) + zarr.consolidate_metadata(zarr_store) def _validate_zarr(zarr_store: Path) -> None: From 2a008510a8c41bb094428b94844dff11f43bcd40 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Tue, 14 Oct 2025 13:31:43 +0200 Subject: [PATCH 22/51] Support GRIB files without step dimension --- openhexa/toolbox/era5/extract.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index 65f5683f..f2dbd378 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -387,6 +387,8 @@ def _drop_incomplete_days(ds: xr.Dataset, data_var: str) -> xr.Dataset: Returns: The xarray dataset with incomplete days removed. """ + if "step" not in ds.dims: + return ds complete_times = ~ds[data_var].isnull().any(dim=["step", "latitude", "longitude"]) return ds.sel(time=complete_times) @@ -408,6 +410,10 @@ def _flatten_time_dimension(ds: xr.Dataset) -> xr.Dataset: The flattened xarray dataset. """ + if "step" not in ds.dims: + ds = ds.drop_vars(["number", "surface"], errors="ignore") + return ds + valid_times = ds.valid_time.values.flatten() ds = ds.stack(new_time=("time", "step")) ds = ds.reset_index("new_time", drop=True) From 0983303779a8e4bdcadfbef936643aa849a1aa2d Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Tue, 14 Oct 2025 14:23:36 +0200 Subject: [PATCH 23/51] Calculate relative humidity --- openhexa/toolbox/era5/transform.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/openhexa/toolbox/era5/transform.py b/openhexa/toolbox/era5/transform.py index 6dfa8ef7..c8349223 100644 --- a/openhexa/toolbox/era5/transform.py +++ b/openhexa/toolbox/era5/transform.py @@ -211,3 +211,28 @@ def aggregate_in_time( raise ValueError(msg) return df.select(["boundary", "period", "value"]).sort(["boundary", "period"]) + + +def calculate_relative_humidity(t2m: xr.DataArray, d2m: xr.DataArray) -> xr.DataArray: + """Calculate relative humidity from 2m temperature and 2m dewpoint temperature. + + Uses Magnus formula to calculate RH from t2m and d2m. + + Args: + t2m: 2m temperature in Kelvin. + d2m: 2m dewpoint temperature in Kelvin. + + Returns: + Relative humidity in percentage. + """ + t2m_c = t2m - 273.15 + d2m_c = d2m - 273.15 + + a = 17.1 # temperature coefficient + b = 235.0 # temperature offset (°C) + base_pressure = 6.1078 + vapor_pressure = base_pressure * np.exp(a * d2m_c / (b + d2m_c)) + sat_vapor_pressure = base_pressure * np.exp(a * t2m_c / (b + t2m_c)) + rh = vapor_pressure / sat_vapor_pressure + rh = rh.clip(0, 1) + return xr.DataArray(rh * 100, dims=t2m.dims, coords=t2m.coords, name="rh", attrs={"units": "%"}) From 621d5c13fba2814d9e6cd887762461be1de3a350 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Wed, 15 Oct 2025 10:36:48 +0200 Subject: [PATCH 24/51] Add accumulated vs. instantaneous in variables.toml --- openhexa/toolbox/era5/data/variables.toml | 11 +++++++++++ openhexa/toolbox/era5/extract.py | 1 + 2 files changed, 12 insertions(+) diff --git a/openhexa/toolbox/era5/data/variables.toml b/openhexa/toolbox/era5/data/variables.toml index 9f04eca5..865aac43 100644 --- a/openhexa/toolbox/era5/data/variables.toml +++ b/openhexa/toolbox/era5/data/variables.toml @@ -6,63 +6,74 @@ # - time: A list of hours (HH:MM) to fetch for daily aggregation. # - Accumulated variables (e.g., precipitation) are fetched at "00:00". # - Instantaneous variables are sampled at four hours. +# - accumulated: Whether the variable is accumulated (True) or instantaneous (False). [10m_u_component_of_wind] name = "10m_u_component_of_wind" short_name = "u10" unit = "m s**-1" time = ["01:00", "07:00", "13:00", "19:00"] +accumulated = false [10m_v_component_of_wind] name = "10m_v_component_of_wind" short_name = "v10" unit = "m s**-1" time = ["01:00", "07:00", "13:00", "19:00"] +accumulated = false [2m_dewpoint_temperature] name = "2m_dewpoint_temperature" short_name = "d2m" unit = "K" time = ["01:00", "07:00", "13:00", "19:00"] +accumulated = false [2m_temperature] name = "2m_temperature" short_name = "t2m" unit = "K" time = ["01:00", "07:00", "13:00", "19:00"] +accumulated = false [runoff] name = "runoff" short_name = "ro" unit = "m" time = ["00:00"] +accumulated = true [soil_temperature_level_1] name = "soil_temperature_level_1" short_name = "stl1" unit = "K" time = ["01:00", "07:00", "13:00", "19:00"] +accumulated = false [volumetric_soil_water_layer_1] name = "volumetric_soil_water_layer_1" short_name = "swvl1" unit = "m**3 m**-3" time = ["01:00", "07:00", "13:00", "19:00"] +accumulated = false [volumetric_soil_water_layer_2] name = "volumetric_soil_water_layer_2" short_name = "swvl2" unit = "m**3 m**-3" time = ["01:00", "07:00", "13:00", "19:00"] +accumulated = false [total_precipitation] name = "total_precipitation" short_name = "tp" unit = "m" time = ["00:00"] +accumulated = true [total_evaporation] name = "total_evaporation" short_name = "e" unit = "m" time = ["00:00"] +accumulated = true \ No newline at end of file diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index f2dbd378..c9629ed2 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -33,6 +33,7 @@ class Variable(TypedDict): short_name: str unit: str time: list[str] + accumulated: bool class Request(TypedDict): From 6226c286e1802b9c8cbb155254f6a5f4287616f4 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Wed, 15 Oct 2025 10:47:25 +0200 Subject: [PATCH 25/51] Calculate relative humidity and wind speed --- openhexa/toolbox/era5/transform.py | 41 +++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/openhexa/toolbox/era5/transform.py b/openhexa/toolbox/era5/transform.py index c8349223..edabc091 100644 --- a/openhexa/toolbox/era5/transform.py +++ b/openhexa/toolbox/era5/transform.py @@ -75,7 +75,7 @@ def create_masks(gdf: gpd.GeoDataFrame, id_column: str, ds: xr.Dataset) -> xr.Da def aggregate_in_space( ds: xr.Dataset, masks: xr.DataArray, - variable: str, + data_var: str, agg: Literal["mean", "sum", "min", "max"], ) -> pl.DataFrame: """Perform spatial aggregation on the dataset using the provided masks. @@ -85,7 +85,7 @@ def aggregate_in_space( 'latitude' and 'longitude' coordinates, and daily data. masks: An xarray DataArray containing the masks for spatial aggregation, as returned by the `create_masks()` function. - variable: Name of the variable to aggregate (e.g. "t2m") + data_var: Name of the variable to aggregate in input dataset (e.g. "t2m") agg: Spatial aggregation method (one of "mean", "sum", "min", "max"). Returns: @@ -98,13 +98,13 @@ def aggregate_in_space( ValueError: If an unsupported aggregation method is specified. """ - if variable not in ds.data_vars: - msg = f"Variable '{variable}' not found in dataset" + if data_var not in ds.data_vars: + msg = f"Variable '{data_var}' not found in dataset" raise ValueError(msg) if "step" in ds.dims: msg = "Dataset still contains 'step' dimension. Please aggregate to daily data first." raise ValueError(msg) - da = ds[variable] + da = ds[data_var] area_weights = np.cos(np.deg2rad(ds.latitude)) results: list[xr.DataArray] = [] for boundary in masks.boundary: @@ -213,7 +213,7 @@ def aggregate_in_time( return df.select(["boundary", "period", "value"]).sort(["boundary", "period"]) -def calculate_relative_humidity(t2m: xr.DataArray, d2m: xr.DataArray) -> xr.DataArray: +def calculate_relative_humidity(t2m: xr.DataArray, d2m: xr.DataArray) -> xr.Dataset: """Calculate relative humidity from 2m temperature and 2m dewpoint temperature. Uses Magnus formula to calculate RH from t2m and d2m. @@ -235,4 +235,31 @@ def calculate_relative_humidity(t2m: xr.DataArray, d2m: xr.DataArray) -> xr.Data sat_vapor_pressure = base_pressure * np.exp(a * t2m_c / (b + t2m_c)) rh = vapor_pressure / sat_vapor_pressure rh = rh.clip(0, 1) - return xr.DataArray(rh * 100, dims=t2m.dims, coords=t2m.coords, name="rh", attrs={"units": "%"}) + rh_da = xr.DataArray( + rh * 100, + dims=t2m.dims, + coords=t2m.coords, + attrs={"units": "%"}, + ) + return xr.Dataset({"rh": rh_da}) + + +def calculate_wind_speed(u10: xr.DataArray, v10: xr.DataArray) -> xr.Dataset: + """Calculate wind speed from u10 and v10 components. + + Args: + u10: U component of wind at 10m in m/s. + v10: V component of wind at 10m in m/s. + + Returns: + Wind speed in m/s. + """ + wind_speed = np.sqrt(u10**2 + v10**2) + wind_speed_da = xr.DataArray( + wind_speed, + dims=u10.dims, + coords=u10.coords, + name="ws", + attrs={"units": "m/s"}, + ) + return xr.Dataset({"ws": wind_speed_da}) From 5f2b6e60af09bb50d5cc4559b5f4fbfcb283de78 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Wed, 15 Oct 2025 10:54:49 +0200 Subject: [PATCH 26/51] Add tests for relative humidity and wind speeds --- tests/era5/test_transform.py | 82 +++++++++++++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 5 deletions(-) diff --git a/tests/era5/test_transform.py b/tests/era5/test_transform.py index a90bbb36..615e7d96 100644 --- a/tests/era5/test_transform.py +++ b/tests/era5/test_transform.py @@ -10,7 +10,14 @@ import pytest import xarray as xr -from openhexa.toolbox.era5.transform import Period, aggregate_in_space, aggregate_in_time, create_masks +from openhexa.toolbox.era5.transform import ( + Period, + aggregate_in_space, + aggregate_in_time, + calculate_relative_humidity, + calculate_wind_speed, + create_masks, +) @pytest.fixture @@ -47,7 +54,7 @@ def sample_masks(sample_boundaries, sample_dataset) -> xr.DataArray: def test_aggregate_in_space(sample_dataset, sample_masks): ds = sample_dataset.mean(dim="step") - df = aggregate_in_space(ds=ds, masks=sample_masks, variable="t2m", agg="mean") + df = aggregate_in_space(ds=ds, masks=sample_masks, data_var="t2m", agg="mean") # We have 4 boundaries and 9 days in the sample data, so shape should be 9*4=36 rows # and 3 columns (boundary, time, value) @@ -64,11 +71,11 @@ def test_aggregate_in_space(sample_dataset, sample_masks): # The following aggregation methods do not make sense for 2m_temperature, # but values should match expected results nonetheless - df = aggregate_in_space(ds=ds, masks=sample_masks, variable="t2m", agg="sum") + df = aggregate_in_space(ds=ds, masks=sample_masks, data_var="t2m", agg="sum") assert df.shape == (36, 3) assert pytest.approx(df["value"].min(), 0.1) == 11589.94 assert pytest.approx(df["value"].max(), 0.1) == 68461.84 - df = aggregate_in_space(ds=ds, masks=sample_masks, variable="t2m", agg="max") + df = aggregate_in_space(ds=ds, masks=sample_masks, data_var="t2m", agg="max") assert df.shape == (36, 3) assert pytest.approx(df["value"].min(), 0.1) == 305.08 assert pytest.approx(df["value"].max(), 0.1) == 308.07 @@ -76,7 +83,7 @@ def test_aggregate_in_space(sample_dataset, sample_masks): def test_aggregate_in_time(sample_masks, sample_dataset): ds = sample_dataset.mean(dim="step") - df = aggregate_in_space(ds=ds, masks=sample_masks, variable="t2m", agg="mean") + df = aggregate_in_space(ds=ds, masks=sample_masks, data_var="t2m", agg="mean") weekly = aggregate_in_time(df, Period.WEEK, agg="mean") # 4 boundaries * 2 weeks = 8 rows @@ -93,3 +100,68 @@ def test_aggregate_in_time(sample_masks, sample_dataset): monthly = aggregate_in_time(df, Period.MONTH, agg="mean") assert monthly.shape[0] == 8 # 4 boundaries * 2 months assert "202503" in monthly["period"].to_list() + + +def test_calculate_relative_humidty(): + t2m = xr.DataArray( + np.array([[[300.0, 305.0]], [[310.0, 290.0]]]), + dims=["time", "latitude", "longitude"], + coords={ + "time": ["2025-01-01", "2025-01-02"], + "latitude": [45.0], + "longitude": [10.0, 11.0], + }, + ) + + # when t2m == d2m, RH should be 100% + d2m = t2m.copy() + result = calculate_relative_humidity(t2m, d2m) + + assert "rh" in result.data_vars + assert result["rh"].dims == ("time", "latitude", "longitude") + assert result["rh"].attrs["units"] == "%" + assert np.allclose(result["rh"].values, 100.0, rtol=0.01) + + # RH should be between 0 and 100% with lower dewpoint + d2m_lower = t2m - 10.0 + result2 = calculate_relative_humidity(t2m, d2m_lower) + assert (result2["rh"] < 100.0).all() + assert (result2["rh"] > 0.0).all() + + # check that clipping works (dewpoint higher than temperature = invalid, should clip to 100%) + d2m_higher = t2m + 5.0 + result_clipped = calculate_relative_humidity(t2m, d2m_higher) + assert (result_clipped["rh"] <= 100.0).all() + + +def test_calculate_wind_speed(): + u10 = xr.DataArray( + np.array([[[0.0, 3.0]], [[4.0, 5.0]]]), + dims=["time", "latitude", "longitude"], + coords={ + "time": ["2025-01-01", "2025-01-02"], + "latitude": [45.0], + "longitude": [10.0, 11.0], + }, + ) + v10 = xr.DataArray( + np.array([[[0.0, 4.0]], [[0.0, 12.0]]]), + dims=["time", "latitude", "longitude"], + coords={ + "time": ["2025-01-01", "2025-01-02"], + "latitude": [45.0], + "longitude": [10.0, 11.0], + }, + ) + + result = calculate_wind_speed(u10, v10) + + assert "ws" in result.data_vars + assert result["ws"].dims == ("time", "latitude", "longitude") + assert result["ws"].attrs["units"] == "m/s" + + expected = np.array([[[0.0, 5.0]], [[4.0, 13.0]]]) + assert np.allclose(result["ws"].values, expected, rtol=1e-10) + + # wind speed should always be non-negative + assert (result["ws"] >= 0).all() From 2600aa8ec43c7d21b5aa34ef896edfeea0165fe5 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Wed, 15 Oct 2025 19:26:33 +0200 Subject: [PATCH 27/51] Support single-day downloads --- openhexa/toolbox/era5/extract.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index c9629ed2..8b289624 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -411,10 +411,18 @@ def _flatten_time_dimension(ds: xr.Dataset) -> xr.Dataset: The flattened xarray dataset. """ + # Nothing to do if there is no step dimension (for example, because we only + # downloaded data for a single step per day). However we still want to drop + # unused dimensions. if "step" not in ds.dims: ds = ds.drop_vars(["number", "surface"], errors="ignore") return ds + # If data has been downloaded for a single day, time dimension might have been + # squeezed into a scalar. + if "time" in ds.coords and "time" not in ds.dims: + ds = ds.expand_dims("time") + valid_times = ds.valid_time.values.flatten() ds = ds.stack(new_time=("time", "step")) ds = ds.reset_index("new_time", drop=True) From 91058801f29454f5a250366ceeb9b8f560963690 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Wed, 15 Oct 2025 20:11:23 +0200 Subject: [PATCH 28/51] Sanitize dimensions and coordinates --- openhexa/toolbox/era5/extract.py | 61 +++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index 8b289624..2d7645a9 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -373,6 +373,46 @@ def _list_times_in_zarr(store: Path, data_var: str) -> npt.NDArray[np.datetime64 return ds[data_var].time.values +def _clean_dims_and_coords(ds: xr.Dataset) -> xr.Dataset: + """Expand time and step dimensions if needed. + + When data is downloaded for a single day (or a single step per day), + time and step dimensions can be squeezed into a coordinate instead. + In that case, we expand the coordinate into a dimension to ensure + compatibility with the subsequent processes. + + Args: + ds: The xarray dataset to process (loaded from GRIB file) + + Returns: + The xarray dataset with expanded dimensions if needed. + + Raises: + ValueError: If the dataset does not have a time or step dimension. + """ + if "time" in ds.coords and "time" not in ds.dims: + ds = ds.expand_dims("time") + if "step" in ds.coords and "step" not in ds.dims: + ds = ds.expand_dims("step") + if "time" not in ds.dims: + raise ValueError("Dataset does not have a time dimension") + if "step" not in ds.dims: + raise ValueError("Dataset does not have a step dimension") + + # Drop unused dimensions if they exist + ds = ds.drop_vars(["number", "surface"], errors="ignore") + + # Ensure latitude and longitude are rounded to 0.1 degree + ds = ds.assign_coords( + { + "latitude": np.round(ds.latitude.values, 1), + "longitude": np.round(ds.longitude.values, 1), + }, + ) + + return ds + + def _drop_incomplete_days(ds: xr.Dataset, data_var: str) -> xr.Dataset: """Drop days with incomplete data from the dataset. @@ -388,8 +428,6 @@ def _drop_incomplete_days(ds: xr.Dataset, data_var: str) -> xr.Dataset: Returns: The xarray dataset with incomplete days removed. """ - if "step" not in ds.dims: - return ds complete_times = ~ds[data_var].isnull().any(dim=["step", "latitude", "longitude"]) return ds.sel(time=complete_times) @@ -411,18 +449,6 @@ def _flatten_time_dimension(ds: xr.Dataset) -> xr.Dataset: The flattened xarray dataset. """ - # Nothing to do if there is no step dimension (for example, because we only - # downloaded data for a single step per day). However we still want to drop - # unused dimensions. - if "step" not in ds.dims: - ds = ds.drop_vars(["number", "surface"], errors="ignore") - return ds - - # If data has been downloaded for a single day, time dimension might have been - # squeezed into a scalar. - if "time" in ds.coords and "time" not in ds.dims: - ds = ds.expand_dims("time") - valid_times = ds.valid_time.values.flatten() ds = ds.stack(new_time=("time", "step")) ds = ds.reset_index("new_time", drop=True) @@ -609,12 +635,7 @@ def grib_to_zarr( for fp in src_dir.glob("*.grib"): logger.info("Processing GRIB file %s", fp.name) ds = xr.open_dataset(fp, engine="cfgrib", decode_timedelta=False) - ds = ds.assign_coords( - { - "latitude": np.round(ds.latitude.values, 1), - "longitude": np.round(ds.longitude.values, 1), - }, - ) + ds = _clean_dims_and_coords(ds) ds = _drop_incomplete_days(ds, data_var=data_var) ds = _flatten_time_dimension(ds) if not zarr_store.exists(): From 32510cfb9c082e116d77fa5fb46aff4ac3808462 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Wed, 15 Oct 2025 20:21:12 +0200 Subject: [PATCH 29/51] Don't try to remove unused dimensions in this function --- openhexa/toolbox/era5/extract.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index 2d7645a9..5e699a43 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -439,9 +439,6 @@ def _flatten_time_dimension(ds: xr.Dataset) -> xr.Dataset: step (n=n_hours) dimensions, we only have one (n=n_days*n_hours). This makes analysis easier. - NB: Unused dimensions (number, surface) are also dropped because they are not - relevant to the variables we currently support. - Args: ds: The xarray dataset to flatten. @@ -453,7 +450,7 @@ def _flatten_time_dimension(ds: xr.Dataset) -> xr.Dataset: ds = ds.stack(new_time=("time", "step")) ds = ds.reset_index("new_time", drop=True) ds = ds.assign_coords(new_time=valid_times) - ds = ds.drop_vars(["valid_time", "number", "surface"]) + ds = ds.drop_vars(["valid_time"]) ds = ds.rename({"new_time": "time"}) return ds From cb9a830c4ca2269eb2d61f301434e77868574a02 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 17 Oct 2025 09:40:42 +0200 Subject: [PATCH 30/51] Solve warning --- tests/era5/test_transform.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/era5/test_transform.py b/tests/era5/test_transform.py index 615e7d96..fd1f6862 100644 --- a/tests/era5/test_transform.py +++ b/tests/era5/test_transform.py @@ -32,7 +32,7 @@ def sample_dataset() -> xr.Dataset: with tempfile.TemporaryDirectory() as tmp_dir: with tarfile.open(archive, "r:gz") as tar: tar.extractall(path=tmp_dir, filter="data") - ds = xr.open_zarr(Path(tmp_dir) / "2m_temperature.zarr") + ds = xr.open_zarr(Path(tmp_dir) / "2m_temperature.zarr", decode_timedelta=False) ds.load() return ds From 2e7542ccc12f87eed6ea3ac1c87004a55e627feb Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 17 Oct 2025 13:02:52 +0200 Subject: [PATCH 31/51] Update docs --- docs/era5.md | 379 +++++++++++++++++++++++++++ docs/images/era5_boundary_raster.png | Bin 0 -> 4903 bytes docs/images/era5_boundary_vector.png | Bin 0 -> 36504 bytes docs/images/era5_t2m_lineplot.png | Bin 0 -> 36151 bytes docs/images/era5_t2m_raster.png | Bin 0 -> 33994 bytes 5 files changed, 379 insertions(+) create mode 100644 docs/era5.md create mode 100644 docs/images/era5_boundary_raster.png create mode 100644 docs/images/era5_boundary_vector.png create mode 100644 docs/images/era5_t2m_lineplot.png create mode 100644 docs/images/era5_t2m_raster.png diff --git a/docs/era5.md b/docs/era5.md new file mode 100644 index 00000000..c61f5dc1 --- /dev/null +++ b/docs/era5.md @@ -0,0 +1,379 @@ +# OpenHEXA Toolbox ERA5 + +Download and process ERA5-Land climate reanalysis data from the [Copernicus Climate Data +Store](https://www.google.com/url?sa=t&source=web&rct=j&opi=89978449&url=https://cds.climate.copernicus.eu/&ved=2ahUKEwi0x-Pl4aqQAxUnRKQEHftaGdAQFnoECBEQAQ&usg=AOvVaw1BwvwpB-Kja5hnXP6DTcbl) +(CDS). + +- [Overview](#overview) +- [Installation](#installation) +- [Supported variables](#supported-variables) +- [Usage](#usage) + - [Prepare and retrieve data requests](#prepare-and-retrieve-data-requests) + - [Move GRIB files into a Zarr store](#move-grib-files-into-a-zarr-store) + - [Read climate data from a Zarr store](#read-climate-data-from-a-zarr-store) + - [Aggregate climate data stored in a Zarr store](#aggregate-climate-data-stored-in-a-zarr-store) +- [Tests](#tests) + +## Overview + +The package provides tools to: +- Download ERA5-Land hourly data from ECMWF's Climate Data Store +- Convert GRIB files to analysis-ready Zarr format +- Perform spatial aggregation using geographic boundaries +- Aggregate data temporally across various periods (daily, weekly, monthly, yearly) +- Support DHIS2-compatible weekly periods (standard, Wednesday, Thursday, Saturday, Sunday weeks) + +## Installation + +With pip: + +```bash +pip install openhexa.toolbox[all] +# Or +pip install openhexa.toolbox[era5] +``` + +With uv: + +```bash +uv add openhexa.toolbox --extra all +# Or +uv add openhexa.toolbox --extra era5 +``` + +## Supported variables + +The module supports a subset of ERA5-Land variables commonly used in health: + +- 10m u-component of wind (`u10`) +- 10m v-component of wind (`v10`) +- 2m dewpoint temperature (`d2m`) +- 2m temperature (`t2m`) +- Runoff (`ro`) +- Soil temperature level 1 (`stl1`) +- Volumetric soil water layer 1 (`swvl1`) +- Volumetric soil water layer 2 (`swvl2`) +- Total precipitation (`tp`) +- Total evaporation (`e`) + +When fetching hourly data, we sample instantaneous variable at 4 daily steps: 01:00, +07:00, 13:00 and 19:00. For accumulated variables (e.g. total precipitation), we only +retrieve totals at the end of each day. + +See [variables.toml](/openhexa/toolbox/era5/data/variables.toml) for more details on +supported variables. + +## Usage + +### Prepare and retrieve data requests + +Download ERA5-Land data from the CDS API. You'll need to set up your CDS API credentials +first (see [CDS API setup](https://cds.climate.copernicus.eu/how-to-api)) and accept the +license of the dataset you want to download. + +```python +from datetime import date +from pathlib import Path +from ecmwf.datastores import Client +from openhexa.toolbox.era5.extract import prepare_requests, retrieve_requests +import os + +client = Client(url=os.getenv("CDS_API_URL"), key=os.getenv("CDS_API_KEY")) + +# Prepare the data requests that need to be submitted to the CDS +# If data already exists in the destination zarr store, it will not be requested again +# NB: At this point, no data is moved to the Zarr store - it is used to avoid +# downloading data we already have +requests = prepare_requests( + client=client, + dataset_id="reanalysis-era5-land", + start_date=date(2025, 3, 28), + end_date=date(2025, 4, 5), + variable="2m_temperature", + area=[10, -1, 8, 1], # [north, west, south, east] in degrees + zarr_store=Path("data/2m_temperature.zarr"), +) + +# Submit data requests and retrieve data in GRIB format as they are ready +# Depending on request size and server load, this may take a while +retrieve_requests( + client=client, + dataset_id="reanalysis-era5-land", + requests=requests, + dst_dir=Path("data/raw"), + wait=30, # Check every 30 seconds for completed requests +) +``` + +### Move GRIB files into a Zarr store + +Convert downloaded GRIB files into an analysis-ready Zarr store for efficient access. + +```python +from pathlib import Path +from openhexa.toolbox.era5.extract import grib_to_zarr + +grib_to_zarr( + src_dir=Path("data/raw"), + zarr_store=Path("data/2m_temperature.zarr"), + data_var="t2m", # Short name for 2m temperature +) +``` + +### Read climate data from a Zarr store + +Data is stored in [Zarr](https://zarr.dev/) stores for efficient storage and access of +climate variables as N-dimensional arrays. You can read data in Zarr stores using +[xarray](https://xarray.dev/). + +When opening a Zarr store, no data is loaded into memory yet. You can check the dataset +structure without loading the data. + +```python +import xarray as xr + +ds = xr.open_zarr("data/2m_temperature.zarr", consolidated=True) +print(ds) +``` +``` + Size: 7MB +Dimensions: (latitude: 71, longitude: 91, time: 284) +Coordinates: + * latitude (latitude) float64 568B 16.0 15.9 15.8 15.7 ... 9.3 9.2 9.1 9.0 + * longitude (longitude) float64 728B -6.0 -5.9 -5.8 -5.7 ... 2.7 2.8 2.9 3.0 + * time (time) datetime64[ns] 2kB 2024-10-01T01:00:00 ... 2024-12-10T1... +Data variables: + t2m (latitude, longitude, time) float32 7MB ... +Attributes: + Conventions: CF-1.7 + GRIB_centre: ecmf + GRIB_centreDescription: European Centre for Medium-Range Weather Forecasts + GRIB_edition: 1 + GRIB_subCentre: 0 + history: 2025-10-14T09:02 GRIB to CDM+CF via cfgrib-0.9.1... + institution: European Centre for Medium-Range Weather Forecasts +``` + +You can use real dates and coordinates to index the data. + +```python +import xarray as xr + +t2m = xr.open_zarr("data/2m_temperature.zarr", consolidated=True) +t2m_daily_mean = t2m.resample(time="1D").mean() +t2m_daily_mean.mean(dim=["latitude", "longitude"]).t2m.plot.line() +``` + +![ERA5 2m Temperature Daily Mean](/docs/images/era5_t2m_lineplot.png) + +### Aggregate climate data stored in a Zarr store + +Aggregate hourly climate data by administrative boundaries and time periods. + +```python +from pathlib import Path +import geopandas as gpd +import xarray as xr +from openhexa.toolbox.era5.transform import ( + create_masks, + aggregate_in_space, + aggregate_in_time, + Period, +) + +t2m = xr.open_zarr("./2m_temperature.zarr", consolidated=True, decode_timedelta=False) +``` + +For instantaneous variables (e.g. 2m temperature, soil moisture...), hourly data should +be aggregated to daily 1st. In ERA5-Land data, data is structured along 2 temporal +dimensions: `time` and `step`. To aggregate hourly data to daily, you need to average over +the `step` dimension: + +```python +t2m_daily = t2m.mean(dim="step") + +# or to compute daily extremes +t2m_daily_max = t2m.max(dim="step") +t2m_daily_min = t2m.min(dim="step") +``` + +```python +import matplotlib.pyplot as plt + +plt.imshow( + t2m_daily.sel(time="2024-10-04").t2m, + cmap="coolwarm", +) +plt.colorbar(label="Temperature (°C)", shrink=0.8) +plt.axis("off") +``` +![2m temperature raster](/docs/images/era5_t2m_raster.png) + +The module provides helper functions to help you perform spatial aggregation on gridded +ERA5 data. Use the `create_masks()` function to create raster masks from vector +boundaries. Raster masks uses the same grid as the ERA5 dataset. + +```python +import geopandas as gpd +from openhexa.toolbox.era5.transform import create_masks + +# Boundaries geographic file should use EPSG:4326 coordinate reference system (lat/lon) +boundaries = gpd.read_file("boundaries.gpkg") + +masks = create_masks( + gdf=boundaries, + id_column="district_id", # Column in the GeoDataFrame with unique boundary IDs + ds=t2m_daily, +) +``` + +Example of raster mask for 1 vector boundary: + +![Boundary vector](/docs/images/era5_boundary_vector.png) +![Boundary raster mask](/docs/images/era5_boundary_raster.png) + +You can now aggregate daily gridded ERA5 data in space and time: + +```python +from openhexa.toolbox.era5.transform import aggregate_in_space, aggregate_in_time, Period + +# convert from Kelvin to Celsius +t2m_daily = t2m_daily - 273.15 + +t2m_agg = aggregate_in_space( + ds=t2m_daily, + masks=masks, + variable="t2m", + agg="mean", +) +print(t2m_agg) +``` + +``` +shape: (4_970, 3) +┌─────────────┬────────────┬───────────┐ +│ boundary ┆ time ┆ value │ +│ --- ┆ --- ┆ --- │ +│ str ┆ date ┆ f64 │ +╞═════════════╪════════════╪═══════════╡ +│ mPenE8ZIBFC ┆ 2024-10-01 ┆ 26.534632 │ +│ mPenE8ZIBFC ┆ 2024-10-02 ┆ 25.860088 │ +│ mPenE8ZIBFC ┆ 2024-10-03 ┆ 26.068018 │ +│ mPenE8ZIBFC ┆ 2024-10-04 ┆ 26.103462 │ +│ mPenE8ZIBFC ┆ 2024-10-05 ┆ 24.362678 │ +│ … ┆ … ┆ … │ +│ eKYyXbBdvmB ┆ 2024-12-06 ┆ 25.130324 │ +│ eKYyXbBdvmB ┆ 2024-12-07 ┆ 24.946449 │ +│ eKYyXbBdvmB ┆ 2024-12-08 ┆ 24.840832 │ +│ eKYyXbBdvmB ┆ 2024-12-09 ┆ 25.242334 │ +│ eKYyXbBdvmB ┆ 2024-12-10 ┆ 26.697817 │ +└─────────────┴────────────┴───────────┘ +``` + +Likewise, to aggregate in time (e.g. weekly averages): + +```python +t2m_weekly = aggregate_in_time( + dataframe=t2m_agg, + period=Period.WEEK, + agg="mean", +) +print(t2m_weekly) +``` + +``` +shape: (770, 3) +┌─────────────┬─────────┬───────────┐ +│ boundary ┆ period ┆ value │ +│ --- ┆ --- ┆ --- │ +│ str ┆ str ┆ f64 │ +╞═════════════╪═════════╪═══════════╡ +│ AKVCJJ2TKSi ┆ 2024W40 ┆ 27.33611 │ +│ AKVCJJ2TKSi ┆ 2024W41 ┆ 27.011093 │ +│ AKVCJJ2TKSi ┆ 2024W42 ┆ 27.905081 │ +│ AKVCJJ2TKSi ┆ 2024W43 ┆ 28.239824 │ +│ AKVCJJ2TKSi ┆ 2024W44 ┆ 27.34595 │ +│ … ┆ … ┆ … │ +│ yhs1ecKsLOc ┆ 2024W46 ┆ 27.711391 │ +│ yhs1ecKsLOc ┆ 2024W47 ┆ 26.394333 │ +│ yhs1ecKsLOc ┆ 2024W48 ┆ 24.863514 │ +│ yhs1ecKsLOc ┆ 2024W49 ┆ 24.714464 │ +│ yhs1ecKsLOc ┆ 2024W50 ┆ 24.923738 │ +└─────────────┴─────────┴───────────┘ +``` + +Or per week starting on Sundays: + +``` python +t2m_sunday_week = aggregate_in_time( + dataframe=t2m_agg, + period=Period.WEEK_SUNDAY, + agg="mean", +) +print(t2m_sunday_week) +``` + +``` +shape: (770, 3) +┌─────────────┬────────────┬───────────┐ +│ boundary ┆ period ┆ value │ +│ --- ┆ --- ┆ --- │ +│ str ┆ str ┆ f64 │ +╞═════════════╪════════════╪═══════════╡ +│ AKVCJJ2TKSi ┆ 2024SunW40 ┆ 27.898345 │ +│ AKVCJJ2TKSi ┆ 2024SunW41 ┆ 26.483939 │ +│ AKVCJJ2TKSi ┆ 2024SunW42 ┆ 27.9347 │ +│ AKVCJJ2TKSi ┆ 2024SunW43 ┆ 28.291441 │ +│ AKVCJJ2TKSi ┆ 2024SunW44 ┆ 27.510819 │ +│ … ┆ … ┆ … │ +│ yhs1ecKsLOc ┆ 2024SunW46 ┆ 27.691862 │ +│ yhs1ecKsLOc ┆ 2024SunW47 ┆ 26.316256 │ +│ yhs1ecKsLOc ┆ 2024SunW48 ┆ 25.249807 │ +│ yhs1ecKsLOc ┆ 2024SunW49 ┆ 24.751227 │ +│ yhs1ecKsLOc ┆ 2024SunW50 ┆ 24.542277 │ +└─────────────┴────────────┴───────────┘ +``` + +Or per month: + +``` python +t2m_monthly = aggregate_in_time( + dataframe=t2m_agg, + period=Period.MONTH, + agg="mean", +) +print(t2m_monthly) +``` + +``` +shape: (210, 3) +┌─────────────┬────────┬───────────┐ +│ boundary ┆ period ┆ value │ +│ --- ┆ --- ┆ --- │ +│ str ┆ str ┆ f64 │ +╞═════════════╪════════╪═══════════╡ +│ AKVCJJ2TKSi ┆ 202410 ┆ 27.615368 │ +│ AKVCJJ2TKSi ┆ 202411 ┆ 26.527692 │ +│ AKVCJJ2TKSi ┆ 202412 ┆ 25.080745 │ +│ AVb6wBstPAo ┆ 202410 ┆ 29.747595 │ +│ AVb6wBstPAo ┆ 202411 ┆ 26.137431 │ +│ … ┆ … ┆ … │ +│ vQ6AJUeqBpc ┆ 202411 ┆ 25.915338 │ +│ vQ6AJUeqBpc ┆ 202412 ┆ 23.130632 │ +│ yhs1ecKsLOc ┆ 202410 ┆ 29.050539 │ +│ yhs1ecKsLOc ┆ 202411 ┆ 26.628291 │ +│ yhs1ecKsLOc ┆ 202412 ┆ 24.688542 │ +└─────────────┴────────┴───────────┘ +``` + +Note that the period column uses DHIS2 format (e.g. `2024W40` for week 40 of 2024). + +## Tests + +The module uses Pytest. To run tests, install development dependencies and execute +Pytest in the virtual environment. + +```bash +uv sync --dev +uv run pytest tests/era5/* +``` \ No newline at end of file diff --git a/docs/images/era5_boundary_raster.png b/docs/images/era5_boundary_raster.png new file mode 100644 index 0000000000000000000000000000000000000000..1c22784c363382f24569d42040c0c19d93fb393b GIT binary patch literal 4903 zcmeHL`&(1z9X~lc&I27?FbEqOiM1nCZf@mKiI|*nF;GgvMd}3vrxlPo1LGDjS6iWq zBxTR~plc*(o(am`rzoYEq&gPVBE*0xh>{Y}073{xh!B#!$@&j$`)yBt$jN!$^Zk6j z-}n1|zVGd9B7^SX?&}UgkVjl>bTS0tX2I_a3WJu+{D`l?AFd#o9tpKP+&Be6>wb!h z{$MW~H#__!`RIYSW0Gw4`fC+U4exv(E2*4_VVhI@Z!8^qd44#0>B)C?JP8vhS>EiG zzKW7!7(wxDZN42v@ud0h))ZPx6jBX=gkh@H4Gue__kd|^mB(NTARKXt1)i`X4O8)# zR>F7VV5D$V0iLM!r0}YqWs7or#=$-OAp={vvJ<=_6dcj``44V*(wD(?`eCEK5co47 zh24ZdkTC&vQy|J#h&7%htd1~XV)RM?ei)|Iu2LCv0VHH#s$H840RJa%8Q6;}JBcnr zR}NjWaaD?Ia&XOu|IM1BJG16o#O{r^gNCB2Xf#e>meFVojekClpfAxuw`I!H@Kjw$ z=6kl4v!b=~nk}B}%|iPT^IcjuzHe+_FsIPU+RER89o|>htGTP5n>t;5s=@T4g0jiX z5Jl?N{F|GkmIl%W2TV1qOS*C7G;%KD(r<=*cD3tNt%H?JCarctBVDCZt5{qZ*(zkl zby>8z^|$;>W*@&E*J#GseLYv1S>RhnI`DEfsjBqI_u|ZJKGIrV#Jzgjj3rF|+6~`; zP=7Zyp4oWGO3j$Lu#SZ3B+8yV{&E?&oaskh)|Q#3j^`g#h?)8{DJ1-`t~X_*q3fU4 zrhx%dDQ_28>ThG!_1~zMYJR-nD*vn)`Ff!?!$$3c|=i1l_paVMZw%4wQxhe)lHhI#OP$)93W0n4g4wKY|N2+_E`a{ysRsm7 zqPtgV*>XdKWw8x3&8ph`aIl|4CtR~h!n7A$zN}~RiEByokARm)d-}QcpdZqeuwwH3 z^v^BV|F;%Q7b?8+u~4QEQLQ!O%M|JfRw;~#P_GX6jl^$T2o`EoGDON!7D)z|OS)XNsU$(Zf}KwH)Ajhn26`CK9`XG)KtAGv#V-1kekv$we= z%U1H0dSClahk6!{9+J$@6rHrjb+7>GhB0L_*jGl^g(O?dWS@xDi{o=VKU(v2C4!EI zCJt`p7t&M}>nmqwENjX9cQM^C2!4SsWB0r+;_Q6}DjEz&?pHKQ@z7_|kd{{c+F-5( z62^gCyphFSxIQ%WgS|g|d8;GJMvT`~N#NanSVgw|QP$X$}4XL&quU3#Y@%=R5=cqFpRZH2t z1_WfS`@5Jx+V-9!>@c^Kj;_DGv4_($dq9+&^}XVv{5!4MZ%yPOdCx~G7Uvi7o}~H2 zI!2h_f!<9ld(@u{1!$SZeFGL{VsKx?uD-RrSKjN zYEgV=jMLCt=ogZlW3c?%gL@tldOB;svzp}{3}n3FeVRXZ*lCv&V;D(Q2e!#UsiZnxG}h1- zR)J{6*m4jKz6&z3NWWXM`zFUBfjv_i1U(NVFydGB%xoBUWGCQiKgdo%;$SX6jNPw~ z_W_F3OXMF2ln(u@s(%db4@ytZRP_s%-^RgFPQs{?h+8Kdg!Jh&KnUav2uJ5%UU3ix zT{n7?@TA1S*?$D+BF8pe?i7jmjagvlD94g+5o8l9-deS3qM~3sjQ9$#oFwn*%VH9J zKwG{1d>@#5{2frk$bl@yp>(<`d#afsP&PQBYFS2lH=xV>=5-M`n4CZjmhv0B!Qp8e zxlUhj^J9NdGvxviE8xjz;4D)BQ>fXW9&;JG(&G|^s}%l^W(tXsZA#bzCN_L5o*lpQ z^Y@*%O2=_TTEYvkMB~4}5178l&O?EP1Y99$3gxljLI=eW#q3{JI4+j{6H_4{n(PiY V=N^_Mf}1ZWE`|}^5_$OazW_t05hMTr literal 0 HcmV?d00001 diff --git a/docs/images/era5_boundary_vector.png b/docs/images/era5_boundary_vector.png new file mode 100644 index 0000000000000000000000000000000000000000..7d401620d7cd3a45674e719293810dbc314bec3b GIT binary patch literal 36504 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 literal 0 HcmV?d00001 diff --git a/docs/images/era5_t2m_lineplot.png b/docs/images/era5_t2m_lineplot.png new file mode 100644 index 0000000000000000000000000000000000000000..3f8fc37bf4fd4afdfd2b50e1518dcfd7080261f0 GIT binary patch literal 36151 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 literal 0 HcmV?d00001 diff --git a/docs/images/era5_t2m_raster.png b/docs/images/era5_t2m_raster.png new file mode 100644 index 0000000000000000000000000000000000000000..cb81daca2293557bf8cd097b36fb6ec6bb04fa11 GIT binary patch literal 33994 zcmZU)cQ_U9|3BW=l4ONYWUpjr@4d4!P8mnpdmg07CLDW386iSeha;O~oa{};u{j*` zaPYgm-kC)vx@K@#L74Xch z(OnPlaM?{)N%m4{-=oz_mmXhIk(YVteR(7Embd;e1@@{z>#bsi$%z-gs(u*l%)M-) zB>Ktrsx22CG_Or0t)NDyqob`OPk!B6Tl&7vXBn0j@+-klo;=yjn7S+d{(k<`;HOb_ z*pQDWXML)Deb&!fYjHn#XD4jNY_i?Z_-T)GJFLGf;kdw1y6nCA<%TQKhqr?TX7mR6 z2RXm|@3}@@bEhi}SA4JU-4u{Ve$qL0^@U36?AeZxCmS^t! zQ+v1ndkQ}4_y1lKf13?MyIWAaod3U<%&VWddhvQI6mNSBzuZG%X$E;;FPP6Nv=j}* zW(gVyRq+0Bf6HLW%s@znCMDn7taKZ5Yey;8D|1rgew8~o9XvBQlJ%PSZEhBAJ?&Ll z9))nqO~n`=PG|UNvGR6rc}DVWWw{(`teT0@L^iHs+aJ8BDoAx0KzN!?E@)b=XV*s> z{5^5BOwYn9PHy)|wAV%&uZYy&G%@!d8>_w?TX#1(rdzmi?~=n{!=xL{TUqLwVDI z_A%T@@?fp;;;W>~bOVwu+Ay9WxHf7@lIkAsJMj9({Qg3Z^ZiY>+HO2`w?%7Z z7mrngxAre#GUb*(SDywuL~Mn4z7jE=3!eoaQ66??X?Azsf7*$8UJv2^Z%|oxJDft& zYfdg}2>)V48Zsnp>s+}wS-PAwrS7csx}QA!D31!FSo8oRPnf^MgW+HdXiEKYcYZl* zoAu+--=#p7d8#b8TO?NZ8C+Co83TOQyX#VAVBjsM9*Eu3O4`17(`6)5H(jW&Pie#@ zzhn>ON`Y;xYoj$oVJywQ>YB5c!3|Li4`>CGq(M{&HqjyA(E)W9xPR~ccg;h}%ZT*H5s>!Gw#} z6z_XIa(h90GNooV^NrzAXnOf4?y}R+>Z^wA_QZSI95~4$w87D3=jC?(qC*FQ|NE|o6$7bC6bB~rPN!N& zH*MhHEIx+~!&HWh2!nXEC92|wc<5-Jy{45PgyU3w3=NqBZoT7T7({aO6_wYM_3x&q z1jVd8Y3Y4fe>n5XTvy8RrVN$- z++>DtF7gj{3&GBER+Qv-rtN|{?$1_vMeh#kI=x>P)O!k1p1j_sz0kS{kUbED`@Li$milHRLv}#VC>x z1~1g6@HT#)BOvhUtfvGC2P|KITx-AUV#v;`sL<|FnbofiHmA*Q^$t z>VUFpOeC$*l>|rSY1qT3Dm;0EzOo&y9c3SM`}1F-2c`j3W*xD?h3maRlkUZMM{ zK17EdnK-vT#8bkD5zlnD8D>?VJXlSdbeTu^e@)ItByfsd>hLho*tOrKF&lcIRYc=;aLu~!3u!7hzVeEC^ zRy4H^AC{lDIOA01Phmfrnsh};%C5FpLcGu<-{$>Bd|!4$_J`+RZ$J)BL<_{N-}WEI zW$yHHYE(Cjf7E+_D_bxuY=$vT4{qh8(T&-Ibu~id!u^ zO)}B_FL81Azv84}dt`tu>;X|Wo z^?XJ5X7xYbWOlIrrFGL68~Amy&|>fpMS94ObCO1XyM3zaGbylt^}5L8eYv9UvfuNn zEnXcuC}C$5udN%th_kF>GFK>Bm=R?XpW73c7TyjeE|Aw_e_nd0L;vsRD39o*+jM3s zyD$FMv~aw66y4@!m`e3j^xj}1<4&hO;d>{a!Q>6`_S>oVCM18dMjIQb;-l6h%>(|% z-?ZK}x@t$M7t5E-38N2_%l3wYL(hNM7R6aP;uAa76qQPipBEvwnle)=6RpvHl!)nV<-1!>|op~xsGNZV_CdD6vcJVIW#dDml z7}G$MZ3YUIRz=1~+YHS~_eYC2tB+8ea9mIqi&>^^*ipzrttxxt>iEfZXXC{mWpIe^ zj`fEPE)ERXYGd}mYpsT;K*kU5hB}sbudLUP8~tBvRis3X7|pD9b$EY%^7QL#zFW^0 ztS!urzy=UVAJ1S9#o#23Yp7v|bK9Ej;?f}_b4yTKp4qbvCdI5`_aX(CIBv1cACaWEmd(%6xSw%%fCXRK?PoG*X z^`x4f$sKvrD+C{lTu-tMJR5tj6JacX{6Hy`(>yjGP9ab!ZhwVyS*F;34c9}qP1IU4 z_oz_84C(FcM8o8mW9k-0=EY7o5!$W~v%4MAoXODmFHK^zmpw40w=I$ZI{p2O{i ziTO$Fs59#*ZW-Z27X}bGD>heIl{+i9Xj@d4<8Us}m7A#XCUFqt(xXn)q7^_@F12LK z2f6M%Fh>@c;_1*z2>LcV5-oOWv4)$9s#N=wc|w(S9W8)4pe;1m9WWlCeL4IQj;&Jz zkhT73UfPtoUDt=A?vB*_hGWfkRLba^S8u@(++}~At9E3~bV9Qr*I=hEWxLlA5v9r1 zxHnNeVmdnFs8-fc)MpgXmm!*ylQTX(ZftB^R*yr4vu35FrP-#va9{b`mnp`HX+$@1 zVEkr(z#%iou8iry6q3SZNL|T=t#mlkv({ztF5hl zd^)=|jkrFd;c|XQ&k-mY0{;X}D;(#JG|W0Eo-Q5NpeS(IBITI{yoY zxL8gG2NI?oIUVh3=g-z>8ylN`yTm6XXi4l3k!gy|Me+CDBqsJqI$Y^r=vQL!fPs>~ zcnz;7l2vL%c?1#-d?MW>^UTzYBd1vP9hRioN}E{pa%6P$=46dicM|vDzyK7T<=?MB zm6(_)_!xz_!N=G3+c|@?Z7tD}IQ^e$%_ps*FD|@`u4ewr%Q3fm^t*=EH?&OM^!sSc zcQjuA;f63(0j?uZ_N}lh=7r{X7*+Jnbqt1V<~3y)QR3NX&}wpOiZw|$cbR0*x5Djc zQ$0XqSjyxN5)*beAMRQGnQ}XFv8Qr_h(}=ZUiN%n68ENcb5yUrm7UIKoeUU}xxal% zO;r6chi9Z!4BYrH!0}mr&Dl}6QnM6Bq@&G`rqrz<##Zh6PcNkiK%Zt!9eDt2$x6}i3!itvn+G!DCVAMVYFM|X||yixy!MROiwLe{=w`x@yG1NiptC%X1OeA zW13lZ!!KgH*HlRc zazbz4xudsD_29vC`>~((_4Q1zy|xi-n5+JesQc2T&DpW?HmW{~JE}fpeZrC`CC_6& zoP+Qfcg5iB`Zw4t(w~Wu5x2XWkdSb4usQinB|#yQ@|J+`O|y&M0@^;yY$7f?1y?Lq zqt&pWi1@KJh15hz;X0Y9Or2#ap}l>QCmS(zVk7dfkE@mo!-oC`tUUV}>2*Y$bwh5B zLN>v6dt`!MI#=_bF6>#sT2wGmB%CY_mQe$B)cSKGJZ1rZEMFFjPKBTTdN`UK?OhH4 z@#XXbnYP2F-;dptQZW~BmCe<@fV^&sFK~$zO9tB*u$4OTY1Gg~)$Se%@fz z#%T8sc1w*H8AUYD5&2JZ$4tFP+SoKIVm@zSpVa40zJT{mo!XK_=3R$Dgy%ON-kH5t znd)4Lp?Tx0QeOb@cHyUo7g+XV#woma)kuuoeI$Kh&fZr6X_v#BU+Yvdb$7?FOqQx4 zDnCx=Iw49mQ4B8pG58n{HleHq%|zV2^+qLF+b zV=6$-cQk#2((Qy;6+c&cf2MVbraO>uW#Mf!Ci6X*8a;k5*fF#>F{AEvd<$EFV-6b0 zv={wD-@a&>%%%j;n|7RH&AZNsUaeSMQx_(Ky~G0dI(aVhpR7tv*v8a=zNjr(cQm~j zSVSJo#55*Jm7#4bp5$zOUoTF=P3@DoMrxM-qFKT2=Fba`vLF*;+6TRpt(U_$_$N|t z6HR9wRi-;9Q*5I}i~GgLaw(Vx9fOCB-$XbqOA@qD_g;PuK_3mt`V@`jHi}m{9oX6= zd(KsUBz)jE(MewBcv7pEysY+5-f!%s?W`%ipTAg)*?iRl?8r~p9o)c9QN%=HybIOs z9e{AGcgL!OH8<;+EAN=nkIub9?TLS`XvZ>Jz*hhP1wq>f`5^gxfVj7%e=@7_YvG z#&9C(zX2{;XUA1m4*Lwbs>#K09_WPH8X0%oIX`5%Xr&bCCKZV3=JzjKi(XzTWW5<8 zPmvC!n*k&9n?+KE775N+DOdX1VaHpCXLA4X4^GUa*zMY*hO&s>rDXcIoxfr8rF(_SxDU+ZZI!DOR5yQ`>}|gB&12zVu_kEwcU!o z%Dy#H9qSzPl!q6<7LF(4h13ZiopT=2HNHrSVv89Y45?n!<}1m>_L)MKIFmD}XdYhEs6K6|OU z2BSlNFcdq>Z{!cP??OJe>CS#zPv**S#tM;WSC=eD0x@Dks|R_>;@M+iH*4!q@zWM- zNWz)F+DxEhjmbb`F=WZhs50^Tcbe1t>2z&{Y=vt)>IsesM3qz#cKmsSdNdq&?@<>W zXr}IYgrw0kH&eE3g7h*$3H#FocXgO|R}U4)JMrvT@_B_D??`?*p^5X$MfGTEmz5KA zZZvBi=(C@zysq|&#z+OoP|)tYaMHKWfUrij>X(o2ZJbImwy|JxOhzSV+8o(QrYFKI zeE3D1O6E#M9STR$)J>Y7GsKI1J(ICw@&;OLiarw!sEX7vPHpmX@tFzIG3W%5F3UXB zF zynlUOBIJ9>bh(#>jc&=kyb?cG@s92C#9Y1_wn>MbbmX?*#q@f5Yfo5ikH55MBkTC( zq;@&qHbbv|uFy;zg9PSH}M`-J3a-Sb_oJaMS)OH>DsK-g^3uMc6fDXDMoW4pVU=6C&# zFW5$lZ>}l37E%n^w5Rjp9%hif!T*%ZY%}R#W%B|HbCGo>vtbakYwH1=SEZvd)NNu& zuvv=%Q4g|TlN6qvA12*?RQRY1LWLrvwC$FAqdT0@$n}zDxDS*X_-SIt#Ao31t zD@k=gkK^Ms*xxhC`Fd3`HB0x5Zzpcaqw6rYo8|ZXHQm-qDbm$zss|f5$mXti5Xqgr7D$F5P zOoL&Q(D7UQ>+)85RztS`;;LIRF?y=y>Mi?Q)Dz?i%Z_V(FP{WI;HK(8{wzJ@<38bHA@yKpWYSq?u;j>J}iu(6EMqvk)q@5S%|Rko*3OiK7?3=h9WvJ zT&ohw_jS1uj-%%%VW8ei#1$`dV2Eewlz>c`rJ}=Y&WR!J*o@JgIlT=QoGPZ+NTlx? zF3*ng+2`5MsjYveuHmSW`*#<~DymbYCHW9xh{eUlH0gi>U8KJWSbLv$T!Czb9p73# zJ&77ea$rkjUJe^>+0)OnYt<}$q5%ON-W~gU#4iQllzutst56Q17h_3i zu*gD}pHlR7gdi32Z)UiX%j1tfz-Bv7-CyvH)3G|Fja&ID-K0Oo3`oHd1*3DVb?g!4=`+ zBWBI*IsT_l)KL2M+cS(MkX=u!krBPeU%Ekq_POB|z1l(ZVbp5FY(z(ZRu zh{U8tX8wP5q#=U7WPDff@kmJ71wggPJi6!lQ4Susb(U*LUt!YccF2iYR|HKn#@%u; zO<-0LB^a_=PDp#%rr0yih@=OKlqpTrZ6#%5IK=k0AT*pq=t8}spiC|&WfQ7Hdo2{0 z7H6NpY82)1+bR_8%`7kf7FxK~ZBuELJcAY$t)@6oYPr)f+h1vONrs_=)Z7$5hX(~W z5z0$3yONd|j9TOEcF!Lvik3tjKiK2i*-*<%M+5$}QKXn=^sY7S!nd2Cn~M6S6)!Z2 zX*oZ>r4*?>NRE?mG8kdGrdMh~40tI?bi4$^1`yivWHpG>WGfXy?jJ$poZs{Uy+LNv zBxDrQ?k4aTm@x{wMvKNj$LG~L?Wp2rwsqe^1{ie!sUny``_j;kQ$rD?@JI(TF_OpDny z-$3qHDQzSjT7W6FWBuoG1N5d-k~9B~-KA+Wfuz|3D9H_+*o=?tMY&~$Wy!o?atA6T z=$`+?%3%SsMAiF&LymZVh6|TMzr4=wG{Nf~7y4ol_B+tHa%G?6rp~eQCGE0{&!KuCn<`kbpKpULc%_lY2qc5s{^P~UnW3-l$5sHr<$IPM z%?xg>#lA=-m$HHI<*iU;N%TCT^To3r$Sjtg}@|ELq-(5U2j zcf-1->XcpwtaPQuV}M8*eS=uOrM~M2@0r)h}M<-Y>;QV#f(bkO)rUvZpYDdCuFJO zZB%VWQsGCFBNSrt&{QC`G%+`U*t_DyO!#{XRgr84XvB ztr&XS$WmRbdl5-mf~zAH`uw6Ugo9vffdnpY)oAnVL|gTnTNzwg;o~1l$Z3>h>yE?w z=-RRiVLvE#HHRfXg+7a9#+j+NKR-dmqzp^uZ$b~$j~=PDT^J81B!emHzCj+(ckXiuFMv(KNtSJ+;qEmR1)5lQ$&YnRien{9v-Mq4d$- zswg!yqz_x8WO7HKmv(mN6)ub_UJ<>L`7?F^axMQ;b?!IfcxM(H%$}GZw=+VYF8a+E z`K!x#3s4(wty@7x8^^ycHq3aMoI^?SVE+(7*I4Jh6np15BDE+vF`SKb`S4lr7J@aa zVsq-`>sIzb|5jkf{xoN2c<`X&>_-;h`Q?+xHNG66q59BtI|Zy>*8Ql1zi8n{6gy&( zQ;~0~bdmY@^2^1q?lNs>8X^B}n&&YPMjfv^9qRsP7m|7d+>o#7((WgMc1>ixz!60Dj25UMD8O49LQwEPh$JY3!@Q9RXj#- z{-#f`+To97{bdnz-e1&fEe2FALD| z=PJ@-f;4!uYHPj~M?2$hyR3wsaA-kjv14|w-+v0^vbah(&MIkv%+~6@&kwj)X`(LD z06>iGG2Q>4+PQpqQsXo`gYd#iovv#2rVC49>vyvPj#uO3TbXd*gd)aXLjywMFTqQYUL0N~)@Id?nAyGa6km$#QUtKV!7dbH!*mU?ax6ZiJ@^`TI{ zm_$QO@txM8p&>XNUTTKuOXX(;f5gQJv4MmIGp}ju(Cz**eblHVB$`7=?1D<9rwvYP z&wkl!67e-FD^d1D{@ zXiPBB{$HNq47UHrqF5!84%)7@bLa{@vkT)h>SMu`-E+E(JD2&xHSGPxX3+N zB#|7*6~t?Xn0+H2SBOX4%=R(6*A2XZ0=vJI`2$lsePlc`@BUccFtIOq*weO9;V=-0 zOjqG`qnb;1c+~M=iJ!ZPfq1X(zXX9c>Ie zPwQ3I)Rf#G($f-|3+C?=VGePkCcb)E=3fbLiy9d?Elx^M8={qbT|JYZwe z@~RaJ(eGP@y+|d6CxPXDCE18dvP&}?!HJ<8VF14gbeTCfgkqybjRu1xl{#0W4Vpe1 zO+`x!bk*4;ZjmK#}l8DOu5ErbErGq1{E*du4A?%lh_h{hEnhe!(N101R+Cz81-(-mgjKsEK*Uj z|8uS@jNY6Z2m0@hI&hRe_d=0o*M72m*OQzh9zA7p_eB1p7Jf$IxvHe@()%RrO;k@; z+*7d!Ne_7_@;hE#@-ixZ)V5@#gLIljl9H0*@%XN;F8`AQhkF$@e1QV zvQ*g+!+K%a2SLRx&+}lOHQD^KG4jIvD!oUaCBAYZeRyAcN2|0YU+tE{Iz=_dY@227 zfN+bibHx3DyZsaRojZ?Gm|DG%-LfSXj_+?>8g!X)DKs6VN-w{`aYmV4rGHW;X&)u^ zIQX9?cNTIiDp4=eMl1x~3k=qSKwynr3$)T1ttOURG9 zHjELrV$n(0HJgcBqOQLKxj^f59>^RWz)bO7H^VdYgN^mD#$b$bwK zNHtr8GOBdv7EV=$u}^x#2Xg$!Zln?xquzD4R-8cR?o5AgjMV2!lix}*WPrX(6kP@( z7b`z`Jy8;~e|ks0pO1_DPcY=&ut%X} z*L~Z5h9aiAkd*#siWqp4C+9JNI1l#aBw80K!V^_ft9y1IWE8H_GkxuH1QOHnf*gLw zNk4$U)lp$#_5zQA;FSnbMLSL|Ys4s^!0#3I6MeVVLnG0Be*}fb!@~cI_-Ki}2-iJR zQpAU2UurVg*5rm`1?W+FAM@E7g~UEeNKP!)zmuxj1)lS1(npN;Z0vK<&$y6+}$)|QJ#QLEQ zhb0aBy&9=P{8DwyFxiJxZYQ-9&$jlZCtj;d3563;R7jFtvgk}KK7x|0ZM0L7EjbHN zR^FTc^2n5>J+n^JbL}K3wR0-Vc&8|9b1zc*m2GN zwZwmt$M5a!jY0xXf$>E+#Ri^(=v5IIza)}J{|q7i);_%@_<`q|bf*~ndiYu}0M(8y zcDWI~IjK~$D>ZDi@`%g-yj>Oj?mMG8;0Y{i2q_}w40EY|tV;^m51aWB!k zvz8B^5Zx#5;DwfcD3-4re_5^Z^eP)sxc{2vgwylKS}jbVR=YOA`o#)1<#6nitgL2f2oa5k| z3TKB@H@Pa7OvM{m(+tj=+(^+@CY2m4$hd~;Sy&R;Sp1_uWv$fwpsck-q_Frq5yZDG zDr3hi)kxh%AGufj=S$JH3XJ$y@h+y>g~B|19J~8E^ntZ<#B&)A8}*iloKU)A|002u zlqNx5la;qIo36|eb=^@U#_60Cgtqk3dbapn7BSyW8Lt4t+@sg5USIbOA8+>XTVxrl z@1d*p&y7;WP!G|4`E%RichrzNNA*;W%9hb+q zOY?^7d@~Fcl0ErjWs-DX8b5n}cF1qjSJJgb%?~APc{LW-P1*e~Rwh-bT5Q4ZMc&vF z@H?WnCoVCvee%cKrB2^3FzieNZ^PaI3Z*)a$jzM}P~-JKUM;BmyEQ6+dIuR4(k+f*vj$lV+*Uo)n1vTS?ZkESzI-W>1Nh{tW{hG> zB9^S#wvedJW8{kMGI&Z#Y$@t~Lp|_G2 zurI)scz4U#BuXbNwrlE5`wNA<DXLr3sCoc2PCVe8${5?;GYy-nH4x{}W@_ncHOl1cc@Tir)J zkxn!NK||UovG)cjo1+ohR#q#AcW>dd;>&{(+u@#*;L-O>J;1gIPlx5c%yc1#iN${q zkb4mG+R8{yrV^iDr>C&K0yg7Q+LLD&THv4iup-Ui45f&aE9uqTDZrSl>XL{V|0xjv6iyn;_uW=NO$@k6I>}#o(j}xZH zBJv=U4!d5K>j^j|Y0s_yc|(af{zo2Jx6^fJt{fldz^tt?Ut~U-`>y6`qzlDOgsy}0 zvA7{?`Ka^r(&Sr3wOgTU8bGf!WD#OzP4*S8EF0<=h1xu$R?{%Gf;2=tN_zquspb)z zF8*$BlEs_;&OARWC4M5QKE4k17%r*;Gz1N0l<_ z$eCq7 zFDu%)BiAu3$2AMmV@mWsiw%b}h{brd`iVz9u6eM>Nrot@JVgMK6LEcCOm{Obbj_=-AJiZG)m1v9LCo-mQt?*`D3E^q`pK5LOXq4#lI}uVg`o$@SscI5lBpOBUJGWl2 zB?s=(yIti;oRwIL1c^k2FU?4aH`Gpd1EiammAZ05(w{$ItKE%TOkjjTH?B$K@_Rny zU&wmYadW3RhYe&dderr+7me0&>6HF;cEfiO4zKpCCA5(SRt!zDd9bWE4Z1Tp>&Kma zjQu~EnIr!FbT+;qJ-cSl_x-6(6LDMAY=+-9OQcYt{#Bf=`O`un{&{e;@a)>B9At+0 zr%N>5{PnIdwa=yH^(njwF2&p6c0-mt*Bk0+VcNz|A!y!>Y)7uyXKWxWhW_y3irS*2 zH43hhz+x=)tXLokq}=kl`IQfKU2A)RPMIrX>7(>pr;SC7^<1fF%-&SIj?1=sT8Jl$ zAlHFAJ>IwL&s6~3Ln-DFH5pmuo0d-wlht?_a=g-}al84>MZ*#tHBErSt+X9o*1pvH z1o^^73^XO;K39d%z5ed8{grs;aIK9kNza+8@$S*rAY@?^p|cYxrIq}!_0)I&pt8v} zL{|?o3lt$K^h)Fpk}0zv_~;tzVadN(AW#XA?^whyjwf*rfwUo1{KTO=ep|td>8lf{$ZTs&8gwK$3_i)sl<>A5Tq5 z!*U@W^xlsJ4mH2A8%vq3h~ku-`M2Kzfr7o}$`#TlolDOEM%bgldL?Wnv|p&Yse$A3 zSFu{Ze+ZeU$MQ2{$4Q@TnI=!9U24&uxH=L)7Si)>_v63iT2by#*&^5;4eDFM6%j)WhW#|)>`8CNh?+7n>VT@ zEexKO6S5@y0K~Mnw+Hb?lWP0aq$E!(_-u_6#K>rDrT;5XLsvmzI8GTE7*G%cXW%sFLYDY`c3G)}Z_XU@~42G%#XhWo$lCMlbc-DqjU(R`$qCZZF+_7&lMU zMvAaSKH823!uh_jr_tBwyL^jd_c5UY1eh>HHykTrxo0?|5z;R}O4vgfMzwNx( z*ClOzsxr`h z*K$(?KKrs{!+|loom$(|RSQ)!Nep@%)!2>Tq*Xn30iDN=gbu zZh$DOktSgL>uvuzL;pmvVcEsG>jM0`ynOTCSzM>!Dd;1d{QQI1Lt51G@-pZwU4k`x zufUB!TSnh#%$*9|FegAxS!+j(B!xRh!5Jdx@7Az#*O;6Y;XQQE;*{v?{(ZQUCTQn* zFm3~s^nMutV7(L-GJTd(3o=|$jc5C1&CRmBFT}b7&vvs5^77(voCYN#%NcGPel9=2 zp~{wh6KYj((P+|BztV_en5gWv%-_@yIf)#W zoF&V$Lzf+o$`LKbbGx_M=Bf~s1y`%MRW|+xq8_-UzsS|G5y14#vq)enZvs;J z=i%Wz&oEBH+`6xQoBSTKIlOE4Ps)uM3ZQ2ZqFR^P_Pq2KGe4&v7fp03j z_1n#4TH<`ro|K4)h*yny^+1NWNET^PTVAuFoS=vB7>zPDW1Yx~5aN$AMi zWKwK)l)JgjYoR#5!>v=GEM;e9Z7FQOPP%A0ms{<)Aa;bl5c%qMT=erKv>6?e*`sRO z)zDDurS_{=Gt%9H7!FWH{bWP48GXDK~eaTkm%TqPtW1=Ay=o^o@R?O#lb`)xs&=Py9z z1x{UGt%SO0XINSok;qx3&hz}f$rFw(r zm zda`RsJh99kfKtTXwQxD~&74;aFh!b>~t!2kX8dr--j5s1w ztoIf1p%Q0YMpVV%Mkn6mZpXz#nh}*6jWDKil#90~tj6RZ4YRN46STp^_CL0eSTuGs zW-?M0uc&};yGR$sbG)1DqG*ruY~;$Dy7^d;&UT*kJEDFFmNTyN{%faW|7tDuWHtD| zU!vl5vcEomEoEbOow<+;PS0<|X%s9^8X*rTHMBz#{mOp7|LC9}2aHtxCZIMoZ0$VWbl@%;O4!BqBSlHqH^P7QkpYE=X{FpSh$rOD1_sgpw^L4;1q|4u^=aj<$ z25Nl8CD{lSNb>sNyEFYKw;e|;o`epIocf$-UlUc^>H8I?;T(wTV(oCHr;WxWRNe{6 zZ<790Tx&`}%j)TBp|Ae`vH%8cpI~0FJlNF&n6D{0cJkHfUTxumGJa}m3LK&ameYo$ zA<}NN)|jQeIe|XSSW@Z@N!z0UzMI*MTHdbLuRqcZQPej(8VU?X(-c1DRvy!^tgIWa zfV)QcZ&aeG!xJimBeNRw*{-Qx_SgG{OkFimidMMRW~#tF%-H6fC~Bb*s)3=>|Lt^@ zq}Bb3U0+|>CJ>QSU3f_h?G(|>wM$_)o3unI2RtP$~)IG{D(c8hN0!>R|6?Q z$^03%*?f)k?#6Sw!TjDbaIN{`RuEWN5E~rpl@5?;c8taVnk1OSs~$THt|vjbcEk+% zM&3=8!{olgz+EIF*!kg7x>F2|AO{l@lXvUMX+SjQ>MYS+VSywx%2@5Xbmw!LZaXgQ zPvj1}5c^KKoqwvQT6pY_8l$tHKniFFQUKwG>TYX|Zh)F9d7j%aNU0$KbLiS}>G2#| zW9{ewF*hI`am-Lx0M-EB?a1xDS5zRsfSo^}Nw0)vC-+8AuUkV8!M}Obr!-9BbseL^ zwV~D_Xact$@{LMjNvg3$KeSqJ=#M*(n;7G(Id08tDJO8~zDK0ob%<~xOD@0*Jy(N< zC)cHR&c=67ss9R9s16OIlC&d3irk?zzJ*p6=Z1^bMRbo^7w|8jdQ%p9ySgwz@)k*5 zM)`c#k>9i2*eLnSm6jrZaYUN4j{wN6F1DK%Mz-AhM=Qt+YT-$WeRHNsJYy3BCyUro z2?sl&t>hVYf0`{tmAHb~UK;H18LpJVLFwn;E+h+c>oVqOBME*V;sggHF8m9jJ(@OM z*omHS3n=VYeG_rlem%q#!=;|LM!pja_f}J)$%AR^jnZPs$R4=1dA*Cau#lCN&<+@D zY6L5WJUy6+fqe-f<$X_)Qq5t>!R z7O+SPd-9%$=rR@@(PwR>NriGWGAke2E`x8Yqsk+Mgdwut8@*4o*ezt}6IVr1TgB~cm5}%(d^CEpXSaN0tfo(xx z_RRi;Ad>o5Yc%V?v;hEKZ6|vQJ85!4ooj?;%CLle*{2|0rsrSjiJIG z3EgAGhKX4yQr!jzK0jNC184}s6x)#qq{cr}?e8^ps7JmV4$MDWME#eifc$^|3SNQr z69UjG0@_6n^d0%x8)l{d)io)B=sK|HUyStyXr<;6t0HpBkX+xS$^2mCk$jbmfvTHN z>s;uhQxRrmB{d~w2HMTct`hg4`~MeFX!$VbA+7o73N_Wi9-hjnkMH)u+dCCyrzRyW z9i9G%kqZk?&M!JUJ8dLi(AQM-y%;d9@vztUksg>$Q#*%7qWkE^==knM>i73)k9WkB zehE_&i=QyAb36HWJ{`U`cax}mgD+HR6n3qTLjCZn4>ZT2UEFXaCWlUASFW9w`Xif* zMYb;gp|H|#1N+T%ZkkNf=(4sy8Sm*)_q~hy^1b{T1p`kq*<8?I(e|)&K5Z~N*7y)T zIWDA7W=)Wu35DqwdN9|N4R`tnEY;KrKeLq?O7CM{5%Xl4z0>zXxiAfDRAe?F?X36@ z!djM@S5Sf9XmATrGZM|vr}~qFCk(m?e~_yp+`Xu%U{ox7a&9yEXTTM3Vlv}rM# z@3HU3m*hEzcEE_cgs|=gJ%vdM8Gx2y4eddXH$D?<48Y5P2OcsArcM=WH@0-CMlU#X zqwcXx=`1%QGcz+e87P1JHYY~_cL!81;F-_nWbM@^O$rnV9NqZa?Y~WC6Wk4|gpjYO zG6|G$fD%wqRHUM%%~ZY@rvKhrLDP&|j9+hKrj5rq%VY{z2T(2n zjG`SAjitcH(M9Sq8mPY0K1dSvBrc+MlHw6$_*zrg(f?+D;&soyp1PcCNhGE@dYa?0 z4Phur!RPSx1G*ua(C$)K z$gBSk!4}F=3!w8Re7{{xzjj7_DpHNxc~8bNQBTV>i&ad)Lx}M{3zLi=8@#LgLsb2I zu+hL<5Jvh}n6HCu1(gdkfcZvO{Bqaboj9ODBS5t{ARwStOrj?oENvFX+4E(7dJt(n z0eI~-Elsve{~IWvz4QrY+ax$qKjEIR+XB`DAg3DqcNgyim}Gx-FU6GSEUtQ2K%-Gp zWHG(FIB9Q1^q^WVq^nWwFBO)7W>N|JW;I?MJ22n*Ltl zykdZS;al{ktgat-Q-ZlS>$dzn3e^Om97vU_VDMYvKJXu!RhfTqed)nw9LcX}cv|!a zC|m4A{=K5QPz*9*rT)3IOkDB^;94+!c@*@isnxlU@@P6gqP$TKu28+3^|o&94j39l zknRuaKq|O2dyW%GJV#!VuhghQGn)P0X$MT^S$k9ak$bP1aN2z80f+}M#D^2BVd>BB z*6e$xjCp)Qmboq37Pop+R~UjIXQ6E279v;r$5+pDRfT_xgOp@}eaAd&{ahB-@@|lD zViDfmPbJV8Y1sM3krxi@av`ybx5~{&3&sz^2WOq@F}ndE5wN=#+J>-V27-BuUzS(B zD$}g-@3g%V9NW37)l7Ds1 zbg`3~&j_C^5Y(VUitb+5(523#n&Zp^;B^scgs?5LT<@xdNN*)u(xkCu*3P)2-$Y{O zK-?`Y|NigWke1ZxMh&1x5;HQKkY<3K0yK%`1vc%9UcjVT$^a6jrle3L_+&pxTLxFv zeNPG$Prbao&qFS|Swg29yjMTI2o$)Axj=zjf|hcnel88KrD#}rnwTBZFVLLI~qyB*Aoa*4LQ_~UZ=5xxHDY`6!ntHlEP+CEh~R{BRL zzwGnfXR!#xPKI&Se*dBym$Q{vg!oUHp7qw5w{<8QR3l3>-WVdxzKF-w5jVI=6w}>3GT7rJs4B`ZnAsH$!a{6Ek9Yf&>jjD|cOtmY3(nR?$>n6hN+ zTG#K0t}8r!N=!_wad|M)==XwjVb3r=PhCw7T8HbFy?{=+)w|8Loc{>J1Fioum}GsJ z3}S4n^>k*ty}ex(C)jeFXIN@vF*RmZ4#GNN`D~VB|UOGwXAHPXA-M)zXJ~;3d|A9L%00f>ti*uOU0@jm;>VP)s zaARU0#YMf78P2#%YrW}9E;hkEcO0Mi585RMGAlIbHAN0K1z?41;rMJENEA? z9h$iHvh>TBozru`qc#7{gHP6burM=cCnfD&oNkLd{dw@{(Qs)1FdA3~tRI|4Aj`@1 z`8UU1ud;BUFhO~0*EV)0=Bj`0@i;hYt4;EKuuQ2-$|ORV zYU&xjLhe({p}twV;!QP3*T!^XJZ>-45P%BtC?`y?En7S$y(^B^a++YJm^^zCMcGEE zqPlD_*ezHrECy+cR*ubS{D4Q9*5FIui0nl-Ip=Loz4$(n*-WputK12j*gDt4kJ53&fv$G3bvx!q_U@eR^h-Cp>uC&JAJ)R{!j%%7AnvS3z~-7ON$Ue-4x9H`)c z;$&}sNZVtg+OGR;r~s(r{LVwQ?ih1ziGs>85DN(dg5bw=Bz7qcNp%QaC~w(|eQ}j- zg(Aya^9!U^xOei;h1*l;UUDifYe`Z>D1x(~kIJm{RFY{zmI$SGnrhoiw8HnS5o@M4#KarqxP&kf1;g7Zl>~UuSvbV|OmT5uL+aQ^5 ziVqMb1=6^!W14F3l>1VE_jJ-dO~KOq3;62AE$cFpTKRd#H?y`RFz)A{KYA4<48uUj zAy;9wuc$0iNaInw$_sh~;f4S8z)P@b+?RC@kyGv>#-l8l zdbg>=P%iN9vI9)&j3F7^Kqdsmi||9)}kckcVY zq!@J|;=QL}>|}_1CMp{Y+s0MAOJYba%cHw0@atkp=o-gIn&tx>=Kkoq7FLY+X671& zonfmPJ@Z<7#>^k)^9@-{dp@~Njwhm&s470-1+fuT_0 zJt=Va0acmI0MU_`93fXB=EtpSFc>_y?n2(hHM`v9DNF`Q)ByZ$j#=9}I}noyO32;{ zkpup1iCQ06-LCpsuQ)nVS3;(o*uy)li-10mb_*O2kHgOyh8awD?YOi4F9Pk(2d&w(V5gx^a z2CWP^Xy$}BO)d(1i5O}9jbtL2ctBCJ;rGC*o6XR5K|f>xviK=4fFG;BLcjYwm~m76 zz9~dTR&eyk$JMfl?z!;JllIOZ+)X!$8k;9^=2?8_9a0|<*>svu$y4G#&lDZ@uYN># zzcUn4QKt{?sV8*Y?w7FYbHN3Pklx8@nM(&#F2>^WH&fc8c5}$^jWOQ0HTkcYdP6Cz zg59TO8iOgTnrEppK{fk32wM7u8m8Z0tqGsq46RnjHAmcl)!`oQ(d+k0S+Sw;J&8mM z&d%OFX}S*_VK4-{Z;Q6Uu7;>~Jq_RXv=yWqYe%pL6Q2?qB+zPPGy5Yi2}@XLzW zv$Sr94_1W6V%~=uqh5p0876npOrIkZf)|hB5}XkV>JMquwD_JsYmEbSr}yFEBes4! z&#rti3t&ECZ(6}_0_Ix0#ovF&C1~||HNN1P2#;yZ?+2RE${?~lFv8G+k$>966|%kJ&`VLb$$dHp zOPrd>xo}{v%e9+BQX1DaP5ynH(28-fB7||;XGql^-_W*D+GQ1@u{jpI`Z{E{q;&E2 zTrG5G%S2C2Bwc7~p*8tO6Rs7vtt|%_{J*AiQ3boc+v?kux2A?CZ>))+cr6OsY_Cm; z0di3+(>UnF*_e#Y-M)czT4T6NQ1$TkJOsFicqBGdh0VpKQk7!dm*u%Cq<;{;kI(fV zHCa{=_*25ZVjPfR+;x!caX2)aQ$0wR)_QOtGnr|*DPmve$JD#u*BRYr#L_2i#ee)| zw51>^_*}TjUwQv}V=%pinMZX0^LW|Q22VHUXcCWG1x?G5FU@#v?;Sflm}A4Xt+UV_-Q7U)Qdpm-Mm) z3z(3U$^R-#Sy=%L9N1s4*jU+HnBv)6h!Pe;qsX5z+?ynq1ES?&LApQ=e$T(SDVeir0pzkXe32YMO_#Fy>HXoSDu6P3B zhL{XkKXWC9r5Pd);8uVfygwJFuHGvcvU?j0+UCnnEHLTArm|I}U|}&X2WpV2>uYjf zptJ=|-gd_a%ACZAzf>*be@az*5-UNLRq-=eu(S07l@pM zPsgVcE}qOH13LyZfIFAe*wrG3Kwp!W>$TV`RwT1L@{!Ho{`fNJ>$4EWsW;Q#66gKs+DXddMN1yOC> z;7S^-p{ds$(O*Hf-(`!Vi(#%2!JmJY8=1v5yaG`!y_b~VyYnbplNQ(P0xZG>~94)%z9NN5iO&eGcSJcsM zJ(!#~Tt4UjW7CJk{ycy1tv5#C#A@ZbOzxfWxijavQpC2{sGkOAVUlmjcj!cO+(x7G zLK;G-R~5f9rGH40{%tJ%^&~lM_J&JGE-Z;tlc`tA!N9^|zBdyz8)yXTx6 zKpB1c%3`haR_lyRw()bai80^>cf~OHe++UfzFN{Pcyc^~v*}j`#RF4-Dx72?$Edtd z4XNt$sQ;aNpol|Uo~=3ro&^XsuXz18o`MzE4rnggf!QAww=A~t&+fBxUq+ny6WtKd zyFe*@;rwUXMGb^n0WBww;WxZmyvrN~%?GtEF?fb`^)0@!MW61g=I*@BC^s!>Q13Z% zy=NGbz%_5Ns3Lwqtj_Y3I@hGl5R^v#36ur&@}{Yy$0riWz75|6;&Ty>-->^G`~8Xa z+0FHhqmv4vQLI|l`~1dL1kn3A&i01(9x6Qk&j4^|$S{(`l8Be_cqq7FEfxq|pb37)Wk5kT*Ju z8XdI%C5|{$-tuFLmfR1ov{A%ePdW_zz3G`I_5Ccep#UMcJLT3n9hi5PK(xEq37U~$ z3<%}KWNt*u*!2&?R`e1DubWDqk?6_MCQZY-015u%uZiHR>pS#Q=C{ zHa3Bu`vl00Q>Gg2+VZ3mH<+nH3c2&lX-x54>P}tBmgFIbZ_@>PdbYF(+M8zXoRAxE z>ATu5vFgT;!=UHixYP1D0+XwIK3JYHSS6$-(8^~qoAM{qSEoCRUNtT+k%(BgxI{l0 z*y9kh32(w$cRjJ#yf044HIP*MD6_-pc=`Z=Of$F6x%e_XPN@*>6yH*dG8y!%XB4q+ zUP_+{-)q(O{f)nGx;)nP3}Hw|iG6}9$ba`~V&*sIeLwFxKbY4qYyPpPijzpGtMIVg zTD&ESHNG|g{Lz;acK-JzLRF97{y!}M+4XJ=V08mO^R^wWVIy?GP#vdDYv{zk9lm`(O)8n? zasI2}8?7@D16SrJ?B*XMy0F8}OqSG)y`q$8IaT? zwWWLLBsjz~Of0FPa0~=D3D$PyQ6$HXaq9(=i$Z(6(Ui2+NFHG;TAPpj6IwNmI^*Fv z9sPi+U*?H)UjnEXK4`zkeOPb%&`*7CXJ*SY%_ILvq!#VMmaZz0^YrY?O~XF8-q1V* zxH{yC&HgiB4<9U&8~#D|6n}*ui{D^2+;zC4fpQy!!t)qojH3@^J=}Aj>`ZdnaFU$< zeevR6r~_latzA${W40*Vwv66jWOYf{-tO|~C#2k3Uz1@JI!-I1DqlFz4|=4Voj6$D z8%=CBJNXQ%;Rd9(&3r}-i?V$u0UVS;>lsCtlH@9iTKNKv!hvaE*N;<`PIDyxz%q_s;IkCaq?RO_!?*Z?P>7A?4%yZXl z#X%l++mdH)b*Y*~6ywo?TLF-!sIZWelXEt3{p%`_{eyOQCYXw{a-HLE>tCPbi;Iha z8gUM`*AJMcc!3GiOkgSA-39l%;fW6k21^(D0)MH{k!+#=c}?>Y=sOHivnnY7tO7(K zp9UG|>&&nHjX7+)6Z^I(b?4c4qtKndcl(`uJx}}Vd7Ipk(H-# z2a;h7PYW6D@LK${n^6qwf5Rc>IeAGt0gLalQM9bGf@#M>18jWDf_SV$S7jQROieqv zX#;K~XMS#tt zYE&V&VS8drKja4TxnRH2U3)RwcX$eqXU|7s zyY2-xf3R1?5n4>`@4W7B8@KWB5alGD400?M+P%3mOvurN1Yw6s)u-(48o0@vX)uY# zUiY`0#`-y-pssl2+!cmL=-#+l*LuF`F+c!x=a&eqL$=Q-80&j1EOSNL2%s0Xb8raS z4B1BmU8#V?yiHfpoBBzRg7*LfZiPX5j&0mfY|xu+Yt{<^P*TMoa3!ZKaafN~b8K4~ zZ)5ZM0YHC1H2xh3KLH2NWKr?FmiNJfWdOp&j@@Uz`v5($=5D$aI7WcZagisaIm&;c z^a}{m!~a!|DAzLOE~@J5FCwjRVU{UPvNgT#c&)@nX`ujtg`7^!tza$8b&%{y@YG$oVtc1}np;3bB z|K4s;3BugFxD<~Ko}$9OFz9wu$(*uAcE^kEbgDgqd-a=lL41P)Dy8Z$!qIpE=Z<$X z1E8i%k^M^$nz?oMdz3WkJ>DDExIB)-=v*RD|8K5b2rgWl%cE8Iwppgo;Dak1@1n$dkpgX$ETU7`=$@T* zes{c8;l6RA5myytX`-_C2h$LAWGwC_@~l2E;;@-udqYu%52^j|rDA4SK&XADv6H(Cqz{Ry{lC9Jq z_%9Lzs0-(vhDF}pox5{jFYyuqENB26j&GDR6~A@wk>f9q(9Ey)6q$agoy$6n2)v`@L9a2PwlsPNzcU9gBM?UntH z2C!PUc=-x6vjuly@mjb;{3fqAk{su4nao~0M@liE2~YBN$FA|ve16()Zcqg^(S}j3 zgZ=u704Mj1gIn*N95WBMI3HNDwB~D>Um!bX9?1b>Ev<%?VMduc^T?j*(_KNBq3gYS zl?}UV*oxqmrA;0Xm?l?ald&4C(Lc-RZ|;p@xN@&YkY>J63O7Hdn&8R4jUc3uxvJ+D z(G0$uQt>t$dM-3{|II6!2KrEIkouRzx>05V%X+cgU`k{}dt8TMS)14WoHt)$Wz9@P zrg&gm!Y`eugODPs2*8JeM_oPdrXck~? zd*P^U8OnNk#Ep;>_LaszB|s}|Ulx`pOZFJ#3?aLo(!}}mMKatky9_-iKEG&`N%HQd z%;?(sm-K9XwEhR3ewlslQl(d1R-Urpio_B=5Pir8SF}lW(hFU8c{7^-Dr>`~6)Hub zHS19yvXqR?4O0->Mtz7 zzA`rB(Rv=@;_1mE9&4vBhohy*$v}SN&9X4@FSO$ogH^_&r57F>1Js}ZgzZKEr#jz}NJ(DE8p)SI0 z-n8=i!R4a8+qVL-oP^N%?6!EW$B3uV5rTMYHXRHSJ*j>xi*5yL;Q-+4uhARt0U-z{Y$&&an_(D zHrSNgbl=N7Dxya#bMSJSBq6O}SgogU@8(1X2J&X3kD}VNCRZA?jJ_$sH@i_~W@L}o zGoTrP^>JuzfN;y!{mtAJv?ML6Qi{OpxcakDZrIZExGz$&iRG$`C8Y1|-X5PuApzCi zB656V9^Qrosl@DaFL7bC%6%)wUUBkd7X9M`Br2`lNn>HG3zHP=>i>eWmjuIncc>}2$RHF*&IcR)K_WtrzJ5}belS9+;AJq;Xv3!e@6OXGu9DXKn3-fd1T|aqZk69;Y zFI*G*^tpf_9?cS`41qAu{etn={pDHK&oF(}B;O_m-uNU)T!%-6-r&|I(~n+IBF`LC zB~@JA`&1S%TiEl!rA)Ka?_OH6CFi?Oi30^s)ejA!$9xBY<&f&b8Q!g0SL1fB45Ki& zPjk`g?B^H^?-)3_1<}gcR?2_RK6Q4b`H8oiqNfAicv|R5i6T?AcXKwlLyM2rH*)@> zpbk1u90+mc9dW*NUca~md;NY&KV@z4^EHhxTau}Y*kX?tf(jmHryY7y2F)EY`$ZGC zxLT(&?=TW=h4{V4ntJovme+ruD3h$^J2NygItk9KhNe_iz|wJjg5yl|(`~0OTxYke z_reyE(W*UTuZL>f88Dd=F7~Ru(&f-{8WwUT8Z)QFkzF7v-imZ)H1;ng>eRO`FqX=)~#X0&;m)A<5 zDbbT(_fFn9s2HO?-%CK+*G)n*#W(wEMPu&Zg82RHL_5Cfry6@-qYc_cZ#c2rBMzcE z;1Zt2W9dmUo|EkB67S`G)MbaEf4O9!fx^XMtVW|ei5M^Vtlr0QFC5q zBefW82Ua^C(geOGmIU)GscPbfx6(4(7QU;@!y7G7QyJ9;zEZFZQxawPM7!k!=D{Ht zS=m-3HTz_4--mSoI?FwaoNyq)FcKYURsHPa8w^~7N!YEVfXyyBp8v>|yVcd$R*P{7 z8}~bi^QGDB(Bb+3M&u&oUqi!cJI~T~(H#s4c(@dRq4})+l}GGe|5D6)9CmG}70YIl zl^}Con)(*I@kJ$A#L63{4qPH>*wPcJ;2+vuv-atYT))izvhN=<<}hghQxwI$vO`5Y z8opAM%kpJ>=Q8h)t+W||kWOUUOG1NK#mj}1f_W{q<&@2YT2DLuX;(Xg@iAo|V3ZWo z8O<#2J>jzbz+Z=mb%)>IcTxG|-I!{i9=00m1@E`EPHOcgxl2C`PQGBzwS70R^M(bM zN2)auINwPeEqSA2gGMcrA-J9#;Z2G1OV}>(LZn#tod^$n`a7tpcsV;^$HY3Kn%lL# zMV0w&x*}rmF{74KLj=}C>FJwRn)P+u@lS6QKY~xjAOcI;8tE8;-3rOqzJqBbm2&20 zxfZB^Qy~aWC6bMhdA2qiWeN58ys(VQ< zjk?AUuR0p2HYchJJ4)nd$7CBBlqA`7JMDTk`w#9=5V~$9HdcS^$*Ebc))tk z_aC}g^@DK}P#W7c6kqM{!AIq=EmuUrl8Bz&x-TV8-+MFwaZ%=$bzF@4wl)_&^eHL= z``1R{j&QPg@)6KzoGsfK=Jc%cH@E^k@hXJLj20sMaMl0cH;)b{s{!Z|whNNtn(fcP{ zRzn4@a=K37cPo|>>s^jfUCqaa^;X^_m{aOTN*P_4WwSU%4TkeKCg5@>!t@&l#q6i| zn9cxHz@SbLdFBF5RlosiS~IggARE8uOgRMtnR8bvWuo`jUfCLk+}Clk=1#PqiX6V=tEj(*qjD-mz@Z z4OW-i6!Tj&+0Vp-TlW6yD}4oA$WjZPFfP;cpZEastnSv>-ZmaZxv}5y2RyGN!R{8>W`76OQO|B&YdiWiV+ zt&I3h^SE0V9Rmc=o5uz<{f8ay#K`pt)7^*Oh`4Lw3ihD|25O941^)YP}!QpHR8l^-E*Xg8?;N$g|G{30k{d|A7F=oJ-$A z$Wm_yh-DoS*sDkFpV(Z04{i4QBd>%i!uPFN^*R#v^FD{vIsW?Toy+f31&v%CAVO<1O7w?2Pbg}L+=q&Fk8&b z!Q4NcmeU^XAcs(Ttr?Y&K*MB&uwCyAaPt__6bJ6Bq5q~E_COQLXAV(OQP9n!siwxm z!;_#nCYDK2K7fSH@Hnd#{3HYkk)DTH2)3H{uu2GmNIi8ES;T6`9WNJG*)8lZM{1Wx zV?n)&2Lh1+W?dKOr=St^Ki>~2zc~L)1Y)k-TuK2qIe;~Y=0*XyHY*NYjB@0Mu|kvn z3Y5j7I_-su+jM4iX4k~}4XW0}HmoXsbF~*wRlYS=3b+vnloX&e2=sSn2$qoJg|GFq z&n+xM&UJG{G|1gh_EoR@1>b@pE+MTo;8C`9!2BVwZ-6)5Nyk7aoaVzK9jz%(6{B+@ zvI{9_nJTc2g(|8dG;EcTwp_vYzFC>uR>|Ff`2FOI>Pyncx)j8vbt#&a`VN1I!%ZC*n|A`G5>P370KN zM2}}wlvBf;-q*r$0b-vl>yArQDpcEz5Vow}C{GBZz~zV}LtiCwt4ilPI2fHgvOb(Z zcAA6XXOS*$=W^b+_RdOsKcLK@RkfBFASZ1kKE<&or>?FJPQStowz7nt33=|bt2m#I zu}FCSNRbS^I0L@6S4R<#UK)N{HCPluk%5 zwue{;;1T4LPFblMVbVpFKAEXuor|cL_(s_yC5a~qyHNFg@t5uIB$BlsPyjbJ$C>Z7 zf)Bw_TWxO%8_U3|J=12&>YPTN{c9c(?g)e8K$xBAE`*%#U(%%=Ir2p;VS!d5&VWxu2E?(w)sa0 zE(UDGGCf+ZgrOMOONbd#hqkt&6{0;)IfZQ6QB0Iy!je+ravnmqRhywW&H;>+^j_G$ zU`giYPTz>G3`6$Uerj4;#@@?E@4x<1w#OBcH-~#Lhtzoc>ANl)J_Wd?cvjkQ-AST8NenL6? z7rA?D%2jHc=dUFkj#+h0y$7KWlH)5Lzs(O>d|KArm9=ceu_&D8`!6xzT{D-RyRfpg z%o4%P;E3^_~rLAmVn|g(j%d`;{5=>Efq(-z8_jcv1&c*Jq_GKmaoc#}( z7F@`0cM&TD6!(DR!EeeRq7c~&%(E`cvqMArG;Q>G>hdc<<1Ar|hXy`0!8QHNUqCKR zTAP_8&O9WZjCxdn#SW|G5yQDqobl%$Vkf1D^j!gI(s7gfccehiz=p?rZ2aNOP{>Z0 zXW+N(9FRgJ%XHdvNlrf4uI>tVosB)Gj;I?pXTRq{Ly;$(**woSbQ>PBABEy<@-kf!-7{^G<@po~i^n&-2 zyz2IRHiOW18q`+Hl9n!EQ4ho>?cU~Ii#FN8mP2NBbRhqPtm$e;ZjBMg-f!6+rs};MZ2>w9qeO1KlUVbXwpeJ6-)Y zcvxayfegEAp!HV_RVHCj)A`>amUlyMpUjGZ^y7aIkJRsHETov?5`7lIX_F531cN`g z*736t!C8i=`6{n_ueKt_@zhLGOz%ZfY%aQq#>_*L`~PGUxi8YI1*K!=uaUmXsFwcI z9l@SE?t=xuhEDxpccikdXBK2OcM#Q2&MZ?st@R7&mVSN<{)D2x^WkA3o0!e#ym8Mr zYVL{f_P=~ClrPGKYxsYz?56U8kC0TLO{d>)PH)N=E$>JPpOU05 zEO+9$eU!enhCHVoYR>i(elmKbfRK1vqJP2~;X9#XF{uEJA~){Ry}CILN4aAU(;VEc zXR^+PQSwQs*k=t~xLo>uKf{};m;H0v>>2p!^f0vp6Xl3CxZ4&Lb!O@M2F&WWK?=CB zBgIsFp=7@uXKumB%f{TZ^O?FQ)9W*y-&?1|6mYk$AAtl!u;Wtr#SQTzM%JPi+>ZgGaig#cx)*fZkXk$KMrcCUu(S#<2pJX zKG|iKc`|MzNLj?Xcp!CgWl9V9)8Sj*Liab#d<56K3(5F*{?4nIULWF0A@8X2;JS=YBtcHnW1nO9Ajp zUz4CiVj$=|*`}ZM0@*|{;0KGFmp}&5%8%SzVC4ps6l7a|6xp<{rbgY4gi$VIL)R*I zWpl>qwZM}qkQB7n>$v(>@Bh)%bxK4@A&1SXZpg6x~BIk9~>@pef66c)Z%b zER0vJ@&5QdggSYZDy2!esndFIa-=!?Otw1hGIY02wFVO-asM?#^It60oPV=_SYdf& z=WIiI*WbL`RNRqRrs}y+m?Q@ld}a6~_Kn-T!(0w=L8%xZbOA3{vIJxtcNeu5CH zin;|0r|XqEzB|8Tst`a_-y3YlJ?H-Gx&n^iev1OGk;P(n+imtv5qR?Qe_K%9SP~Sc z_N+7p9nUL0$B#Ti!D&IWY>Hm1WvS6uh(*)*Yrmh{t!P7|qqzL3a7Ol_vZP=*L{J(& zf$TNSYkjSMU#nXtEG3?F`sccU9ZY<;c8AHdNoCn zDpa>>LM6w;rK(vLUhk}}K7<+aY85qaao-<-W7kAlPP2V*U9As3+LIW|2gvk2ty-$a zW+gR=1RnMtP6`Gh=+GGXRk!FEgsmI?qSA{*23@2LnXKs9@9egvu55E%rw4DkJ#Q%Z z-duO?LDCo6Abr~T(j--*J3bZiSJ;~kQ{syG*e+Mg%j`Q->fVt6lFv?xBNhGnf#9!c z5^^s5Y1L$|verW_Yt)u4NJdS&Tye77_!;lwP!==fL}`!EUltfjkP)gQd zrdSJ+^U(!!L_?z*uS*uuyDs0luguJ%eGX1r5$fzEq5$=jw#f&5=tw^Co61VDS>EL` zQCt@red?SMzPJ*~Oqqp!+biUtT2&bJRkO$tU;y#|(&Y}o8D1=*NidnD8sgO)SBZ3j zoDk1fDDF&@2q3gAkv+cP60!sqpjm+8l=Jp#CugowE1ec!uYi`>7&K#AtI@3~s(%f{ zz2f1V&l&shR<7^84*1k|4{#rq>#i!`#SiIUcciVj=k7ofMSn|M-Fry(XdEvBOX8co z=?Gq=6MhUMyo1SXQ>y0tNC++vMiBHvr=_jDhPnUhYH}hwuXAA{NYzor|6R Date: Fri, 17 Oct 2025 13:09:37 +0200 Subject: [PATCH 32/51] Add docs for derived variables --- docs/era5.md | 31 ++ openhexa/toolbox/era5/README.md | 539 +++++++++++++++++++------------- 2 files changed, 354 insertions(+), 216 deletions(-) diff --git a/docs/era5.md b/docs/era5.md index c61f5dc1..f4b4b27f 100644 --- a/docs/era5.md +++ b/docs/era5.md @@ -12,6 +12,9 @@ Store](https://www.google.com/url?sa=t&source=web&rct=j&opi=89978449&url=https:/ - [Move GRIB files into a Zarr store](#move-grib-files-into-a-zarr-store) - [Read climate data from a Zarr store](#read-climate-data-from-a-zarr-store) - [Aggregate climate data stored in a Zarr store](#aggregate-climate-data-stored-in-a-zarr-store) +- [Calculate derived variables](#calculate-derived-variables) + - [Relative humidity](#relative-humidity) + - [Wind speed](#wind-speed) - [Tests](#tests) ## Overview @@ -368,6 +371,34 @@ shape: (210, 3) Note that the period column uses DHIS2 format (e.g. `2024W40` for week 40 of 2024). +## Calculate derived variables + +### Relative humidity + +You can compute relative humidity from 2m temperature and 2m dewpoint temperature. + +```python +from openhexa.toolbox.era5.transform import calculate_relative_humidity + +rh = calculate_relative_humidity( + t2m=t2m_daily, + d2m=d2m_daily, +) +``` + +### Wind speed + +You can compute wind speed from the 10m u-component and v-component of wind. + +```python +from openhexa.toolbox.era5.transform import calculate_wind_speed + +ws = calculate_wind_speed( + u10=u10_daily, + v10=v10_daily, +) +``` + ## Tests The module uses Pytest. To run tests, install development dependencies and execute diff --git a/openhexa/toolbox/era5/README.md b/openhexa/toolbox/era5/README.md index 0ed324d0..f4b4b27f 100644 --- a/openhexa/toolbox/era5/README.md +++ b/openhexa/toolbox/era5/README.md @@ -1,303 +1,410 @@ -**ERA5 Toolbox** +# OpenHEXA Toolbox ERA5 -Package for downloading, processing, and aggregating ERA5-Land reanalysis data from the ECMWF Climate Data Store (CDS). +Download and process ERA5-Land climate reanalysis data from the [Copernicus Climate Data +Store](https://www.google.com/url?sa=t&source=web&rct=j&opi=89978449&url=https://cds.climate.copernicus.eu/&ved=2ahUKEwi0x-Pl4aqQAxUnRKQEHftaGdAQFnoECBEQAQ&usg=AOvVaw1BwvwpB-Kja5hnXP6DTcbl) +(CDS). - [Overview](#overview) -- [Data Source](#data-source) -- [Data Flow](#data-flow) -- [Usage](#usage) - - [Data Acquisition](#data-acquisition) - - [Data Aggregation](#data-aggregation) +- [Installation](#installation) - [Supported variables](#supported-variables) -- [Zarr store](#zarr-store) - - [Why Zarr instead of GRIB?](#why-zarr-instead-of-grib) - - [How is the Zarr store managed?](#how-is-the-zarr-store-managed) - - [Reading data from the Zarr store](#reading-data-from-the-zarr-store) +- [Usage](#usage) + - [Prepare and retrieve data requests](#prepare-and-retrieve-data-requests) + - [Move GRIB files into a Zarr store](#move-grib-files-into-a-zarr-store) + - [Read climate data from a Zarr store](#read-climate-data-from-a-zarr-store) + - [Aggregate climate data stored in a Zarr store](#aggregate-climate-data-stored-in-a-zarr-store) +- [Calculate derived variables](#calculate-derived-variables) + - [Relative humidity](#relative-humidity) + - [Wind speed](#wind-speed) +- [Tests](#tests) ## Overview -This package provides tools to: +The package provides tools to: - Download ERA5-Land hourly data from ECMWF's Climate Data Store - Convert GRIB files to analysis-ready Zarr format - Perform spatial aggregation using geographic boundaries - Aggregate data temporally across various periods (daily, weekly, monthly, yearly) - Support DHIS2-compatible weekly periods (standard, Wednesday, Thursday, Saturday, Sunday weeks) -## Data Source - -ERA5-Land is a reanalysis dataset providing hourly estimates of land variables from 1950 to present at 9km resolution. Data is accessed via the [ECMWF Climate Data Store](https://cds.climate.copernicus.eu/). +## Installation -**Requirements:** -- CDS API account and credentials -- Dataset license accepted in the CDS +With pip: -## Data Flow +```bash +pip install openhexa.toolbox[all] +# Or +pip install openhexa.toolbox[era5] +``` -```mermaid -flowchart LR - CDS[(ECMWF CDS)] --> GRIB[GRIB Files] --> ZARR[Zarr Store] --> PROCESS[Aggregate] +With uv: - style CDS fill:#e1f5fe - style ZARR fill:#f3e5f5 +```bash +uv add openhexa.toolbox --extra all +# Or +uv add openhexa.toolbox --extra era5 ``` +## Supported variables + +The module supports a subset of ERA5-Land variables commonly used in health: + +- 10m u-component of wind (`u10`) +- 10m v-component of wind (`v10`) +- 2m dewpoint temperature (`d2m`) +- 2m temperature (`t2m`) +- Runoff (`ro`) +- Soil temperature level 1 (`stl1`) +- Volumetric soil water layer 1 (`swvl1`) +- Volumetric soil water layer 2 (`swvl2`) +- Total precipitation (`tp`) +- Total evaporation (`e`) + +When fetching hourly data, we sample instantaneous variable at 4 daily steps: 01:00, +07:00, 13:00 and 19:00. For accumulated variables (e.g. total precipitation), we only +retrieve totals at the end of each day. + +See [variables.toml](/openhexa/toolbox/era5/data/variables.toml) for more details on +supported variables. + ## Usage -### Data Acquisition +### Prepare and retrieve data requests -Use `prepare_requests()` to build data requests for a specific variable and time range. -If the Zarr store already contains data, only missing data will be requested. If the -Zarr store does not exist, all data in the range will be requested and the store -created. +Download ERA5-Land data from the CDS API. You'll need to set up your CDS API credentials +first (see [CDS API setup](https://cds.climate.copernicus.eu/how-to-api)) and accept the +license of the dataset you want to download. ```python +from datetime import date from pathlib import Path +from ecmwf.datastores import Client +from openhexa.toolbox.era5.extract import prepare_requests, retrieve_requests +import os -from ecmwf.datastores.client import Client -from era5.extract import prepare_requests, submit_requests, retrieve_requests, grib_to_zarr - -client = Client(url=CDS_API_URL, key=CDS_API_KEY) -zarr_store = Path("data/2m_temperature.zarr") +client = Client(url=os.getenv("CDS_API_URL"), key=os.getenv("CDS_API_KEY")) -# Prepare and chunk data requests -# Existing data in the zarr store will not be requested +# Prepare the data requests that need to be submitted to the CDS +# If data already exists in the destination zarr store, it will not be requested again +# NB: At this point, no data is moved to the Zarr store - it is used to avoid +# downloading data we already have requests = prepare_requests( - client, + client=client, dataset_id="reanalysis-era5-land", - start_date=date(2025, 3, 1), - end_date=date(2025, 9, 10), + start_date=date(2025, 3, 28), + end_date=date(2025, 4, 5), variable="2m_temperature", - area=[12, -2, 8, 2], # North, West, South, East - zarr_store=zarr_store + area=[10, -1, 8, 1], # [north, west, south, east] in degrees + zarr_store=Path("data/2m_temperature.zarr"), ) -raw_dir = Path("data/2m_temperature/raw") -raw_dir.mkdir(parents=True, exist_ok=True) - -# Retrieve data requests when they are ready -# This will download raw GRIB files to `raw_dir` +# Submit data requests and retrieve data in GRIB format as they are ready +# Depending on request size and server load, this may take a while retrieve_requests( - client, + client=client, dataset_id="reanalysis-era5-land", requests=requests, - dst_dir=raw_dir, + dst_dir=Path("data/raw"), + wait=30, # Check every 30 seconds for completed requests ) +``` + +### Move GRIB files into a Zarr store -# Convert raw GRIB data to Zarr format -# NB: The zarr store will be created if it does not already existed -grib_to_zarr(raw_dir, zarr_store) +Convert downloaded GRIB files into an analysis-ready Zarr store for efficient access. + +```python +from pathlib import Path +from openhexa.toolbox.era5.extract import grib_to_zarr + +grib_to_zarr( + src_dir=Path("data/raw"), + zarr_store=Path("data/2m_temperature.zarr"), + data_var="t2m", # Short name for 2m temperature +) ``` -### Data Aggregation +### Read climate data from a Zarr store + +Data is stored in [Zarr](https://zarr.dev/) stores for efficient storage and access of +climate variables as N-dimensional arrays. You can read data in Zarr stores using +[xarray](https://xarray.dev/). -Use `aggregate_in_space()` to perform spatial aggregation. +When opening a Zarr store, no data is loaded into memory yet. You can check the dataset +structure without loading the data. ```python +import xarray as xr + +ds = xr.open_zarr("data/2m_temperature.zarr", consolidated=True) +print(ds) +``` +``` + Size: 7MB +Dimensions: (latitude: 71, longitude: 91, time: 284) +Coordinates: + * latitude (latitude) float64 568B 16.0 15.9 15.8 15.7 ... 9.3 9.2 9.1 9.0 + * longitude (longitude) float64 728B -6.0 -5.9 -5.8 -5.7 ... 2.7 2.8 2.9 3.0 + * time (time) datetime64[ns] 2kB 2024-10-01T01:00:00 ... 2024-12-10T1... +Data variables: + t2m (latitude, longitude, time) float32 7MB ... +Attributes: + Conventions: CF-1.7 + GRIB_centre: ecmf + GRIB_centreDescription: European Centre for Medium-Range Weather Forecasts + GRIB_edition: 1 + GRIB_subCentre: 0 + history: 2025-10-14T09:02 GRIB to CDM+CF via cfgrib-0.9.1... + institution: European Centre for Medium-Range Weather Forecasts +``` + +You can use real dates and coordinates to index the data. + +```python +import xarray as xr + +t2m = xr.open_zarr("data/2m_temperature.zarr", consolidated=True) +t2m_daily_mean = t2m.resample(time="1D").mean() +t2m_daily_mean.mean(dim=["latitude", "longitude"]).t2m.plot.line() +``` + +![ERA5 2m Temperature Daily Mean](/docs/images/era5_t2m_lineplot.png) + +### Aggregate climate data stored in a Zarr store + +Aggregate hourly climate data by administrative boundaries and time periods. + +```python +from pathlib import Path import geopandas as gpd -from era5.transform import create_masks, aggregate_in_space +import xarray as xr +from openhexa.toolbox.era5.transform import ( + create_masks, + aggregate_in_space, + aggregate_in_time, + Period, +) -boundaries = gpd.read_file("boundaries.geojson") -dataset = xr.open_zarr(zarr_store, decode_timedelta=True) +t2m = xr.open_zarr("./2m_temperature.zarr", consolidated=True, decode_timedelta=False) +``` + +For instantaneous variables (e.g. 2m temperature, soil moisture...), hourly data should +be aggregated to daily 1st. In ERA5-Land data, data is structured along 2 temporal +dimensions: `time` and `step`. To aggregate hourly data to daily, you need to average over +the `step` dimension: + +```python +t2m_daily = t2m.mean(dim="step") + +# or to compute daily extremes +t2m_daily_max = t2m.max(dim="step") +t2m_daily_min = t2m.min(dim="step") +``` + +```python +import matplotlib.pyplot as plt + +plt.imshow( + t2m_daily.sel(time="2024-10-04").t2m, + cmap="coolwarm", +) +plt.colorbar(label="Temperature (°C)", shrink=0.8) +plt.axis("off") +``` +![2m temperature raster](/docs/images/era5_t2m_raster.png) + +The module provides helper functions to help you perform spatial aggregation on gridded +ERA5 data. Use the `create_masks()` function to create raster masks from vector +boundaries. Raster masks uses the same grid as the ERA5 dataset. + +```python +import geopandas as gpd +from openhexa.toolbox.era5.transform import create_masks + +# Boundaries geographic file should use EPSG:4326 coordinate reference system (lat/lon) +boundaries = gpd.read_file("boundaries.gpkg") -# Create spatial masks for aggregation masks = create_masks( gdf=boundaries, - id_column="boundary_id", - ds=dataset + id_column="district_id", # Column in the GeoDataFrame with unique boundary IDs + ds=t2m_daily, ) +``` + +Example of raster mask for 1 vector boundary: + +![Boundary vector](/docs/images/era5_boundary_vector.png) +![Boundary raster mask](/docs/images/era5_boundary_raster.png) -# Convert from hourly to daily data 1st -daily = dataset.mean(dim="step") +You can now aggregate daily gridded ERA5 data in space and time: -# Aggregate spatially -results = aggregate_in_space( - ds=daily, +```python +from openhexa.toolbox.era5.transform import aggregate_in_space, aggregate_in_time, Period + +# convert from Kelvin to Celsius +t2m_daily = t2m_daily - 273.15 + +t2m_agg = aggregate_in_space( + ds=t2m_daily, masks=masks, variable="t2m", - agg="mean" + agg="mean", ) -print(results) +print(t2m_agg) ``` ``` -shape: (36, 3) -┌──────────┬────────────┬────────────┐ -│ boundary ┆ time ┆ value │ -│ --- ┆ --- ┆ --- │ -│ str ┆ date ┆ f64 │ -╞══════════╪════════════╪════════════╡ -│ geom1 ┆ 2025-03-28 ┆ 305.402924 │ -│ geom1 ┆ 2025-03-29 ┆ 306.365845 │ -│ geom1 ┆ 2025-03-30 ┆ 306.80304 │ -│ geom1 ┆ 2025-03-31 ┆ 307.176575 │ -│ geom1 ┆ 2025-04-01 ┆ 306.338745 │ -│ … ┆ … ┆ … │ -│ geom4 ┆ 2025-04-01 ┆ 305.957886 │ -│ geom4 ┆ 2025-04-02 ┆ 306.503937 │ -│ geom4 ┆ 2025-04-03 ┆ 305.563995 │ -│ geom4 ┆ 2025-04-04 ┆ 306.381927 │ -│ geom4 ┆ 2025-04-05 ┆ 307.367096 │ -└──────────┴────────────┴────────────┘ +shape: (4_970, 3) +┌─────────────┬────────────┬───────────┐ +│ boundary ┆ time ┆ value │ +│ --- ┆ --- ┆ --- │ +│ str ┆ date ┆ f64 │ +╞═════════════╪════════════╪═══════════╡ +│ mPenE8ZIBFC ┆ 2024-10-01 ┆ 26.534632 │ +│ mPenE8ZIBFC ┆ 2024-10-02 ┆ 25.860088 │ +│ mPenE8ZIBFC ┆ 2024-10-03 ┆ 26.068018 │ +│ mPenE8ZIBFC ┆ 2024-10-04 ┆ 26.103462 │ +│ mPenE8ZIBFC ┆ 2024-10-05 ┆ 24.362678 │ +│ … ┆ … ┆ … │ +│ eKYyXbBdvmB ┆ 2024-12-06 ┆ 25.130324 │ +│ eKYyXbBdvmB ┆ 2024-12-07 ┆ 24.946449 │ +│ eKYyXbBdvmB ┆ 2024-12-08 ┆ 24.840832 │ +│ eKYyXbBdvmB ┆ 2024-12-09 ┆ 25.242334 │ +│ eKYyXbBdvmB ┆ 2024-12-10 ┆ 26.697817 │ +└─────────────┴────────────┴───────────┘ ``` -Use `aggregate_in_time()` to perform temporal aggregation. +Likewise, to aggregate in time (e.g. weekly averages): ```python -from era5.transform import Period, aggregate_in_time - -# Aggregate to weekly periods -weekly_data = aggregate_in_time( - results, +t2m_weekly = aggregate_in_time( + dataframe=t2m_agg, period=Period.WEEK, - agg="mean" + agg="mean", ) +print(t2m_weekly) +``` -# DHIS2-compatible Sunday weeks -sunday_weekly = aggregate_in_time( - results, +``` +shape: (770, 3) +┌─────────────┬─────────┬───────────┐ +│ boundary ┆ period ┆ value │ +│ --- ┆ --- ┆ --- │ +│ str ┆ str ┆ f64 │ +╞═════════════╪═════════╪═══════════╡ +│ AKVCJJ2TKSi ┆ 2024W40 ┆ 27.33611 │ +│ AKVCJJ2TKSi ┆ 2024W41 ┆ 27.011093 │ +│ AKVCJJ2TKSi ┆ 2024W42 ┆ 27.905081 │ +│ AKVCJJ2TKSi ┆ 2024W43 ┆ 28.239824 │ +│ AKVCJJ2TKSi ┆ 2024W44 ┆ 27.34595 │ +│ … ┆ … ┆ … │ +│ yhs1ecKsLOc ┆ 2024W46 ┆ 27.711391 │ +│ yhs1ecKsLOc ┆ 2024W47 ┆ 26.394333 │ +│ yhs1ecKsLOc ┆ 2024W48 ┆ 24.863514 │ +│ yhs1ecKsLOc ┆ 2024W49 ┆ 24.714464 │ +│ yhs1ecKsLOc ┆ 2024W50 ┆ 24.923738 │ +└─────────────┴─────────┴───────────┘ +``` + +Or per week starting on Sundays: + +``` python +t2m_sunday_week = aggregate_in_time( + dataframe=t2m_agg, period=Period.WEEK_SUNDAY, - agg="mean" + agg="mean", ) - -print(sunday_weekly) +print(t2m_sunday_week) ``` + ``` -shape: (8, 3) -┌──────────┬────────────┬────────────┐ -│ boundary ┆ period ┆ value │ -│ --- ┆ --- ┆ --- │ -│ str ┆ str ┆ f64 │ -╞══════════╪════════════╪════════════╡ -│ geom1 ┆ 2025WedW13 ┆ 306.417426 │ -│ geom1 ┆ 2025WedW14 ┆ 307.149551 │ -│ geom2 ┆ 2025WedW13 ┆ 306.327582 │ -│ geom2 ┆ 2025WedW14 ┆ 306.987686 │ -│ geom3 ┆ 2025WedW13 ┆ 306.03266 │ -│ geom3 ┆ 2025WedW14 ┆ 306.774063 │ -│ geom4 ┆ 2025WedW13 ┆ 305.77348 │ -│ geom4 ┆ 2025WedW14 ┆ 306.454239 │ -└──────────┴────────────┴────────────┘ +shape: (770, 3) +┌─────────────┬────────────┬───────────┐ +│ boundary ┆ period ┆ value │ +│ --- ┆ --- ┆ --- │ +│ str ┆ str ┆ f64 │ +╞═════════════╪════════════╪═══════════╡ +│ AKVCJJ2TKSi ┆ 2024SunW40 ┆ 27.898345 │ +│ AKVCJJ2TKSi ┆ 2024SunW41 ┆ 26.483939 │ +│ AKVCJJ2TKSi ┆ 2024SunW42 ┆ 27.9347 │ +│ AKVCJJ2TKSi ┆ 2024SunW43 ┆ 28.291441 │ +│ AKVCJJ2TKSi ┆ 2024SunW44 ┆ 27.510819 │ +│ … ┆ … ┆ … │ +│ yhs1ecKsLOc ┆ 2024SunW46 ┆ 27.691862 │ +│ yhs1ecKsLOc ┆ 2024SunW47 ┆ 26.316256 │ +│ yhs1ecKsLOc ┆ 2024SunW48 ┆ 25.249807 │ +│ yhs1ecKsLOc ┆ 2024SunW49 ┆ 24.751227 │ +│ yhs1ecKsLOc ┆ 2024SunW50 ┆ 24.542277 │ +└─────────────┴────────────┴───────────┘ ``` -## Supported variables +Or per month: -The package supports the following ERA5-Land variables: +``` python +t2m_monthly = aggregate_in_time( + dataframe=t2m_agg, + period=Period.MONTH, + agg="mean", +) +print(t2m_monthly) +``` ``` -[10m_u_component_of_wind] -name = "10m_u_component_of_wind" -short_name = "u10" -unit = "m s**-1" -time = ["01:00", "07:00", "13:00", "19:00"] - -[10m_v_component_of_wind] -name = "10m_v_component_of_wind" -short_name = "v10" -unit = "m s**-1" -time = ["01:00", "07:00", "13:00", "19:00"] - -[2m_dewpoint_temperature] -name = "2m_dewpoint_temperature" -short_name = "d2m" -unit = "K" -time = ["01:00", "07:00", "13:00", "19:00"] - -[2m_temperature] -name = "2m_temperature" -short_name = "t2m" -unit = "K" -time = ["01:00", "07:00", "13:00", "19:00"] - -[runoff] -name = "runoff" -short_name = "ro" -unit = "m" -time = ["00:00"] - -[soil_temperature_level_1] -name = "soil_temperature_level_1" -short_name = "stl1" -unit = "K" -time = ["01:00", "07:00", "13:00", "19:00"] - -[volumetric_soil_water_layer_1] -name = "volumetric_soil_water_layer_1" -short_name = "swvl1" -unit = "m**3 m**-3" -time = ["01:00", "07:00", "13:00", "19:00"] - -[volumetric_soil_water_layer_2] -name = "volumetric_soil_water_layer_2" -short_name = "swvl2" -unit = "m**3 m**-3" -time = ["01:00", "07:00", "13:00", "19:00"] - -[total_precipitation] -name = "total_precipitation" -short_name = "tp" -unit = "m" -time = ["00:00"] - -[total_evaporation] -name = "total_evaporation" -short_name = "e" -unit = "m" -time = ["00:00"] +shape: (210, 3) +┌─────────────┬────────┬───────────┐ +│ boundary ┆ period ┆ value │ +│ --- ┆ --- ┆ --- │ +│ str ┆ str ┆ f64 │ +╞═════════════╪════════╪═══════════╡ +│ AKVCJJ2TKSi ┆ 202410 ┆ 27.615368 │ +│ AKVCJJ2TKSi ┆ 202411 ┆ 26.527692 │ +│ AKVCJJ2TKSi ┆ 202412 ┆ 25.080745 │ +│ AVb6wBstPAo ┆ 202410 ┆ 29.747595 │ +│ AVb6wBstPAo ┆ 202411 ┆ 26.137431 │ +│ … ┆ … ┆ … │ +│ vQ6AJUeqBpc ┆ 202411 ┆ 25.915338 │ +│ vQ6AJUeqBpc ┆ 202412 ┆ 23.130632 │ +│ yhs1ecKsLOc ┆ 202410 ┆ 29.050539 │ +│ yhs1ecKsLOc ┆ 202411 ┆ 26.628291 │ +│ yhs1ecKsLOc ┆ 202412 ┆ 24.688542 │ +└─────────────┴────────┴───────────┘ ``` -See [documentation](https://cds.climate.copernicus.eu/datasets/reanalysis-era5-land) for details. +Note that the period column uses DHIS2 format (e.g. `2024W40` for week 40 of 2024). -## Zarr store +## Calculate derived variables -### Why Zarr instead of GRIB? +### Relative humidity -The package converts GRIB files to Zarr format for several reasons: +You can compute relative humidity from 2m temperature and 2m dewpoint temperature. -1. **Efficient data access**: Zarr provides chunked, compressed storage that allows reading specific temporal/spatial subsets without loading entire files -2. **Cloud-optimized**: Unlike GRIB files which require sequential reading, Zarr enables parallel and partial reads, ideal for cloud storage -3. **Consolidated metadata**: All metadata is stored in a single `.zmetadata` file, making dataset discovery instant -4. **Append-friendly**: New time steps can be efficiently appended without rewriting existing data -5. **Analysis-ready**: Direct integration with xarray makes the data immediately usable for scientific computing - -### How is the Zarr store managed? +```python +from openhexa.toolbox.era5.transform import calculate_relative_humidity -The ERA5 toolbox implements the following data pipeline: +rh = calculate_relative_humidity( + t2m=t2m_daily, + d2m=d2m_daily, +) +``` -1. **Initial download**: GRIB files from CDS are treated as temporary artifacts -2. **Conversion**: `grib_to_zarr()` converts GRIB to Zarr, handling: - - Automatic creation of new stores - - Appending to existing stores without duplicating time steps - - Metadata consolidation for optimal performance -3. **Incremental updates**: When requesting new data, the package: - - Checks existing time coverage in the Zarr store - - Only downloads missing time periods - - Appends new data +### Wind speed -### Reading data from the Zarr store +You can compute wind speed from the 10m u-component and v-component of wind. ```python -import xarray as xr - -# Open the zarr store (lazy loading - no data read yet) -ds = xr.open_zarr("data/2m_temperature.zarr", consolidated=True) +from openhexa.toolbox.era5.transform import calculate_wind_speed -# Explore the dataset structure -print(ds) # Shows dimensions, coordinates, and variables -print(ds.time.values) # Time range available - -# Access specific time ranges -subset = ds.sel(time=slice("2025-01", "2025-03")) - -# Load only specific variables -temperature = ds["t2m"] # Still lazy -temp_values = temperature.values # Triggers actual data read +ws = calculate_wind_speed( + u10=u10_daily, + v10=v10_daily, +) +``` -# Spatial subsetting -region = ds.sel(latitude=slice(10, 5), longitude=slice(-1, 2)) +## Tests -# Time aggregation (hourly to daily) -daily_mean = ds.resample(time="1D").mean() +The module uses Pytest. To run tests, install development dependencies and execute +Pytest in the virtual environment. -# Direct computation without loading everything -monthly_max = ds["t2m"].resample(time="1M").max().compute() -``` +```bash +uv sync --dev +uv run pytest tests/era5/* +``` \ No newline at end of file From 1fac22914c2a62a3d764ba14b383be1cebd0ee98 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 17 Oct 2025 14:56:57 +0200 Subject: [PATCH 33/51] Add more debug logs --- openhexa/toolbox/era5/extract.py | 5 +++++ openhexa/toolbox/era5/transform.py | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index 5e699a43..412067bd 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -608,6 +608,7 @@ def get_missing_dates( collection.begin_datetime.date(), collection.end_datetime.date(), ) + logger.debug("Checking existing data for variable '%s' from %s to %s", data_var, start_date, end_date) dates = _diff_zarr(start_date, end_date, zarr_store, data_var) logger.debug("Missing dates for variable '%s': %s", data_var, dates) return dates @@ -636,8 +637,12 @@ def grib_to_zarr( ds = _drop_incomplete_days(ds, data_var=data_var) ds = _flatten_time_dimension(ds) if not zarr_store.exists(): + logger.debug("Creating new Zarr store '%s'", zarr_store.name) _create_zarr(ds, zarr_store) else: + logger.debug("Appending data to existing Zarr store '%s'", zarr_store.name) _append_zarr(ds, zarr_store, data_var) + logger.debug("Consolidating Zarr store '%s'", zarr_store.name) _consolidate_zarr(zarr_store) + logger.debug("Validating Zarr store '%s'", zarr_store.name) _validate_zarr(zarr_store) diff --git a/openhexa/toolbox/era5/transform.py b/openhexa/toolbox/era5/transform.py index edabc091..d85064be 100644 --- a/openhexa/toolbox/era5/transform.py +++ b/openhexa/toolbox/era5/transform.py @@ -1,5 +1,6 @@ """Spatial aggregation of ERA5-Land data.""" +import logging from enum import StrEnum from typing import Literal @@ -12,6 +13,8 @@ from openhexa.toolbox.era5.dhis2weeks import WeekType, to_dhis2_week +logger = logging.getLogger(__name__) + def create_masks(gdf: gpd.GeoDataFrame, id_column: str, ds: xr.Dataset) -> xr.DataArray: """Create masks for each boundary in the GeoDataFrame. @@ -33,6 +36,7 @@ def create_masks(gdf: gpd.GeoDataFrame, id_column: str, ds: xr.Dataset) -> xr.Da containing the masks. Each mask corresponds to a boundary in the GeoDataFrame. """ + logger.debug("Creating masks for %s boundaries", len(gdf)) lat = ds.latitude.values lon = ds.longitude.values lat_res = abs(lat[1] - lat[0]) @@ -61,6 +65,8 @@ def create_masks(gdf: gpd.GeoDataFrame, id_column: str, ds: xr.Dataset) -> xr.Da masks.append(mask) # type: ignore names.append(row[id_column]) # type: ignore + logger.debug("Created masks with shape %s", (len(masks), len(lat), len(lon))) + return xr.DataArray( np.stack(masks), dims=["boundary", "latitude", "longitude"], @@ -98,6 +104,7 @@ def aggregate_in_space( ValueError: If an unsupported aggregation method is specified. """ + logger.debug("Aggregating data for variable '%s' using masks", data_var) if data_var not in ds.data_vars: msg = f"Variable '{data_var}' not found in dataset" raise ValueError(msg) @@ -169,6 +176,7 @@ def aggregate_in_time( The aggregated dataframe. """ + logger.debug("Aggregating dataframe over period '%s' with method '%s'", period, agg) # We 1st create a "period" column to be able to group by it if period == Period.DAY: df = dataframe.with_columns( From 5d42a21bf8e27de611dcef58bd0c4691cc80ed77 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 17 Oct 2025 15:14:03 +0200 Subject: [PATCH 34/51] Add more logs --- openhexa/toolbox/era5/transform.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openhexa/toolbox/era5/transform.py b/openhexa/toolbox/era5/transform.py index d85064be..29078754 100644 --- a/openhexa/toolbox/era5/transform.py +++ b/openhexa/toolbox/era5/transform.py @@ -115,6 +115,7 @@ def aggregate_in_space( area_weights = np.cos(np.deg2rad(ds.latitude)) results: list[xr.DataArray] = [] for boundary in masks.boundary: + logger.debug("Aggregating for boundary '%s'", boundary) mask = masks.sel(boundary=boundary) if agg == "mean": weights = area_weights * mask From 11d18940e9e4229517786d07312c049142f8c98d Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 17 Oct 2025 15:19:52 +0200 Subject: [PATCH 35/51] Fix capture of boundary id in logs --- openhexa/toolbox/era5/transform.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openhexa/toolbox/era5/transform.py b/openhexa/toolbox/era5/transform.py index 29078754..01c2e3d7 100644 --- a/openhexa/toolbox/era5/transform.py +++ b/openhexa/toolbox/era5/transform.py @@ -115,7 +115,7 @@ def aggregate_in_space( area_weights = np.cos(np.deg2rad(ds.latitude)) results: list[xr.DataArray] = [] for boundary in masks.boundary: - logger.debug("Aggregating for boundary '%s'", boundary) + logger.debug("Aggregating for boundary '%s'", boundary.item()) mask = masks.sel(boundary=boundary) if agg == "mean": weights = area_weights * mask From a5b2b3b4850d9754b5987d2f66ea417eaecd80ec Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 17 Oct 2025 17:03:08 +0200 Subject: [PATCH 36/51] Store DataArray in memory for faster processing --- openhexa/toolbox/era5/transform.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openhexa/toolbox/era5/transform.py b/openhexa/toolbox/era5/transform.py index 01c2e3d7..23bc3a36 100644 --- a/openhexa/toolbox/era5/transform.py +++ b/openhexa/toolbox/era5/transform.py @@ -111,7 +111,7 @@ def aggregate_in_space( if "step" in ds.dims: msg = "Dataset still contains 'step' dimension. Please aggregate to daily data first." raise ValueError(msg) - da = ds[data_var] + da = ds[data_var].compute() area_weights = np.cos(np.deg2rad(ds.latitude)) results: list[xr.DataArray] = [] for boundary in masks.boundary: From 8d0b4ce0448e4985d3d9cb4553cf1d3f2289c26c Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 24 Oct 2025 10:47:51 +0200 Subject: [PATCH 37/51] Add a caching mechanism --- openhexa/toolbox/era5/cache.py | 196 +++++++++++++++++++++++++++++++ openhexa/toolbox/era5/extract.py | 148 +++++++++++++++++------ openhexa/toolbox/era5/models.py | 91 ++++++++++++++ 3 files changed, 396 insertions(+), 39 deletions(-) create mode 100644 openhexa/toolbox/era5/cache.py create mode 100644 openhexa/toolbox/era5/models.py diff --git a/openhexa/toolbox/era5/cache.py b/openhexa/toolbox/era5/cache.py new file mode 100644 index 00000000..c5e111ef --- /dev/null +++ b/openhexa/toolbox/era5/cache.py @@ -0,0 +1,196 @@ +import gzip +import hashlib +import json +import shutil +from dataclasses import dataclass +from pathlib import Path + +import psycopg +from psycopg.rows import class_row + +from openhexa.toolbox.era5.models import Request + + +@dataclass +class CacheEntry: + job_id: str + file_name: str | None + + +def _hash_data_request(request: Request) -> str: + """Convert data request dict into MD5 hash.""" + json_str = json.dumps(request, sort_keys=True) + return hashlib.md5(json_str.encode()).hexdigest() + + +class Cache: + """Cache data requests using PostgreSQL.""" + + def __init__(self, database_uri: str, cache_dir: Path): + """Initialize cache in database. + + Args: + database_uri: URI of the PostgreSQL database, e.g. + "postgresql://user:password@host:port/dbname". + cache_dir: Directory to store downloaded GRIB files. + """ + self.database_uri = database_uri + self.cache_dir = cache_dir + self._init_db() + self._init_cache_dir() + + def _init_db(self) -> None: + """Create schema and table if they do not exist.""" + with psycopg.connect(self.database_uri) as conn: + with conn.cursor() as cur: + cur.execute("--sql create schema if not exists era5") + cur.execute( + """--sql + create table if not exists era5.cds_cache ( + cache_key varchar(32) primary key, + request json not null, + job_id varchar(64) not null, + file_name text, + created_at timestamp not null default now(), + updated_at timestamp not null default now(), + expires_at timestamp + ) + """ + ) + cur.execute( + """--sql + create index if not exists idx_cds_cache_expires + on era5.cds_cache(expires_at) + """ + ) + conn.commit() + + def _init_cache_dir(self) -> None: + """Create cache directory if it does not exist.""" + self.cache_dir.mkdir(parents=True, exist_ok=True) + + def _archive(self, src_fp: Path, job_id: str) -> None: + """Archive a GRIB file using gzip. + + Args: + src_fp: Path to the source GRIB file. + job_id: The ID of the corresponding CDS job. + """ + dst_fp = self.cache_dir / f"{job_id}.grib.gz" + with open(src_fp, "rb") as src_f: + with gzip.open(dst_fp, "wb", compresslevel=9) as dst_f: + shutil.copyfileobj(src_f, dst_f) + + def retrieve(self, job_id: str, dst_fp: Path) -> None: + """Retrieve a GRIB file from a gzip archive. + + Args: + job_id: The ID of the corresponding CDS job. + dst_fp: Path to the destination GRIB file. + """ + src_fp = self.cache_dir / f"{job_id}.grib.gz" + if not src_fp.exists(): + raise FileNotFoundError(f"Cached file not found: {src_fp}") + with gzip.open(src_fp, "rb") as src_f: + with open(dst_fp, "wb") as dst_f: + shutil.copyfileobj(src_f, dst_f) + + def set(self, request: Request, job_id: str, file_path: Path | None = None) -> None: + """Store a data request in the cache. + + Data request info and metadata are stored in the database. If available, the + downloaded GRIB file is archived in the cache directory. + + Args: + request: The data request parameters. + job_id: The ID of the corresponding CDS job. + file_path: Optional path to the downloaded file to be cached. + """ + if file_path: + self._archive(file_path, job_id) + file_name = f"{job_id}.grib.gz" + else: + file_name = None + + cache_key = _hash_data_request(request) + with psycopg.connect(self.database_uri) as conn: + with conn.cursor() as cur: + cur.execute( + """--sql + insert into era5.cds_cache ( + cache_key, request, job_id, file_name + ) values (%s, %s, %s, %s) + on conflict (cache_key) do update set + job_id = excluded.job_id, + file_name = excluded.file_name, + updated_at = now() + """, + (cache_key, json.dumps(request), job_id, file_name), + ) + conn.commit() + + def get(self, request: Request) -> CacheEntry | None: + """Retrieve a data request from the cache. + + Args: + request: The data request parameters. + """ + cache_key = _hash_data_request(request) + with psycopg.connect(self.database_uri) as conn: + with conn.cursor(row_factory=class_row(CacheEntry)) as cur: + cur.execute( + """--sql + select job_id, file_name from era5.cds_cache + where cache_key = %s + """, + (cache_key,), + ) + return cur.fetchone() + + def clean_expired_jobs(self, job_ids: list[str]) -> None: + """Remove cache entries associated with expired jobs. + + NB: The entry is only removed if the associated GRIB file has not been archived + yet. + + Args: + job_ids: The IDs of the expired CDS jobs. + """ + with psycopg.connect(self.database_uri) as conn: + with conn.cursor() as cur: + cur.execute( + """--sql + delete from era5.cds_cache + where job_id = any(%s) and file_name is null + """, + (job_ids,), + ) + conn.commit() + + def clean_missing_files(self) -> None: + """Remove cache entries with missing archived files.""" + with psycopg.connect(self.database_uri) as conn: + with conn.cursor(row_factory=class_row(CacheEntry)) as cur: + cur.execute( + """--sql + select job_id, file_name from era5.cds_cache + where file_name is not null + """ + ) + entries = cur.fetchall() + + missing_job_ids: list[str] = [] + for entry in entries: + if entry.file_name and not (self.cache_dir / entry.file_name).exists(): + missing_job_ids.append(entry.job_id) + + if missing_job_ids: + with conn.cursor() as cur: + cur.execute( + """--sql + delete from era5.cds_cache + where job_id = any(%s) + """, + (missing_job_ids,), + ) + conn.commit() diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index 412067bd..889b4356 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -4,12 +4,15 @@ move GRIB data to an analysis-ready Zarr store for further processing. """ +import hashlib import importlib.resources +import json import logging import shutil import tempfile import tomllib from collections import defaultdict +from dataclasses import dataclass from datetime import date from pathlib import Path from time import sleep @@ -17,44 +20,17 @@ import numpy as np import numpy.typing as npt +import psycopg import xarray as xr import zarr from dateutil.relativedelta import relativedelta from ecmwf.datastores import Remote from ecmwf.datastores.client import Client -logger = logging.getLogger(__name__) - - -class Variable(TypedDict): - """Metadata for a single variable in the ERA5-Land dataset.""" - - name: str - short_name: str - unit: str - time: list[str] - accumulated: bool - - -class Request(TypedDict): - """Request parameters for the 'reanalysis-era5-land' dataset.""" +from openhexa.toolbox.era5.cache import Cache +from openhexa.toolbox.era5.models import Job, Request, RequestTemporal, Variable - variable: list[str] - year: str - month: str - day: list[str] - time: list[str] - data_format: Literal["grib", "netcdf"] - download_format: Literal["unarchived", "zip"] - area: list[int] - - -class RequestTemporal(TypedDict): - """Temporal request parameters.""" - - year: str - month: str - day: list[str] +logger = logging.getLogger(__name__) def _get_variables() -> dict[str, Variable]: @@ -254,6 +230,23 @@ def prepare_requests( return requests +def find_jobs(client: Client) -> list[Job]: + """Get the list of current jobs from the CDS API. + + NB: Jobs with expired results are filtered out and we only search for the latest 100 + jobs. + + Args: + client: CDS API client. + + Returns: + A list of submitted jobs. + + """ + r = client.get_jobs(limit=100, sortby="-created", status=["accepted", "running", "successful"]) + return [Job(**job) for job in r.json["jobs"]] + + def _submit_requests( client: Client, collection_id: str, @@ -314,27 +307,104 @@ def retrieve_requests( dataset_id: str, requests: list[Request], dst_dir: Path, + cache: Cache | None = None, wait: int = 30, ) -> None: - """Retrieve the results of the submitted requests. + """Submit and retrieve the results of data requests. Args: client: The CDS API client. dataset_id: The ID of the dataset to retrieve. requests: The list of requests to retrieve. dst_dir: The directory containing the source data files. + cache: Optional Cache to use for caching downloaded files. wait: Time in seconds to wait between checking for completed requests. """ - logger.debug("Submitting %s requests", len(requests)) - remotes = _submit_requests( - client=client, - collection_id=dataset_id, - requests=requests, - ) + logger.debug("Retrieving %s data requests", len(requests)) + + # If using cache, check for already downloaded files and already submitted + # data requests before submitting new requests + if cache: + triage = _triage_requests(client, cache, requests) + for job_id in triage.downloaded: + cache.retrieve(job_id, dst_dir / f"{job_id}.grib") + logger.info("Retrieved file %s from cache", f"{job_id}.grib") + remotes = triage.submitted + remotes += _submit_requests( + client=client, + collection_id=dataset_id, + requests=triage.to_submit, + ) + + # If not using cache, submit all requests directly + else: + remotes = _submit_requests( + client=client, + collection_id=dataset_id, + requests=requests, + ) + while remotes: remotes = _retrieve_remotes(remotes, dst_dir) - sleep(wait) + if remotes: + sleep(wait) + + +@dataclass +class TriageResult: + """Result of triaging data requests after checking the cache. + + Attributes: + downloaded: Job IDs of already downloaded requests. + submitted: Remote objects of already submitted requests. + to_submit: Data requests that still need to be submitted. + """ + + downloaded: list[str] + submitted: list[Remote] + to_submit: list[Request] + + +def _triage_requests(client: Client, cache: Cache, requests: list[Request]) -> TriageResult: + """Triage the requests into downloaded, submitted, and to_submit categories. + + Args: + client: The CDS API client. + cache: The cache to use for checking existing downloads. + requests: The list of requests to triage. + + Returns: + A TriageResult object containing the triaged requests. + """ + result = TriageResult( + downloaded=[], + submitted=[], + to_submit=[], + ) + + jobs = find_jobs(client) + cache.clean_expired_jobs(job_ids=[job.jobID for job in jobs if job.expired]) + cache.clean_missing_files() + + for request in requests: + entry = cache.get(request) + if entry and entry.file_name: + result.downloaded.append(entry.job_id) + elif entry: + remote = client.get_remote(entry.job_id) + result.submitted.append(remote) + else: + result.to_submit.append(request) + + logger.debug( + "Triage result: %s downloaded, %s submitted, %s to submit", + len(result.downloaded), + len(result.submitted), + len(result.to_submit), + ) + + return result def _variable_is_in_zarr(zarr_store: Path, data_var: str) -> bool: diff --git a/openhexa/toolbox/era5/models.py b/openhexa/toolbox/era5/models.py new file mode 100644 index 00000000..f28d4e70 --- /dev/null +++ b/openhexa/toolbox/era5/models.py @@ -0,0 +1,91 @@ +from dataclasses import dataclass +from typing import Literal, TypedDict + + +class Variable(TypedDict): + """Metadata for a single variable in the ERA5-Land dataset.""" + + name: str + short_name: str + unit: str + time: list[str] + accumulated: bool + + +class Request(TypedDict): + """Request parameters for the 'reanalysis-era5-land' dataset.""" + + variable: list[str] + year: str + month: str + day: list[str] + time: list[str] + data_format: Literal["grib", "netcdf"] + download_format: Literal["unarchived", "zip"] + area: list[int] + + +class RequestTemporal(TypedDict): + """Temporal request parameters.""" + + year: str + month: str + day: list[str] + + +class JobLink(TypedDict): + """A link related to a data request job.""" + + href: str + rel: str + type: str | None + title: str | None + + +class JobMetadataResults(TypedDict): + """Metadata about the results of a data request job.""" + + type: str + title: str + status: int + detail: str + trace_id: str + + +class JobMetadata(TypedDict): + """Metadata about a data request job.""" + + results: JobMetadataResults + datasetMetadata: dict[str, str] + qos: dict[str, dict] + origin: str + + +@dataclass +class Job: + """A data request job in the CDS.""" + + processID: str + type: str + jobID: str + status: str + created: str + started: str + finished: str + updated: str + links: list[JobLink] + metadata: JobMetadata + + @property + def expired(self) -> bool: + """Whether the job results have expired. + + Means that a data request has been successfully processed by the server, + but the results expired and cannot be downloaded anymore. This doesn't change + the status, we have to dig into job metadata for this info. + """ + if "results" in self.metadata: + if "type" in self.metadata["results"]: + if self.metadata["results"]["type"] == "results expired": + return True + return False From 7aacd58dd8cd7998f3142abce68991a50f69f2ee Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 24 Oct 2025 10:48:23 +0200 Subject: [PATCH 38/51] Remove unused imports --- openhexa/toolbox/era5/extract.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index 889b4356..cc2554dd 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -4,9 +4,7 @@ move GRIB data to an analysis-ready Zarr store for further processing. """ -import hashlib import importlib.resources -import json import logging import shutil import tempfile @@ -16,11 +14,10 @@ from datetime import date from pathlib import Path from time import sleep -from typing import Literal, TypedDict +from typing import Literal import numpy as np import numpy.typing as npt -import psycopg import xarray as xr import zarr from dateutil.relativedelta import relativedelta From 47e1e82742dcb2b0b4cebd090c4d25d0ee1f30db Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 24 Oct 2025 11:14:07 +0200 Subject: [PATCH 39/51] Add psycopg dependency --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 03e6efaf..8316b996 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "openhexa.sdk", "humanize", "rich", + "psycopg>=3.2.11", ] [project.optional-dependencies] @@ -71,7 +72,7 @@ lineage = [ "openlineage-python >=1.33.0" ] -all = ["openhexa.toolbox[era5,lineage]"] +all = [] [tool.setuptools] include-package-data = true From ef1e86ef0ec0f797cc8afeb77f24c5ce1498ce03 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 24 Oct 2025 11:31:54 +0200 Subject: [PATCH 40/51] Remove sql comments --- openhexa/toolbox/era5/cache.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/openhexa/toolbox/era5/cache.py b/openhexa/toolbox/era5/cache.py index c5e111ef..4c9b6c7c 100644 --- a/openhexa/toolbox/era5/cache.py +++ b/openhexa/toolbox/era5/cache.py @@ -43,9 +43,9 @@ def _init_db(self) -> None: """Create schema and table if they do not exist.""" with psycopg.connect(self.database_uri) as conn: with conn.cursor() as cur: - cur.execute("--sql create schema if not exists era5") + cur.execute("create schema if not exists era5") cur.execute( - """--sql + """ create table if not exists era5.cds_cache ( cache_key varchar(32) primary key, request json not null, @@ -58,7 +58,7 @@ def _init_db(self) -> None: """ ) cur.execute( - """--sql + """ create index if not exists idx_cds_cache_expires on era5.cds_cache(expires_at) """ @@ -116,7 +116,7 @@ def set(self, request: Request, job_id: str, file_path: Path | None = None) -> N with psycopg.connect(self.database_uri) as conn: with conn.cursor() as cur: cur.execute( - """--sql + """ insert into era5.cds_cache ( cache_key, request, job_id, file_name ) values (%s, %s, %s, %s) @@ -139,7 +139,7 @@ def get(self, request: Request) -> CacheEntry | None: with psycopg.connect(self.database_uri) as conn: with conn.cursor(row_factory=class_row(CacheEntry)) as cur: cur.execute( - """--sql + """ select job_id, file_name from era5.cds_cache where cache_key = %s """, @@ -159,7 +159,7 @@ def clean_expired_jobs(self, job_ids: list[str]) -> None: with psycopg.connect(self.database_uri) as conn: with conn.cursor() as cur: cur.execute( - """--sql + """ delete from era5.cds_cache where job_id = any(%s) and file_name is null """, @@ -172,7 +172,7 @@ def clean_missing_files(self) -> None: with psycopg.connect(self.database_uri) as conn: with conn.cursor(row_factory=class_row(CacheEntry)) as cur: cur.execute( - """--sql + """ select job_id, file_name from era5.cds_cache where file_name is not null """ @@ -187,7 +187,7 @@ def clean_missing_files(self) -> None: if missing_job_ids: with conn.cursor() as cur: cur.execute( - """--sql + """ delete from era5.cds_cache where job_id = any(%s) """, From a825a0a28b593680781ed374e2620ca43f560f8e Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 24 Oct 2025 14:54:49 +0200 Subject: [PATCH 41/51] Further optimizations to zarr storage --- openhexa/toolbox/era5/cache.py | 9 +-- openhexa/toolbox/era5/extract.py | 128 +++++++++++++++++-------------- openhexa/toolbox/era5/utils.py | 33 ++++++++ 3 files changed, 107 insertions(+), 63 deletions(-) create mode 100644 openhexa/toolbox/era5/utils.py diff --git a/openhexa/toolbox/era5/cache.py b/openhexa/toolbox/era5/cache.py index 4c9b6c7c..e6a4e9a2 100644 --- a/openhexa/toolbox/era5/cache.py +++ b/openhexa/toolbox/era5/cache.py @@ -69,14 +69,13 @@ def _init_cache_dir(self) -> None: """Create cache directory if it does not exist.""" self.cache_dir.mkdir(parents=True, exist_ok=True) - def _archive(self, src_fp: Path, job_id: str) -> None: + def _archive(self, src_fp: Path) -> None: """Archive a GRIB file using gzip. Args: src_fp: Path to the source GRIB file. - job_id: The ID of the corresponding CDS job. """ - dst_fp = self.cache_dir / f"{job_id}.grib.gz" + dst_fp = self.cache_dir / f"{src_fp.name}.gz" with open(src_fp, "rb") as src_f: with gzip.open(dst_fp, "wb", compresslevel=9) as dst_f: shutil.copyfileobj(src_f, dst_f) @@ -107,8 +106,8 @@ def set(self, request: Request, job_id: str, file_path: Path | None = None) -> N file_path: Optional path to the downloaded file to be cached. """ if file_path: - self._archive(file_path, job_id) - file_name = f"{job_id}.grib.gz" + self._archive(file_path) + file_name = f"{file_path.name}.gz" else: file_name = None diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index cc2554dd..e1b3afa1 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -4,11 +4,8 @@ move GRIB data to an analysis-ready Zarr store for further processing. """ -import importlib.resources import logging import shutil -import tempfile -import tomllib from collections import defaultdict from dataclasses import dataclass from datetime import date @@ -25,38 +22,12 @@ from ecmwf.datastores.client import Client from openhexa.toolbox.era5.cache import Cache -from openhexa.toolbox.era5.models import Job, Request, RequestTemporal, Variable +from openhexa.toolbox.era5.models import Job, Request, RequestTemporal +from openhexa.toolbox.era5.utils import get_name, get_variables logger = logging.getLogger(__name__) -def _get_variables() -> dict[str, Variable]: - """Load ERA5-Land variables metadata. - - Returns: - A dictionary mapping variable names to their metadata. - - """ - with importlib.resources.files("openhexa.toolbox.era5").joinpath("data/variables.toml").open("rb") as f: - return tomllib.load(f) - - -def _get_name(remote: Remote) -> str: - """Create file name from remote request. - - Returns: - File name with format: {year}{month}_{request_id}.{ext} - - """ - request = remote.request - data_format = request["data_format"] - download_format = request["download_format"] - year = request["year"] - month = request["month"] - ext = "zip" if download_format == "zip" else data_format - return f"{year}{month}_{remote.request_id}.{ext}" - - def get_date_range( start_date: date, end_date: date, @@ -195,7 +166,7 @@ def prepare_requests( A list of requests to be submitted to the CDS API. """ - variables = _get_variables() + variables = get_variables() if variable not in variables: msg = f"Variable '{variable}' not supported" raise ValueError(msg) @@ -290,7 +261,7 @@ def _retrieve_remotes( for remote in queue: if remote.results_ready: - name = _get_name(remote) + name = get_name(remote) fp = output_dir / name remote.download(target=fp.as_posix()) logger.info("Downloaded %s", name) @@ -523,6 +494,24 @@ def _flatten_time_dimension(ds: xr.Dataset) -> xr.Dataset: return ds +def _prepare_for_zarr(ds: xr.Dataset) -> xr.Dataset: + """Prepare dataset for zarr storage by setting optimal chunks. + + Args: + ds: The xarray Dataset to prepare (after all transformations) + + Returns: + Dataset with optimal chunking for zarr storage. + """ + # Clear previous encoding for all data vars + for var in ds.data_vars: + ds[var].encoding.pop("chunks", None) + + chunks = {"time": 30, "latitude": -1, "longitude": -1} + + return ds.chunk(chunks) + + def _create_zarr(ds: xr.Dataset, zarr_store: Path) -> None: """Create a new zarr store from the dataset. @@ -533,6 +522,7 @@ def _create_zarr(ds: xr.Dataset, zarr_store: Path) -> None: """ if zarr_store.exists(): raise ValueError(f"Zarr store {zarr_store} already exists") + ds = _prepare_for_zarr(ds) ds.to_zarr(zarr_store, mode="w", consolidated=True, zarr_format=2) logger.debug("Created Zarr store at %s", zarr_store) @@ -548,7 +538,20 @@ def _append_zarr(ds: xr.Dataset, zarr_store: Path, data_var: str) -> None: data_var: Name of the variable to append. """ - if data_var in xr.open_zarr(zarr_store).data_vars: + existing_ds = xr.open_zarr(zarr_store, consolidated=True, decode_timedelta=False) + + # Validate that lat/lon coordinates match + if not np.array_equal(existing_ds.latitude.values, ds.latitude.values): + msg = f"Latitude coordinates don't match with zarr store {zarr_store.name}" + logger.error(msg) + raise ValueError(msg) + if not np.array_equal(existing_ds.longitude.values, ds.longitude.values): + msg = f"Longitude coordinates don't match with zarr store {zarr_store.name}" + logger.error(msg) + raise ValueError(msg) + + # Check for overlapping times and only append non-overlapping data + if data_var in existing_ds.data_vars: existing_times = _list_times_in_zarr(zarr_store, data_var) new_times = ds.time.values overlap = np.isin(new_times, existing_times) @@ -558,6 +561,9 @@ def _append_zarr(ds: xr.Dataset, zarr_store: Path, data_var: str) -> None: if len(ds.time) == 0: logger.debug("No new data to add to Zarr store") return + + ds = _prepare_for_zarr(ds) + if data_var in existing_ds.data_vars: ds.to_zarr(zarr_store, mode="a", append_dim="time", zarr_format=2) else: ds.to_zarr(zarr_store, mode="a", zarr_format=2) @@ -577,32 +583,12 @@ def _consolidate_zarr(zarr_store: Path) -> None: """ zarr.consolidate_metadata(zarr_store) ds = xr.open_zarr(zarr_store, consolidated=True, decode_timedelta=False) - ds_sorted = ds.sortby("time") - if not np.array_equal(ds.time.values, ds_sorted.time.values): - logger.debug("Sorting time dimension in Zarr store") - with tempfile.TemporaryDirectory() as tmp_dir: - tmp_zarr_store = Path(tmp_dir) / zarr_store.name - ds_sorted.to_zarr(tmp_zarr_store, mode="w", consolidated=True, zarr_format=2) - shutil.rmtree(zarr_store) - shutil.move(tmp_zarr_store, zarr_store) - else: - zarr.consolidate_metadata(zarr_store) - - -def _validate_zarr(zarr_store: Path) -> None: - """Validate the zarr store by checking for duplicate or missing time values. - - Args: - zarr_store: Path to the zarr store to validate. - Raises: - RuntimeError: If duplicate or inconsistent time values are found. - """ - ds = xr.open_zarr(zarr_store, consolidated=True, decode_timedelta=False) + # Validate input dataset (duplicate time, inconsistent steps per day) for data_var in ds.data_vars: times = ds.time.values if len(times) != len(np.unique(times)): - msg = f"Zarr store {zarr_store} has duplicate time values for variable {data_var}" + msg = f"Duplicate time values found in Zarr store {zarr_store.name} for variable {data_var}" raise RuntimeError(msg) dates = times.astype("datetime64[D]") _, counts = np.unique(dates, return_counts=True) @@ -611,6 +597,32 @@ def _validate_zarr(zarr_store: Path) -> None: msg = f"Inconsistent steps per day found: {unique_counts}\nExpected all days to have {counts[0]} steps" raise RuntimeError(msg) + # Make sure time dimension is sorted + ds_sorted = ds.sortby("time") + if not np.array_equal(ds.time.values, ds_sorted.time.values): + logger.warning("Time dimension is unsorted, rewriting zarr store") + ds_sorted = _prepare_for_zarr(ds_sorted) + _safe_rewrite_zarr(ds_sorted, zarr_store) + + +def _safe_rewrite_zarr(ds: xr.Dataset, zarr_store: Path) -> None: + """Safely rewrite a zarr store with backup.""" + backup = zarr_store.parent / f"{zarr_store.name}.backup" + + for var in ds.data_vars: + ds[var].encoding.pop("chunks", None) + + try: + shutil.move(zarr_store, backup) + ds.to_zarr(zarr_store, mode="w", consolidated=True, zarr_format=2) + shutil.rmtree(backup) + except Exception as e: + if backup.exists(): + if zarr_store.exists(): + shutil.rmtree(zarr_store) + shutil.move(backup, zarr_store) + raise e + def _diff_zarr( start_date: date, @@ -697,12 +709,13 @@ def grib_to_zarr( data_var: Short name of the variable to process (e.g. "t2m", "tp", "swvl1"). """ - for fp in src_dir.glob("*.grib"): + for fp in sorted(src_dir.glob("*.grib")): logger.info("Processing GRIB file %s", fp.name) ds = xr.open_dataset(fp, engine="cfgrib", decode_timedelta=False) ds = _clean_dims_and_coords(ds) ds = _drop_incomplete_days(ds, data_var=data_var) ds = _flatten_time_dimension(ds) + if not zarr_store.exists(): logger.debug("Creating new Zarr store '%s'", zarr_store.name) _create_zarr(ds, zarr_store) @@ -712,4 +725,3 @@ def grib_to_zarr( logger.debug("Consolidating Zarr store '%s'", zarr_store.name) _consolidate_zarr(zarr_store) logger.debug("Validating Zarr store '%s'", zarr_store.name) - _validate_zarr(zarr_store) diff --git a/openhexa/toolbox/era5/utils.py b/openhexa/toolbox/era5/utils.py new file mode 100644 index 00000000..f5191c0d --- /dev/null +++ b/openhexa/toolbox/era5/utils.py @@ -0,0 +1,33 @@ +import importlib.resources +import tomllib + +from ecmwf.datastores import Remote + +from openhexa.toolbox.era5.models import Variable + + +def get_name(remote: Remote) -> str: + """Create file name from remote request. + + Returns: + File name with format: {year}{month}_{request_id}.{ext} + + """ + request = remote.request + data_format = request["data_format"] + download_format = request["download_format"] + year = request["year"] + month = request["month"] + ext = "zip" if download_format == "zip" else data_format + return f"{year}{month}_{remote.request_id}.{ext}" + + +def get_variables() -> dict[str, Variable]: + """Load ERA5-Land variables metadata. + + Returns: + A dictionary mapping variable names to their metadata. + + """ + with importlib.resources.files("openhexa.toolbox.era5").joinpath("data/variables.toml").open("rb") as f: + return tomllib.load(f) From 3e77f7d2022266a6d47a80c7ac98eb901c44bcdf Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 24 Oct 2025 15:37:18 +0200 Subject: [PATCH 42/51] Load new dataset into memory before appending --- openhexa/toolbox/era5/extract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index e1b3afa1..c41d91ec 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -562,7 +562,7 @@ def _append_zarr(ds: xr.Dataset, zarr_store: Path, data_var: str) -> None: logger.debug("No new data to add to Zarr store") return - ds = _prepare_for_zarr(ds) + ds = ds.load() if data_var in existing_ds.data_vars: ds.to_zarr(zarr_store, mode="a", append_dim="time", zarr_format=2) else: From 58fc592aae63a74d0147e93e5ebd91f848746184 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 24 Oct 2025 15:41:42 +0200 Subject: [PATCH 43/51] Set cache when downloading --- openhexa/toolbox/era5/extract.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index c41d91ec..54bcc176 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -242,15 +242,13 @@ def _submit_requests( return remotes -def _retrieve_remotes( - queue: list[Remote], - output_dir: Path, -) -> list[Remote]: +def _retrieve_remotes(queue: list[Remote], output_dir: Path, cache: Cache) -> list[Remote]: """Retrieve the results of the submitted remotes. Args: queue: List of Remote objects to check and download if ready. output_dir: Directory to save downloaded files. + cache: Cache to update with downloaded files. Returns: List of Remote objects that are still pending (not ready). @@ -264,6 +262,7 @@ def _retrieve_remotes( name = get_name(remote) fp = output_dir / name remote.download(target=fp.as_posix()) + cache.set(request=Request(**remote.request), job_id=remote.request_id, file_path=fp) logger.info("Downloaded %s", name) else: pending.append(remote) From b3e2a992bde7152fb57ffb32cecaeaf4741deb96 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 24 Oct 2025 15:45:18 +0200 Subject: [PATCH 44/51] Fix cache setting --- openhexa/toolbox/era5/extract.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index 54bcc176..e6061067 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -242,13 +242,13 @@ def _submit_requests( return remotes -def _retrieve_remotes(queue: list[Remote], output_dir: Path, cache: Cache) -> list[Remote]: +def _retrieve_remotes(queue: list[Remote], output_dir: Path, cache: Cache | None = None) -> list[Remote]: """Retrieve the results of the submitted remotes. Args: queue: List of Remote objects to check and download if ready. output_dir: Directory to save downloaded files. - cache: Cache to update with downloaded files. + cache: Cache to use for caching downloaded files (optional). Returns: List of Remote objects that are still pending (not ready). @@ -284,8 +284,8 @@ def retrieve_requests( dataset_id: The ID of the dataset to retrieve. requests: The list of requests to retrieve. dst_dir: The directory containing the source data files. - cache: Optional Cache to use for caching downloaded files. - wait: Time in seconds to wait between checking for completed requests. + cache: Cache to use for caching downloaded files (optional). + wait: Seconds to wait between checks for completed requests (default=30). """ logger.debug("Retrieving %s data requests", len(requests)) @@ -313,7 +313,7 @@ def retrieve_requests( ) while remotes: - remotes = _retrieve_remotes(remotes, dst_dir) + remotes = _retrieve_remotes(remotes, dst_dir, cache=cache) if remotes: sleep(wait) From f53f24dbcfb447ef665e0f8b51acfa4203b81928 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 24 Oct 2025 15:53:12 +0200 Subject: [PATCH 45/51] Don't cache if cache is set to None --- openhexa/toolbox/era5/extract.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index e6061067..d6076eb8 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -262,7 +262,8 @@ def _retrieve_remotes(queue: list[Remote], output_dir: Path, cache: Cache | None name = get_name(remote) fp = output_dir / name remote.download(target=fp.as_posix()) - cache.set(request=Request(**remote.request), job_id=remote.request_id, file_path=fp) + if cache: + cache.set(request=Request(**remote.request), job_id=remote.request_id, file_path=fp) logger.info("Downloaded %s", name) else: pending.append(remote) From a62a54f426bf628ddb3041e1808b04646cd2567a Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 24 Oct 2025 15:56:36 +0200 Subject: [PATCH 46/51] Retrieve by filename instead of jobid --- openhexa/toolbox/era5/cache.py | 6 +++--- openhexa/toolbox/era5/extract.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openhexa/toolbox/era5/cache.py b/openhexa/toolbox/era5/cache.py index e6a4e9a2..858bbfa7 100644 --- a/openhexa/toolbox/era5/cache.py +++ b/openhexa/toolbox/era5/cache.py @@ -80,14 +80,14 @@ def _archive(self, src_fp: Path) -> None: with gzip.open(dst_fp, "wb", compresslevel=9) as dst_f: shutil.copyfileobj(src_f, dst_f) - def retrieve(self, job_id: str, dst_fp: Path) -> None: + def retrieve(self, file_name: str, dst_fp: Path) -> None: """Retrieve a GRIB file from a gzip archive. Args: - job_id: The ID of the corresponding CDS job. + file_name: The name of the cached GRIB file. dst_fp: Path to the destination GRIB file. """ - src_fp = self.cache_dir / f"{job_id}.grib.gz" + src_fp = self.cache_dir / f"{file_name}.gz" if not src_fp.exists(): raise FileNotFoundError(f"Cached file not found: {src_fp}") with gzip.open(src_fp, "rb") as src_f: diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index d6076eb8..b7daa479 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -295,9 +295,9 @@ def retrieve_requests( # data requests before submitting new requests if cache: triage = _triage_requests(client, cache, requests) - for job_id in triage.downloaded: - cache.retrieve(job_id, dst_dir / f"{job_id}.grib") - logger.info("Retrieved file %s from cache", f"{job_id}.grib") + for file_name in triage.downloaded: + cache.retrieve(file_name, dst_dir / file_name) + logger.info("Retrieved file %s from cache", file_name) remotes = triage.submitted remotes += _submit_requests( client=client, @@ -358,7 +358,7 @@ def _triage_requests(client: Client, cache: Cache, requests: list[Request]) -> T for request in requests: entry = cache.get(request) if entry and entry.file_name: - result.downloaded.append(entry.job_id) + result.downloaded.append(entry.file_name) elif entry: remote = client.get_remote(entry.job_id) result.submitted.append(remote) From e1e1b629d295c6af2d33ddfb968802d4be204669 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Fri, 24 Oct 2025 15:59:40 +0200 Subject: [PATCH 47/51] Fix gz extension --- openhexa/toolbox/era5/cache.py | 2 +- openhexa/toolbox/era5/extract.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/openhexa/toolbox/era5/cache.py b/openhexa/toolbox/era5/cache.py index 858bbfa7..796e09ab 100644 --- a/openhexa/toolbox/era5/cache.py +++ b/openhexa/toolbox/era5/cache.py @@ -87,7 +87,7 @@ def retrieve(self, file_name: str, dst_fp: Path) -> None: file_name: The name of the cached GRIB file. dst_fp: Path to the destination GRIB file. """ - src_fp = self.cache_dir / f"{file_name}.gz" + src_fp = self.cache_dir / file_name if not src_fp.exists(): raise FileNotFoundError(f"Cached file not found: {src_fp}") with gzip.open(src_fp, "rb") as src_f: diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index b7daa479..dca8b889 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -296,7 +296,8 @@ def retrieve_requests( if cache: triage = _triage_requests(client, cache, requests) for file_name in triage.downloaded: - cache.retrieve(file_name, dst_dir / file_name) + dst_fp = dst_dir / file_name.replace(".gz", "") + cache.retrieve(file_name, dst_fp) logger.info("Retrieved file %s from cache", file_name) remotes = triage.submitted remotes += _submit_requests( From c68bf9ef1cd93d832b5c7919e7fad936c91d4dc4 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Tue, 28 Oct 2025 14:48:35 +0100 Subject: [PATCH 48/51] Handle water bodies edge case --- openhexa/toolbox/era5/extract.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/openhexa/toolbox/era5/extract.py b/openhexa/toolbox/era5/extract.py index dca8b889..01cb5004 100644 --- a/openhexa/toolbox/era5/extract.py +++ b/openhexa/toolbox/era5/extract.py @@ -453,12 +453,11 @@ def _clean_dims_and_coords(ds: xr.Dataset) -> xr.Dataset: def _drop_incomplete_days(ds: xr.Dataset, data_var: str) -> xr.Dataset: - """Drop days with incomplete data from the dataset. + """Drop days with incomplete temporal steps from the dataset. - Days at the boundaries of the data request might have incomplete data. Ex: 1st day - with data only for the last step, or last day with missing data for the last step. - We only keep days with complete data to avoid having to deal with missing values & - partial appends. + A day is incomplete if any temporal step (hour) has completely missing data + across all spatial points. Days with spatial nulls (e.g., water bodies) are + kept as long as each temporal step has at least some valid data. Args: ds: The xarray dataset to process. @@ -467,8 +466,9 @@ def _drop_incomplete_days(ds: xr.Dataset, data_var: str) -> xr.Dataset: Returns: The xarray dataset with incomplete days removed. """ - complete_times = ~ds[data_var].isnull().any(dim=["step", "latitude", "longitude"]) - return ds.sel(time=complete_times) + has_valid_data = ~ds[data_var].isnull().all(dim=["latitude", "longitude"]) + all_steps_complete = has_valid_data.all(dim="step") + return ds.sel(time=all_steps_complete) def _flatten_time_dimension(ds: xr.Dataset) -> xr.Dataset: @@ -588,6 +588,9 @@ def _consolidate_zarr(zarr_store: Path) -> None: # Validate input dataset (duplicate time, inconsistent steps per day) for data_var in ds.data_vars: times = ds.time.values + if len(times) == 0: + msg = f"Zarr store {zarr_store.name} has not time values for variable {data_var}" + raise RuntimeError(msg) if len(times) != len(np.unique(times)): msg = f"Duplicate time values found in Zarr store {zarr_store.name} for variable {data_var}" raise RuntimeError(msg) @@ -714,7 +717,13 @@ def grib_to_zarr( logger.info("Processing GRIB file %s", fp.name) ds = xr.open_dataset(fp, engine="cfgrib", decode_timedelta=False) ds = _clean_dims_and_coords(ds) + if ds[data_var].isnull().all(): + logger.warning("GRIB file %s is completely empty, skipping", fp.name) + continue ds = _drop_incomplete_days(ds, data_var=data_var) + if len(ds.time) == 0: + logger.warning("All days dropped from %s after filtering, skipping", fp.name) + continue ds = _flatten_time_dimension(ds) if not zarr_store.exists(): From eae7ba73f98912db1bf268f5e84ba01728d0dc7c Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Thu, 30 Oct 2025 09:33:00 +0100 Subject: [PATCH 49/51] Add __init__.py to toolbox dir --- openhexa/toolbox/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 openhexa/toolbox/__init__.py diff --git a/openhexa/toolbox/__init__.py b/openhexa/toolbox/__init__.py new file mode 100644 index 00000000..e69de29b From f358afa3b1d255439a85e930d895ab56dcd5ce79 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Thu, 30 Oct 2025 10:05:33 +0100 Subject: [PATCH 50/51] Fix namespace packaging --- openhexa/toolbox/__init__.py | 0 pyproject.toml | 9 +-------- 2 files changed, 1 insertion(+), 8 deletions(-) delete mode 100644 openhexa/toolbox/__init__.py diff --git a/openhexa/toolbox/__init__.py b/openhexa/toolbox/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/pyproject.toml b/pyproject.toml index 8316b996..2f039a27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,14 +79,7 @@ include-package-data = true [tool.setuptools.packages.find] where = ["."] -include = [ - "openhexa.toolbox.dhis2", - "openhexa.toolbox.era5", - "openhexa.toolbox.hexa", - "openhexa.toolbox.iaso", - "openhexa.toolbox.kobo", - "openhexa.toolbox.lineage", -] +include = ["openhexa.toolbox.*"] namespaces = true [tool.setuptools.package-data] From 53c8438214dfbbc64537c1bd48a34001c65b8963 Mon Sep 17 00:00:00 2001 From: Yann Forget Date: Thu, 30 Oct 2025 10:40:39 +0100 Subject: [PATCH 51/51] Bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2f039a27..ed358e75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "openhexa.toolbox" -version = "2.9.0" +version = "2.10.0" description = "A set of tools to acquire & process data from various sources" authors = [{ name = "Bluesquare", email = "dev@bluesquarehub.com" }] maintainers = [{ name = "Bluesquare", email = "dev@bluesquarehub.com" }]