Source code for gedih3.egi.raster

# Copyright (C) 2026, University of Maryland. All Rights Reserved.
# Authors: Tiago de Conto, Amelia Grace Holcomb
# For commercial licensing inquiries, contact UM Ventures at umdtechtransfer@umd.edu

"""
EGI (EASE Grid Index) Raster Module

This module provides rasterization functions for converting EGI-indexed
GeoDataFrames to raster (xarray/GeoTIFF) format. The native alignment of
EGI with EASE-Grid 2.0 allows for direct rasterization without resampling.
"""
import logging
from typing import List, Optional
import numpy as np
import pandas as pd
import geopandas as gpd
import xarray as xr
import rioxarray  # Register .rio accessor for xarray
from affine import Affine
from rasterio import transform
from .config import RESOLUTIONS, OUTER_RES, OUTER_LEVEL, EGI_CRS_STRING, get_resolution
from .spatial import pixel_shape
from ..cliutils import filter_raster_columns as _filter_raster_columns
from ..exceptions import GediRasterizationError
from ..utils import object_series

logger = logging.getLogger(__name__)


[docs] def geodf_to_raster( geodf: gpd.GeoDataFrame, columns: Optional[List[str]] = None, fill_value: float = np.nan, outer_tile: Optional[int] = None ) -> xr.Dataset: """ Convert EGI-indexed GeoDataFrame to raster (xarray Dataset). This function creates a raster aligned to the EASE-Grid 2.0 projection, with each EGI pixel mapped directly to a corresponding raster cell using direct index assignment (no interpolation or extrapolation). The raster covers exactly ONE level-12 outer tile. When that tile is given explicitly (``outer_tile``), stray pixels from any other tile are skipped with a WARNING — their inner indices are positions within their own tile and would land at wrong map coordinates in this tile's grid (this protects legacy datasets extracted before the ``to_hash`` boundary-overflow carry and the ``egi_load`` spillover filter, which can still hold cross-tile rows). When no tile is given, the input must resolve to a single tile; a genuine multi-tile input raises ``GediRasterizationError`` instead of guessing which tile to keep — split by outer tile first (``rasterize_partition``) to keep every pixel. Parameters ---------- geodf : GeoDataFrame EGI-indexed GeoDataFrame (index must be EGI hash values) columns : list of str, optional Columns to rasterize. If None, all numeric columns are used. Internal columns (egi indices, h3 indices) are automatically excluded. fill_value : float Value for pixels with no data (default: NaN) outer_tile : int, optional Level-12 EGI hash of the tile to rasterize. Callers that know their tile (partition writers, ``rasterize_partition``) should always pass it. When None, the input must resolve to a single outer tile; a multi-tile input with no ``outer_tile`` hint raises ``GediRasterizationError`` rather than guessing which tile to keep — split by outer tile first (``rasterize_partition``) so no pixels are dropped. Raises ------ GediRasterizationError If the input spans more than one outer tile and no ``outer_tile`` is given (the function rasterizes exactly one tile and will not silently drop the rest). Returns ------- xr.Dataset Raster dataset with one data variable per column Examples -------- >>> # Rasterize aggregated GEDI data >>> raster = geodf_to_raster(agg_gdf, columns=['agbd_mean', 'rh_098_mean']) >>> raster.rio.to_raster("output.tif") Notes ----- This implementation uses direct index-based pixel assignment: 1. Decode each EGI hash to get pixel indices within the outer tile 2. Create a raster array with exact tile dimensions 3. Assign values directly to pixel locations This guarantees one polygon = one pixel with no extrapolation. """ from .core import from_hash # Handle empty GeoDataFrames if len(geodf) == 0: return xr.Dataset() # Filter out internal columns from rasterization columns = _filter_raster_columns(columns, geodf) if columns is None or len(columns) == 0: raise GediRasterizationError("No columns to rasterize. Provide numeric columns or check input data.") # Get EGI level and resolution from index egi_hashes = np.asarray(geodf.index.values, dtype=np.uint64) level = int(egi_hashes[0] // np.uint64(1e18)) res = RESOLUTIONS[level] # Determine the target outer tile. ``outer_ids`` packs px_outer*1000 + # py_outer for every input pixel (same digit layout from_hash decodes). outer_ids = (egi_hashes % np.uint64(10**18)) // np.uint64(10**12) uniq_outer, outer_counts = np.unique(outer_ids, return_counts=True) if outer_tile is not None: pid = np.uint64(outer_tile) target_outer = (pid % np.uint64(10**18)) // np.uint64(10**12) elif len(uniq_outer) == 1: # No hint, but the input resolves to exactly one tile — that tile is # determined, not guessed. target_outer = uniq_outer[0] pid = (np.uint64(OUTER_LEVEL) * np.uint64(10**18) + target_outer * np.uint64(10**12)) else: # Multi-tile input with no explicit target. Refuse rather than pick a # winner and silently drop the rest: this function rasterizes exactly # one outer tile, so callers must split by tile first # (``rasterize_partition``) or pass the tile they want explicitly. raise GediRasterizationError( f"geodf_to_raster received {len(uniq_outer)} outer tiles but " f"rasterizes exactly one. Split the input by outer tile " f"(egi.rasterize_partition) so every pixel is kept, or pass " f"outer_tile=<level-12 hash> to select the tile to rasterize. " f"Tiles present: {sorted(int(t) for t in uniq_outer)}." ) n_dropped = int(outer_counts[uniq_outer != target_outer].sum()) if n_dropped: logger.warning( f"geodf_to_raster: input spans {len(uniq_outer)} outer tiles; " f"rasterizing tile {int(pid)} and skipping {n_dropped} pixel(s) " f"from other tile(s). Single-tile input is expected — mixed " f"tiles indicate a legacy dataset (pre boundary-overflow fix) " f"or a multi-tile API call; split by outer tile " f"(rasterize_partition) to keep every pixel." ) # Get outer tile bounds left, bottom, _, _ = pixel_shape(pid).bounds pixels_per_tile = int(round(OUTER_RES / res)) # Always use the full (unclamped) tile extent for coordinates and the # affine transform. Tiles at the eastern CRS boundary have a clamped # `right`, which makes from_bounds() produce a smaller x_cell (~900 m # instead of ~1001 m). Using bottom + OUTER_RES as the canonical top # guarantees every tile carries the same pixel size regardless of # CRS clipping. tile_top = bottom + OUTER_RES # Create coordinate arrays for xarray # Y coordinates go from top to bottom (north-up convention) y_coords = tile_top - res * (np.arange(pixels_per_tile) + 0.5) x_coords = left + res * (np.arange(pixels_per_tile) + 0.5) # Extract outer tile indices from the dominant tile _, _, px_outer_tile, py_outer_tile, _, _ = from_hash(np.uint64(pid)) # Create data arrays for each column data_vars = {} for col in columns: # Initialize raster with fill value raster_data = np.full((pixels_per_tile, pixels_per_tile), fill_value, dtype=np.float32) # Get column values values = geodf[col].values # Decode each hash and assign values to pixels for i, (egi_hash, value) in enumerate(zip(egi_hashes, values)): _, _, px_outer, py_outer, px_inner, py_inner = from_hash(np.uint64(egi_hash)) # Only process pixels that belong to this outer tile if px_outer == px_outer_tile and py_outer == py_outer_tile: # Row index: from top (y inverted for north-up) row = pixels_per_tile - 1 - int(py_inner) col_idx = int(px_inner) if 0 <= row < pixels_per_tile and 0 <= col_idx < pixels_per_tile: raster_data[row, col_idx] = value # Create DataArray da = xr.DataArray( raster_data, dims=['y', 'x'], coords={'y': y_coords, 'x': x_coords}, name=col ) data_vars[col] = da # Create Dataset ds = xr.Dataset(data_vars) # Set CRS ds = ds.rio.write_crs(EGI_CRS_STRING) # Set NoData on each variable so GeoTIFF exports mask empty pixels correctly for var in ds.data_vars: if np.issubdtype(ds[var].dtype, np.floating): ds[var] = ds[var].rio.write_nodata(np.nan) # Set transform using canonical EGI pixel size trf = Affine(res, 0.0, left, 0.0, -res, tile_top) ds = ds.rio.write_transform(trf) return ds
def _outer_ids(egi_hashes: np.ndarray) -> np.ndarray: """Pack px_outer*1000 + py_outer for every hash (the layout from_hash decodes).""" return (egi_hashes % np.uint64(10**18)) // np.uint64(10**12) def _egi12_id(outer_id) -> int: """Level-12 outer tile hash from a packed ``px_outer*1000 + py_outer`` id.""" return int(np.uint64(OUTER_LEVEL) * np.uint64(10**18) + np.uint64(outer_id) * np.uint64(10**12))
[docs] def split_by_outer_tile(gdf: gpd.GeoDataFrame) -> List[tuple]: """Split an EGI-indexed frame into one sub-frame per level-12 outer tile. For the callers that *legitimately* hold a multi-tile frame — an aggregate over an arbitrary ROI, say. A Dask partition is not one of them: it nests in exactly one outer tile by construction, which is why :func:`rasterize_partition` treats a multi-tile partition as an error to report rather than a shape to accommodate. Parameters ---------- gdf : GeoDataFrame EGI-indexed GeoDataFrame (index holds EGI hashes). Returns ------- list of tuple ``(egi12_id, sub_gdf)`` pairs, largest sub-frame first. Empty input yields an empty list. """ if len(gdf) == 0: return [] egi_hashes = np.asarray(gdf.index.values, dtype=np.uint64) outer_ids = _outer_ids(egi_hashes) uniq, counts = np.unique(outer_ids, return_counts=True) order = np.argsort(-counts) return [(_egi12_id(uniq[i]), gdf.iloc[outer_ids == uniq[i]]) for i in order]
[docs] def rasterize_partition( gdf: gpd.GeoDataFrame, columns: Optional[List[str]] = None, include_egi_id: bool = True, partition_level: Optional[int] = None ) -> pd.Series: """ Rasterize a single EGI partition (for use with Dask map_partitions). One partition is one raster. An EGI partition at any level <= 12 nests in exactly one level-12 outer tile, so a partition spanning several tiles is a violation of that invariant, not a case to accommodate: it is reported with a WARNING, and the tile holding the most rows is the one rasterized. That keeps the "one partition = one output file" contract every consumer downstream relies on (``export_raster_partition`` file naming, ``merge_and_export_rasters``, ``gh3_rasterize``). Parameters ---------- gdf : GeoDataFrame EGI-indexed GeoDataFrame partition columns : list of str, optional Columns to rasterize include_egi_id : bool If True, include outer tile ID in raster attributes partition_level : int, optional EGI level the data is partitioned at. Drives the ``egiNN_id`` attribute that names the output file, so it must identify the *partition*, not the raster's level-12 tile: when partitions are finer than level 12, several of them share one outer tile and naming by the tile makes them overwrite each other. None (the default) stamps ``egi12_id``, which is correct for the standard level-12 partitioning. Returns ------- pd.Series Object-dtype Series of length 0 (empty / unrasterizable partition) or 1 (the partition's raster). Never longer — see the invariant above. Examples -------- >>> # With Dask >>> rasters = ddf.map_partitions(rasterize_partition, meta=pd.Series(dtype=object)) """ if len(gdf) == 0: return pd.Series(dtype=object) try: # ``outer_ids`` packs px_outer*1000 + py_outer, the same digit layout # geodf_to_raster decodes. Partitions nest in exactly one outer tile; # anything else is upstream corruption and must not pass silently. egi_hashes = np.asarray(gdf.index.values, dtype=np.uint64) uniq_outer, outer_counts = np.unique(_outer_ids(egi_hashes), return_counts=True) if len(uniq_outer) > 1: logger.warning( f"rasterize_partition: EGI partition spans {len(uniq_outer)} outer tiles " f"(px_outer*1000+py_outer {[int(t) for t in uniq_outer]}, rows " f"{[int(c) for c in outer_counts]}). Partitions must nest in exactly one " f"level-12 tile — rasterizing the largest and dropping the rest. Check the " f"source dataset / egi_load() partitioning." ) # Tile ID at level 12 (consistent regardless of data level) — passed to # geodf_to_raster as the explicit target so no tile inference happens, # and reused as the raster attribute. geodf_to_raster emits its own # WARNING for the stray pixels it skips. egi12_id = _egi12_id(uniq_outer[int(np.argmax(outer_counts))]) xras = geodf_to_raster(gdf, columns=columns, outer_tile=egi12_id) if len(xras.data_vars) == 0: return pd.Series(dtype=object) # Name by PARTITION identity, the way the H3 rasterizer does. Below # level 12 several partitions share an outer tile, so stamping the # tile id would give them all the same filename and the last one # written would silently replace the rest. if partition_level is not None and partition_level < OUTER_LEVEL: from .core import to_parent part_id = int(np.asarray(to_parent(egi_hashes[:1], partition_level))[0]) attrs = {f'egi{partition_level:02d}_id': part_id} else: attrs = {'egi12_id': egi12_id} for var in list(xras.data_vars): xras[var] = xras[var].assign_attrs(**attrs) return object_series([xras]) except Exception as e: # Whole-partition failure — never benign, so it is logged loudly and # the partition is skipped rather than failing the whole graph. logger.warning(f"Rasterization failed for the whole partition: {e}") return pd.Series(dtype=object)
[docs] def export_raster( xras: xr.Dataset, output_path: str, compress: str = 'LZW', tiled: bool = True, blocksize: int = 256, bigtiff: bool = True, cog: bool = True ) -> str: """ Export xarray Dataset to GeoTIFF file. Thin alias for :func:`gedih3.raster.export_raster`, kept because ``egi.export_raster`` is part of the module's published surface. It used to be a near-copy that inlined its own creation options, so it silently missed the parent's directory creation, its ``compress='NONE'`` handling and — latterly — Cloud Optimized GeoTIFF output. Parameters ---------- xras : xr.Dataset Raster dataset to export output_path : str Output file path compress : str Compression method ('LZW', 'ZSTD', 'DEFLATE', 'NONE') tiled : bool Use tiled output format. Ignored when ``cog`` is True. blocksize : int Tile block size in pixels bigtiff : bool Use BigTIFF format for large files cog : bool Write a Cloud Optimized GeoTIFF (default) Returns ------- str Output file path """ from ..raster.export import export_raster as _export_raster return _export_raster(xras, output_path, compress=compress, tiled=tiled, blocksize=blocksize, bigtiff=bigtiff, cog=cog)
[docs] def merge_raster_partitions( raster_series: pd.Series, output_path: Optional[str] = None ) -> xr.Dataset: """ Merge multiple raster partitions into a single raster. Parameters ---------- raster_series : pd.Series Series of xarray Datasets from rasterize_partition output_path : str, optional If provided, save merged raster to this path Returns ------- xr.Dataset Merged raster dataset """ from rioxarray.merge import merge_datasets # Filter out empty partitions and None values def is_valid_raster(x): if x is None: return False if isinstance(x, xr.Dataset): return len(x.data_vars) > 0 if hasattr(x, 'shape'): return all(s > 0 for s in x.shape) return False valid_rasters = [r for r in raster_series if is_valid_raster(r)] if len(valid_rasters) == 0: raise ValueError("No valid raster partitions to merge") # Merge all rasters # Use merge_datasets which handles overlapping regions correctly # IMPORTANT: Use nodata=np.nan to ensure gaps are filled with NaN, not 0 result = merge_datasets(valid_rasters, nodata=np.nan) if output_path: export_raster(result, output_path) return result
[docs] def get_raster_profile( level: int, bounds: tuple, crs: str = EGI_CRS_STRING ) -> dict: """ Generate a rasterio profile for EGI raster output. Parameters ---------- level : int EGI resolution level bounds : tuple Raster bounds (left, bottom, right, top) crs : str Coordinate reference system Returns ------- dict Rasterio profile dictionary """ res = get_resolution(level) left, bottom, right, top = bounds width = int((right - left) / res) height = int((top - bottom) / res) return { 'driver': 'GTiff', 'dtype': 'float32', 'width': width, 'height': height, 'count': 1, 'crs': crs, 'transform': transform.from_bounds(left, bottom, right, top, width, height), 'compress': 'lzw', 'tiled': True, 'blockxsize': 256, 'blockysize': 256 }