#! python
# 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
"""
gedih3 Validation Module
Parameter validation functions for H3, EGI, and other critical operations.
These functions raise descriptive exceptions to help users fix configuration issues.
Usage:
from gedih3.validation import validate_h3_params, validate_egi_level
validate_h3_params(res=12, part=3) # Raises H3ValidationError if invalid
"""
import os
from typing import Union, List, Dict, Optional, Any
from .exceptions import (
H3ValidationError,
EGIValidationError,
GediProductError,
GediVariableError,
GediFileError,
GediDatabaseNotFoundError,
)
# =============================================================================
# H3 Parameter Validation
# =============================================================================
H3_MIN_RESOLUTION = 0
H3_MAX_RESOLUTION = 15
[docs]
def validate_h3_resolution(res: int, param_name: str = 'resolution') -> int:
"""
Validate H3 resolution is within valid range [0, 15].
Parameters
----------
res : int
H3 resolution level
param_name : str
Parameter name for error messages
Returns
-------
int
Validated resolution
Raises
------
H3ValidationError
If resolution is out of range or not an integer
"""
if not isinstance(res, int):
raise H3ValidationError(
f"H3 {param_name} must be an integer, got {type(res).__name__}",
param_name=param_name,
value=res
)
if not H3_MIN_RESOLUTION <= res <= H3_MAX_RESOLUTION:
raise H3ValidationError(
f"H3 {param_name} must be between {H3_MIN_RESOLUTION} and {H3_MAX_RESOLUTION}, got {res}",
param_name=param_name,
value=res
)
return res
[docs]
def validate_h3_params(res: int, part: int) -> tuple:
"""
Validate H3 resolution and partition parameters.
Parameters
----------
res : int
H3 resolution level for indexing (0-15)
part : int
H3 resolution level for partitioning (0-15, must be <= res)
Returns
-------
tuple
(res, part) validated values
Raises
------
H3ValidationError
If parameters are invalid
Examples
--------
>>> validate_h3_params(12, 3) # OK
(12, 3)
>>> validate_h3_params(3, 12) # Raises H3ValidationError
"""
res = validate_h3_resolution(res, 'resolution')
part = validate_h3_resolution(part, 'partition')
if part > res:
raise H3ValidationError(
f"H3 partition level ({part}) must be <= resolution level ({res}). "
f"Partition cells are used to group shots, so they must be coarser than index cells.",
param_name='partition',
value=part
)
return res, part
[docs]
def validate_h3_cell(cell: str, expected_res: Optional[int] = None) -> str:
"""
Validate an H3 cell index string.
Parameters
----------
cell : str
H3 cell index (hexadecimal string)
expected_res : int, optional
Expected resolution level
Returns
-------
str
Validated cell index
Raises
------
H3ValidationError
If cell is invalid
"""
import h3
if not isinstance(cell, str):
raise H3ValidationError(
f"H3 cell must be a string, got {type(cell).__name__}",
param_name='cell',
value=cell
)
if not h3.is_valid_cell(cell):
raise H3ValidationError(
f"Invalid H3 cell index: {cell}",
param_name='cell',
value=cell
)
if expected_res is not None:
actual_res = h3.get_resolution(cell)
if actual_res != expected_res:
raise H3ValidationError(
f"H3 cell has resolution {actual_res}, expected {expected_res}",
param_name='cell',
value=cell
)
return cell
# =============================================================================
# EGI Parameter Validation
# =============================================================================
EGI_MIN_LEVEL = 1
EGI_MAX_LEVEL = 12
[docs]
def validate_egi_level(level: int, param_name: str = 'level') -> int:
"""
Validate EGI level is within valid range [1, 12].
Parameters
----------
level : int
EGI resolution level
param_name : str
Parameter name for error messages
Returns
-------
int
Validated level
Raises
------
EGIValidationError
If level is out of range
"""
if not isinstance(level, int):
raise EGIValidationError(
f"EGI {param_name} must be an integer, got {type(level).__name__}",
param_name=param_name,
value=level
)
if not EGI_MIN_LEVEL <= level <= EGI_MAX_LEVEL:
raise EGIValidationError(
f"EGI {param_name} must be between {EGI_MIN_LEVEL} and {EGI_MAX_LEVEL}, got {level}",
param_name=param_name,
value=level
)
return level
# =============================================================================
# GEDI Product Validation
# =============================================================================
VALID_PRODUCTS = {'L1B', 'L2A', 'L2B', 'L3', 'L4A', 'L4B', 'L4C'}
[docs]
def validate_product(product: str) -> str:
"""
Validate GEDI product identifier.
Parameters
----------
product : str
GEDI product code (e.g., 'L2A', 'L4A')
Returns
-------
str
Normalized product code (uppercase)
Raises
------
GediProductError
If product is invalid
"""
if not isinstance(product, str):
raise GediProductError(
f"Product must be a string, got {type(product).__name__}"
)
product_upper = product.upper()
if product_upper not in VALID_PRODUCTS:
raise GediProductError(
f"Invalid GEDI product: {product}. "
f"Valid products are: {', '.join(sorted(VALID_PRODUCTS))}"
)
return product_upper
[docs]
def validate_product_vars(product_vars: Dict[str, Any]) -> Dict[str, List[str]]:
"""
Validate product variables dictionary.
Parameters
----------
product_vars : dict
Dictionary mapping product codes to variable specifications
Returns
-------
dict
Validated product variables
Raises
------
GediProductError
If product is invalid
GediVariableError
If variable specification is invalid
"""
if not isinstance(product_vars, dict):
raise GediProductError(
f"product_vars must be a dictionary, got {type(product_vars).__name__}"
)
validated = {}
for product, vars_spec in product_vars.items():
product = validate_product(product)
if vars_spec is None:
validated[product] = None
elif isinstance(vars_spec, str):
validated[product] = [vars_spec]
elif isinstance(vars_spec, list):
if not all(isinstance(v, str) for v in vars_spec):
raise GediVariableError(
f"Variables for {product} must be strings, got: {vars_spec}"
)
validated[product] = vars_spec
else:
raise GediVariableError(
f"Variables for {product} must be a string, list, or None, "
f"got {type(vars_spec).__name__}"
)
return validated
# =============================================================================
# File/Path Validation
# =============================================================================
[docs]
def validate_file_exists(path: str, file_type: str = 'file') -> str:
"""
Validate that a file exists.
Parameters
----------
path : str
File path to validate
file_type : str
Description for error message (e.g., 'HDF5 file', 'database')
Returns
-------
str
Validated path
Raises
------
GediFileError
If file does not exist
"""
if not os.path.exists(path):
raise GediFileError(f"{file_type} not found: {path}")
return path
[docs]
def validate_directory_exists(path: str, create: bool = False) -> str:
"""
Validate that a directory exists, optionally creating it.
Parameters
----------
path : str
Directory path to validate
create : bool
If True, create directory if it doesn't exist
Returns
-------
str
Validated path
Raises
------
GediFileError
If directory does not exist and create=False
"""
if not os.path.exists(path):
if create:
os.makedirs(path, exist_ok=True)
else:
raise GediFileError(f"Directory not found: {path}")
if not os.path.isdir(path):
raise GediFileError(f"Path exists but is not a directory: {path}")
return path
[docs]
def validate_database_path(db_path: str) -> str:
"""
Validate H3 database path exists and appears valid.
Parameters
----------
db_path : str
Path to H3 database directory
Returns
-------
str
Validated path
Raises
------
GediDatabaseNotFoundError
If database directory doesn't exist or appears invalid
"""
from .utils import smart_exists, smart_isdir, smart_glob
if not smart_exists(db_path):
raise GediDatabaseNotFoundError(
f"H3 database not found: {db_path}"
)
if not smart_isdir(db_path):
raise GediDatabaseNotFoundError(
f"H3 database path is not a directory: {db_path}"
)
# Check for H3 partition directories or parquet files
from .utils import smart_join
h3_dirs = smart_glob(smart_join(db_path, 'h3_*/'))
parquet_files = smart_glob(smart_join(db_path, '**/*.parquet'), recursive=True)
if not h3_dirs and not parquet_files:
raise GediDatabaseNotFoundError(
f"H3 database appears empty or invalid: {db_path}. "
f"No H3 partition directories or parquet files found."
)
return db_path
# =============================================================================
# Coordinate Validation
# =============================================================================
[docs]
def validate_coordinates(lat: float, lon: float) -> tuple:
"""
Validate latitude and longitude coordinates.
Parameters
----------
lat : float
Latitude in degrees
lon : float
Longitude in degrees
Returns
-------
tuple
(lat, lon) validated coordinates
Raises
------
ValueError
If coordinates are out of range
"""
if not -90 <= lat <= 90:
raise ValueError(f"Latitude must be between -90 and 90, got {lat}")
if not -180 <= lon <= 180:
raise ValueError(f"Longitude must be between -180 and 180, got {lon}")
return lat, lon
[docs]
def validate_bbox(bbox: Union[list, tuple]) -> tuple:
"""
Validate bounding box coordinates.
Parameters
----------
bbox : list or tuple
Bounding box as (west, south, east, north) or [west, south, east, north]
Returns
-------
tuple
Validated (west, south, east, north)
Raises
------
ValueError
If bbox is invalid
"""
if not isinstance(bbox, (list, tuple)):
raise ValueError(f"Bounding box must be a list or tuple, got {type(bbox).__name__}")
if len(bbox) != 4:
raise ValueError(f"Bounding box must have 4 elements (W, S, E, N), got {len(bbox)}")
west, south, east, north = bbox
# Validate coordinates
validate_coordinates(south, west)
validate_coordinates(north, east)
if south > north:
raise ValueError(f"South ({south}) must be <= North ({north})")
# Note: west > east is valid for antimeridian-crossing bboxes
return (west, south, east, north)