Last active
October 7, 2026 15:23
-
-
Save gcr/4a18224cd05f26c88b08a62e2e607ef0 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env -S uv run --script | |
| # /// script | |
| # requires-python = ">=3.12" | |
| # dependencies = [ | |
| # "cyclopts>=4.23", | |
| # "flox>=0.11.2", | |
| # "h3ronpy>=0.22", | |
| # "numpy>=2", | |
| # "pandas>=2.3", | |
| # "pyarrow>=24", | |
| # "pyproj>=3.7.2", | |
| # "rasterio>=1.5.1", | |
| # "rich>=14", | |
| # "rioxarray>=0.23", | |
| # "xarray>=2026.7", | |
| # ] | |
| # [tool.uv] | |
| # exclude-newer = "2026-10-07T00:00:00Z" | |
| # /// | |
| """Average a weekly eBird Status & Trends GeoTIFF into H3 cells and write parquet. | |
| Each band must be described by its ISO week date, as in the weekly products | |
| (`<species>_<product>_{median,mean}_<res>_<year>.tif`). Seasonal rasters are | |
| rejected. Nothing else is read: species, product and review status are left to | |
| downstream code (e.g. from `tiff_basename` and the species `config.json`). | |
| Examples: | |
| uv run https://gist.github.com/gcr/4a18224cd05f26c88b08a62e2e607ef0/raw/8a45589f8fcfd82a81c9a55efa453d873ade481d/ebirdst_geotiff_to_parquet.py --help | |
| uv run ebirdst_geotiff_to_parquet.py abetow_occurrence_median_3km_2023.tif out.parquet | |
| Output columns, one row per (H3 cell, band) with at least one non-NaN pixel, | |
| sorted by `h3_level`, `h3_cell_id`, `band`: | |
| - `band`: 1-based band number in the GeoTIFF. | |
| - `week_of_year`: the band's ISO date (string). | |
| - `value`: mean of the non-NaN pixels whose centre falls in the cell. | |
| - `h3_cell_id` (int64), `h3_cell_id_str` (hex), `h3_level`. | |
| - `tiff_basename`: input file name. | |
| """ | |
| from datetime import date | |
| from pathlib import Path | |
| from typing import Annotated | |
| import flox.xarray | |
| import numpy as np | |
| import pandas as pd | |
| import rioxarray | |
| import xarray as xr | |
| from cyclopts import App, Parameter | |
| from h3ronpy.vector import coordinates_to_cells | |
| from pyproj import Transformer | |
| from rich import status | |
| app = App(help_format="markdown") | |
| def load_weekly_geotiff(src: Path) -> xr.DataArray: | |
| """Open a weekly GeoTIFF with nodata as NaN and a `week_of_year` band coordinate. | |
| Raises: | |
| ValueError: If any band description is missing or not an ISO date. | |
| """ | |
| data_arr = rioxarray.open_rasterio(src, masked=True, num_threads="all_cpus") | |
| names = data_arr.attrs.get("long_name") | |
| names = [names] if isinstance(names, str) else list(names or []) | |
| try: | |
| if len(names) != data_arr.sizes["band"]: | |
| raise ValueError | |
| weeks = [date.fromisoformat(name).isoformat() for name in names] | |
| except (TypeError, ValueError): | |
| data_arr.close() | |
| raise ValueError(f"Band descriptions must be ISO week dates: {src}") from None | |
| return data_arr.assign_coords(week_of_year=("band", weeks)) | |
| def h3_cell_indexes(data_arr: xr.DataArray, h3_levels: list[int]) -> xr.DataArray: | |
| """Return the uint64 H3 cell of each pixel centre, with dims `(h3_level, y, x)`.""" | |
| native_y, native_x = xr.broadcast(data_arr.y, data_arr.x) | |
| transformer = Transformer.from_crs(data_arr.rio.crs, "EPSG:4326", always_xy=True) | |
| longitude, latitude = transformer.transform(native_x.values, native_y.values) | |
| cells = [ | |
| np.asarray( | |
| coordinates_to_cells(latitude.ravel(), longitude.ravel(), level, radians=False) | |
| ).reshape(longitude.shape) | |
| for level in h3_levels | |
| ] | |
| return xr.DataArray( | |
| np.stack(cells), | |
| dims=("h3_level", "y", "x"), | |
| coords={"h3_level": h3_levels, "y": data_arr.y, "x": data_arr.x}, | |
| ) | |
| def h3_means(data_arr: xr.DataArray, h3_levels: list[int]) -> pd.DataFrame: | |
| """Average non-NaN pixels per (H3 cell, band) at each level; see module docstring.""" | |
| cells = h3_cell_indexes(data_arr, h3_levels) | |
| data_arr = data_arr.expand_dims(h3_level=cells.h3_level).assign_coords(cell_ids=cells) | |
| # Most pixels are NaN in every band; drop them before grouping to save memory. | |
| data_arr = data_arr.stack(pixel=("y", "x"), create_index=False).dropna( | |
| "pixel", how="all" | |
| ) | |
| columns = ["band", "week_of_year", "value", "h3_cell_id", "h3_cell_id_str", "h3_level"] | |
| if not data_arr.sizes["pixel"]: | |
| return pd.DataFrame(columns=columns) | |
| # All levels share one flattened cell_ids axis: the level is encoded in the id. | |
| grouped = flox.xarray.xarray_reduce( | |
| data_arr.drop_vars("spatial_ref", errors="ignore"), | |
| data_arr.cell_ids, | |
| dim=("h3_level", "pixel"), | |
| func="mean", | |
| skipna=True, | |
| fill_value=np.nan, | |
| ) | |
| frame = grouped.rename("value").to_dataframe().reset_index().dropna(subset=["value"]) | |
| ids = frame["cell_ids"].to_numpy(dtype=np.uint64) | |
| frame["h3_cell_id"] = ids.astype("int64") | |
| frame["h3_cell_id_str"] = pd.Series(ids, index=frame.index).map("{:x}".format) | |
| # H3 index bits 52-55 hold the resolution. | |
| frame["h3_level"] = ((ids >> np.uint64(52)) & np.uint64(0xF)).astype("int64") | |
| frame = frame[columns] | |
| return frame.sort_values(["h3_level", "h3_cell_id", "band"]).reset_index(drop=True) | |
| @app.default | |
| def convert( | |
| tiff: Path, | |
| output: Path | None = None, | |
| *, | |
| h3_levels: Annotated[tuple[int, ...], Parameter(consume_multiple=True)] = (2, 3, 4, 5), | |
| overwrite: Annotated[bool, Parameter(name=["--overwrite", "-f"])] = False, | |
| ) -> None: | |
| """Average one weekly eBird S&T GeoTIFF into H3 cells and write it as parquet. | |
| Parameters | |
| ---------- | |
| tiff | |
| Weekly eBird S&T GeoTIFF; band descriptions must be ISO dates. | |
| output | |
| Parquet path. Default: `tiff` with a `.parquet` suffix, next to the tiff. | |
| h3_levels | |
| H3 resolutions (0-15) to aggregate into. | |
| overwrite | |
| Replace an existing output. | |
| """ | |
| tiff = tiff.expanduser() | |
| if not tiff.exists(): | |
| raise SystemExit(f"No such file: {tiff}") | |
| if not h3_levels or len(set(h3_levels)) != len(h3_levels) or any( | |
| not 0 <= level <= 15 for level in h3_levels | |
| ): | |
| raise SystemExit("h3_levels must be distinct resolutions from 0 to 15") | |
| levels = sorted(h3_levels) | |
| output = (output or tiff.with_suffix(".parquet")).expanduser() | |
| if output.exists() and not overwrite: | |
| raise SystemExit(f"{output} exists; pass --overwrite/-f to replace it") | |
| try: | |
| raster = load_weekly_geotiff(tiff) | |
| except ValueError as e: | |
| raise SystemExit(str(e)) from None | |
| with raster, status.Status(f"Averaging {tiff.name} into H3 levels {levels}..."): | |
| frame = h3_means(raster, levels) | |
| frame["tiff_basename"] = tiff.name | |
| output.parent.mkdir(parents=True, exist_ok=True) | |
| temporary = output.with_suffix(".parquet.partial") | |
| try: | |
| frame.to_parquet(temporary, index=False) | |
| temporary.replace(output) | |
| finally: | |
| temporary.unlink(missing_ok=True) | |
| print(f"Wrote {len(frame):,} rows (H3 levels {levels}) to {output}") | |
| if __name__ == "__main__": | |
| app() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment