Source code for gedih3.egi.dataframe

# 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) DataFrame Module

This module provides operations for integrating EGI spatial indexing with
pandas and GeoPandas DataFrames:
- Adding EGI indices to DataFrames based on coordinates
- Converting between resolution levels (to_parent)
- Aggregation functions with spatial grouping
- Conversion to/from GeoDataFrames
"""
from typing import Callable, Dict, List, Optional, Union
import numpy as np
import pandas as pd
import geopandas as gpd

from .config import (
    OUTER_LEVEL, EGI_CRS, EGI_CRS_STRING, egi_col_name, validate_level
)
from .core import to_hash, to_parent as _to_parent, get_level
from .spatial import to_geodataframe


[docs] def egi_dataframe( df: Union[pd.DataFrame, gpd.GeoDataFrame], x_col: str = 'lon_lowestmode', y_col: str = 'lat_lowestmode', level: int = 1, in_epsg: int = 4326, set_index: bool = True ) -> gpd.GeoDataFrame: """ Add EGI spatial index to a DataFrame based on coordinate columns. This is the primary function for converting GEDI shot data to EGI-indexed format. It reprojects coordinates to EPSG:6933 and computes EGI hashes at the specified resolution level. Parameters ---------- df : DataFrame or GeoDataFrame Input data with coordinate columns or Point geometries x_col : str Name of longitude/X column (default: 'lon_lowestmode') y_col : str Name of latitude/Y column (default: 'lat_lowestmode') level : int EGI resolution level (1-12), default=1 (finest) in_epsg : int EPSG code of input coordinates (default: 4326 for WGS84) set_index : bool If True, set the EGI column as the DataFrame index Returns ------- GeoDataFrame GeoDataFrame with EGI column added and optionally set as index Examples -------- >>> # Add EGI index to GEDI shots >>> gedi_df = pd.read_parquet("gedi_shots.parquet") >>> egi_df = egi_dataframe(gedi_df, level=6) # ~1km resolution >>> >>> # Using existing GeoDataFrame >>> gdf = gpd.read_file("points.gpkg") >>> egi_gdf = egi_dataframe(gdf, level=6) """ validate_level(level) # Handle empty DataFrames (e.g., during Dask metadata inference) if len(df) == 0: egi_col = egi_col_name(level) gdf = gpd.GeoDataFrame(df, geometry=[], crs=f'EPSG:{in_epsg}') gdf[egi_col] = np.array([], dtype=np.uint64) if set_index: gdf = gdf.set_index(egi_col) return gdf # Handle GeoDataFrame with Point geometry if isinstance(df, gpd.GeoDataFrame) and len(df) > 0 and df.geom_type.iloc[0] == 'Point': gdf = df else: # Create GeoDataFrame from coordinate columns gdf = gpd.GeoDataFrame( df, geometry=gpd.points_from_xy(df[x_col], df[y_col]), crs=f'EPSG:{in_epsg}' ) # Reproject to EASE-Grid 2.0 if needed if gdf.crs.to_epsg() != EGI_CRS: gdf = gdf.to_crs(epsg=EGI_CRS) # Compute EGI hashes egi_col = egi_col_name(level) gdf[egi_col] = np.uint64([ to_hash(x, y, level) for x, y in zip(gdf.geometry.x, gdf.geometry.y) ]) if set_index: gdf = gdf.set_index(egi_col) return gdf
[docs] def egi_dataframe_vectorized( df: Union[pd.DataFrame, gpd.GeoDataFrame], x_col: str = 'lon_lowestmode', y_col: str = 'lat_lowestmode', level: int = 1, in_epsg: int = 4326, set_index: bool = True ) -> gpd.GeoDataFrame: """ Add EGI spatial index using vectorized operations (faster for large datasets). This is an optimized version of egi_dataframe() that uses numpy vectorization for better performance on large datasets. Parameters ---------- df : DataFrame or GeoDataFrame Input data with coordinate columns x_col : str Name of longitude/X column y_col : str Name of latitude/Y column level : int EGI resolution level (1-12) in_epsg : int EPSG code of input coordinates set_index : bool If True, set the EGI column as index Returns ------- GeoDataFrame GeoDataFrame with EGI column added """ from pyproj import Transformer validate_level(level) # Handle empty DataFrames (e.g., during Dask metadata inference) if len(df) == 0: egi_col = egi_col_name(level) gdf = gpd.GeoDataFrame(df, geometry=[], crs=f'EPSG:{in_epsg}') gdf[egi_col] = np.array([], dtype=np.uint64) if set_index: gdf = gdf.set_index(egi_col) return gdf # Get coordinates if isinstance(df, gpd.GeoDataFrame) and len(df) > 0 and df.geom_type.iloc[0] == 'Point': if df.crs.to_epsg() == EGI_CRS: x = df.geometry.x.values y = df.geometry.y.values else: transformer = Transformer.from_crs(df.crs, f'EPSG:{EGI_CRS}', always_xy=True) x, y = transformer.transform(df.geometry.x.values, df.geometry.y.values) gdf = df else: x = df[x_col].values y = df[y_col].values if in_epsg != EGI_CRS: transformer = Transformer.from_crs(f'EPSG:{in_epsg}', f'EPSG:{EGI_CRS}', always_xy=True) x, y = transformer.transform(x, y) gdf = gpd.GeoDataFrame( df, geometry=gpd.points_from_xy(df[x_col], df[y_col]), crs=f'EPSG:{in_epsg}' ) # Vectorized hash computation egi_col = egi_col_name(level) gdf[egi_col] = to_hash(np.asarray(x), np.asarray(y), level) if set_index: gdf = gdf.set_index(egi_col) return gdf
[docs] def egi_to_parent( gdf: Union[pd.DataFrame, gpd.GeoDataFrame], parent_level: int = OUTER_LEVEL, set_index: bool = True ) -> Union[pd.DataFrame, gpd.GeoDataFrame]: """ Convert EGI-indexed DataFrame to a coarser resolution level. Parameters ---------- gdf : DataFrame or GeoDataFrame EGI-indexed DataFrame (index must be EGI hash) parent_level : int Target coarser resolution level set_index : bool If True, replace the index with the parent level Returns ------- DataFrame or GeoDataFrame DataFrame with parent-level EGI column/index Examples -------- >>> # Aggregate from level 1 to level 6 >>> parent_df = egi_to_parent(fine_df, parent_level=6) """ validate_level(parent_level) # Handle empty DataFrames (e.g., during Dask metadata inference) if len(gdf) == 0: parent_col = egi_col_name(parent_level) gdf = gdf.assign(**{parent_col: np.uint64(0)}) if set_index: gdf = gdf.reset_index().set_index(parent_col) return gdf # Get current level from index current_level = int(gdf.index[0] // np.uint64(1e18)) if parent_level <= current_level: return gdf parent_col = egi_col_name(parent_level) # Compute parent hashes parent_hashes = np.uint64([_to_parent(i, parent_level) for i in gdf.index.to_numpy()]) gdf = gdf.assign(**{parent_col: parent_hashes}) if set_index: gdf = gdf.reset_index().set_index(parent_col) return gdf
[docs] def egi_to_parent_vectorized( gdf: Union[pd.DataFrame, gpd.GeoDataFrame], parent_level: int = OUTER_LEVEL, set_index: bool = True ) -> Union[pd.DataFrame, gpd.GeoDataFrame]: """ Convert EGI-indexed DataFrame to coarser resolution (vectorized version). This is an optimized version using numpy vectorization. Parameters ---------- gdf : DataFrame or GeoDataFrame EGI-indexed DataFrame parent_level : int Target coarser resolution level set_index : bool If True, replace the index with parent level Returns ------- DataFrame or GeoDataFrame DataFrame with parent-level EGI column/index """ validate_level(parent_level) # Handle empty DataFrames (e.g., during Dask metadata inference) if len(gdf) == 0: parent_col = egi_col_name(parent_level) gdf = gdf.assign(**{parent_col: np.uint64(0)}) if set_index: gdf = gdf.reset_index().set_index(parent_col) return gdf current_level = int(gdf.index[0] // np.uint64(1e18)) if parent_level <= current_level: return gdf parent_col = egi_col_name(parent_level) # Vectorized parent hash computation parent_hashes = _to_parent(gdf.index.to_numpy().astype(np.uint64), parent_level) gdf = gdf.assign(**{parent_col: parent_hashes}) if set_index: gdf = gdf.reset_index().set_index(parent_col) return gdf
[docs] def egi_to_geo( df: Union[pd.DataFrame, gpd.GeoDataFrame], polygons: bool = True ) -> gpd.GeoDataFrame: """ Add geometry to an EGI-indexed DataFrame. Parameters ---------- df : DataFrame or GeoDataFrame EGI-indexed DataFrame polygons : bool If True, use polygon geometries; if False, use point centroids Returns ------- GeoDataFrame GeoDataFrame with geometry column added Examples -------- >>> # Add polygon geometries for visualization >>> gdf = egi_to_geo(aggregated_df, polygons=True) """ # Handle empty DataFrames (e.g., during Dask operations on empty partitions) if len(df) == 0: return gpd.GeoDataFrame(df, geometry=[], crs=EGI_CRS_STRING) geom_gdf = to_geodataframe(df.index.to_numpy(), return_polygons=polygons) # Use .copy() to avoid SettingWithCopyWarning when df is a view/slice # (e.g., from set_index() in load_and_aggregate_tile) gdf = gpd.GeoDataFrame(df.copy(), geometry=geom_gdf.geometry.values, crs=EGI_CRS_STRING) return gdf
[docs] def egi_aggregate( gdf: Union[pd.DataFrame, gpd.GeoDataFrame], mapper: Union[str, List[str], Dict, Callable] = 'mean', return_geometry: bool = True, geom_points: bool = False ) -> Union[pd.DataFrame, gpd.GeoDataFrame]: """ Aggregate EGI-indexed DataFrame by spatial index. Parameters ---------- gdf : DataFrame or GeoDataFrame EGI-indexed DataFrame mapper : str, list, dict, or callable Aggregation specification: - str: Single aggregation function (e.g., 'mean', 'sum', 'count') - list: Multiple functions ['mean', 'std', 'count'] - dict: Per-column specification {'col1': 'mean', 'col2': ['min', 'max']} - callable: Custom aggregation function return_geometry : bool If True, return GeoDataFrame with geometry geom_points : bool If True and return_geometry, use point centroids instead of polygons Returns ------- DataFrame or GeoDataFrame Aggregated data, optionally with geometry Examples -------- >>> # Simple mean aggregation >>> agg_df = egi_aggregate(shots_df, mapper='mean') >>> >>> # Multiple aggregations >>> agg_df = egi_aggregate(shots_df, mapper=['mean', 'std', 'count']) >>> >>> # Per-column specification >>> agg_df = egi_aggregate(shots_df, mapper={'agbd': 'mean', 'rh_098': ['mean', 'std']}) """ # Remove geometry column if present (will be regenerated) if 'geometry' in gdf.columns: gdf = gdf.drop(columns='geometry') # Perform aggregation if callable(mapper): df_agg = pd.DataFrame(gdf.groupby(level=0).apply(mapper)) # Handle MultiIndex from apply if isinstance(df_agg.index, pd.MultiIndex): df_agg.index = df_agg.index.get_level_values(0) else: df_agg = gdf.groupby(level=0).agg(mapper) # Add geometry if requested if return_geometry: geom = to_geodataframe(df_agg.index.to_numpy(), return_polygons=not geom_points) # Flatten MultiIndex columns if present if isinstance(df_agg.columns, pd.MultiIndex): df_agg.columns = ['_'.join(str(c) for c in col) for col in df_agg.columns] df_agg = geom.join(df_agg) return df_agg
[docs] def egi_col_from_df(df: Union[pd.DataFrame, gpd.GeoDataFrame]) -> Optional[str]: """ Find the EGI column in a DataFrame. Parameters ---------- df : DataFrame or GeoDataFrame DataFrame to search Returns ------- str or None Name of the EGI column, or None if not found """ # Check index name if df.index.name and df.index.name.startswith('egi'): return df.index.name # Check columns egi_cols = [col for col in df.columns if str(col).startswith('egi')] if egi_cols: return sorted(egi_cols)[0] return None
[docs] def egi_get_level_from_df(df: Union[pd.DataFrame, gpd.GeoDataFrame]) -> Optional[int]: """ Get the EGI resolution level from a DataFrame's index. Parameters ---------- df : DataFrame or GeoDataFrame EGI-indexed DataFrame Returns ------- int or None Resolution level, or None if not EGI-indexed """ col = egi_col_from_df(df) if col is None: return None # Extract level from column name (e.g., 'egi06' -> 6) try: return int(col[3:]) except ValueError: # Fall back to examining the index values if df.index.name and df.index.name.startswith('egi') and len(df) > 0: return int(get_level(np.uint64(df.index[0]))) return None