Source code for gedih3.gedidriver

# 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

"""
GEDI data file operations and HDF5 I/O.

This module provides classes and functions for working with GEDI HDF5 files,
including parsing filenames, extracting data, and building Dask DataFrames.
"""

import os
import re
import glob
import itertools
from typing import Dict, List, Optional, Union
from pathlib import Path

import h5py
import yaml
import numpy as np
import pandas as pd
import geopandas as gpd
from numba import njit
from datetime import datetime
import dask
import dask.dataframe
from earthaccess.store import EarthAccessFile

from .config import GEDI_BEAMS, GEDI_MISSION_START, GEDI_START_DATE, GH3_DEFAULT_SOC_DIR, SOC_MANIFEST_FILENAME, get_default_vars_file, _get_versioned, _GEDI_MIN_VARS
from .utils import h5_copy_subset, h5_info, _read_manifest, generate_manifest
from .logging_config import get_logger
from .exceptions import GediValidationError, GediProductError, GediVariableError, GediFileError

logger = get_logger(__name__)


[docs] def soc_prod_from_file(file_path: Union[str, Path]) -> str: """ Extract GEDI product code from a file path. Parameters ---------- file_path : str or Path Path to a GEDI HDF5 file Returns ------- str Product code (e.g., 'L2A', 'L4A') """ f = GEDIFile(file_path) prod_str = f"L{f.product[-1]}{f.level}" return prod_str
[docs] def soc_from_file(file_path: Union[str, Path]) -> str: """ Get the SOC root directory from a GEDI file path. Parameters ---------- file_path : str or Path Path to a GEDI HDF5 file Returns ------- str Path to the SOC root directory """ return os.path.dirname(os.path.dirname(os.path.dirname(file_path)))
def _soc_manifest_path(soc_dir: str) -> str: """Return the absolute path of the SOC manifest sentinel for *soc_dir*.""" return os.path.join(soc_dir.rstrip('/'), SOC_MANIFEST_FILENAME) def _read_soc_manifest(soc_dir: str) -> Optional[List[str]]: """Read ``_soc_manifest.txt`` from a SOC root and return absolute paths. Returns ``None`` when the manifest does not exist; callers should fall back to the recursive glob. Thin wrapper around the shared :func:`gedih3.utils._read_manifest` (cached, manifest-name aware) — the SOC manifest stores relative paths just like the H3 database manifest, so the only SOC-specific behavior is filling in the sentinel filename and joining results back to absolute paths. """ rels = _read_manifest(soc_dir, manifest_filename=SOC_MANIFEST_FILENAME) if rels is None: return None root = soc_dir.rstrip('/') return [os.path.join(root, rel) for rel in rels]
[docs] def write_soc_manifest(soc_dir: str, files=None) -> int: """(Re)generate the SOC manifest sentinel by walking *soc_dir* once. Producer-driven refresh (R2): every code path that mutates a SOC tree (`gh3_download`, `gh3_build --download`, `s3_etl_subset`, `gh3_build -i` exit, `gh3_doctor --fix soc_health`) calls this on exit. Cheaper than letting every consumer re-walk the tree. Walks the SOC tree in parallel via :func:`gedih3.parallel.walk_soc_parallel` (year/doy fan-out on the registered dask Client). Returns the number of files written to the manifest (0 when *soc_dir* is missing / empty). Parameters ---------- soc_dir SOC root directory. files : list[str], optional Pre-computed absolute file list to use instead of walking. When the caller has already enumerated the tree (e.g. `cli/gh3_build.py` after its existing-h5 listing), passing the list here avoids the redundant parallel walk. """ if not os.path.isdir(soc_dir): return 0 if files is None: from .parallel import walk_soc_parallel files = walk_soc_parallel(soc_dir, pattern='GEDI*.h5') if not files: return 0 generate_manifest(soc_dir, pattern='GEDI*.h5', manifest_filename=SOC_MANIFEST_FILENAME, tree_shape='soc', files=files) return len(files)
# The file-name fields after a granule's orbit/track that rank its releases # within one GEDI version: PPDS, PGE, generation and an optional suffix. _RELEASE_FIELDS = re.compile(r'_O\d+_\d{2}_T\d+_(\d{2})_(\d{3})_(\d{2})_V\d{3}(?:_(.+?))?\.h5$') # The same fields as a release tag (gedi_release_tag). _RELEASE_TAG = re.compile(r'^(\d{2})_(\d{3})_(\d{2})(?:_(.+))?$')
[docs] def gedi_release_tag(file_name: str) -> Optional[str]: """The release fields of a GEDI file name, as a compact tag. With the granule key, the product and the GEDI version, the tag identifies the file: it is what the build log records per granule and product (``releases``), at a fraction of a basename's size. Parameters ---------- file_name : str A GEDI file name, path or URL. Only the basename is read. Returns ------- str or None ``'PP_PGE_GG'`` (PPDS, PGE, generation), plus ``'_<suffix>'`` when the name has one; ``None`` for a name outside the GEDI convention. :func:`gedi_release_rank` ranks tags as it ranks names. Examples -------- >>> gedi_release_tag('GEDI02_A_2019108002012_O01956_03_T03909_02_003_01_V002_SGS.h5') '02_003_01_SGS' """ match = _RELEASE_FIELDS.search(os.path.basename(file_name)) if match is None: return None ppds, pge, generation, suffix = match.groups() return f"{ppds}_{pge}_{generation}" + (f"_{suffix}" if suffix else '')
[docs] def gedi_release_rank(file_name: str) -> tuple: """Rank one file of a granule product against the granule's other files. NASA re-releases granules within one GEDI version with a new PPDS (geolocation), PGE (software) or generation. The highest rank is the file to use: PPDS first, then PGE, then generation, then the suffix, where a suffixed file (e.g. ``_SGS``) beats the plain file of the same release and suffixes compare alphabetically. For names that follow the GEDI convention, this is the order a sort by file name gives within one directory. Parameters ---------- file_name : str A GEDI file name, path or URL (only the basename is read), or a release tag from :func:`gedi_release_tag`. Returns ------- tuple ``(ppds, pge, generation, has_suffix, suffix)``, ordered so that a larger tuple is a later release. A name that does not follow the GEDI convention ranks below every one that does. Examples -------- >>> old = 'GEDI02_A_2019108002012_O01956_03_T03909_02_003_01_V002.h5' >>> new = 'GEDI02_A_2019108002012_O01956_03_T03909_02_004_01_V002.h5' >>> gedi_release_rank(new) > gedi_release_rank(old) True """ match = _RELEASE_FIELDS.search(os.path.basename(file_name)) if match is None and '/' not in file_name and not file_name.endswith('.h5'): match = _RELEASE_TAG.match(file_name) if match is None: return (-1, -1, -1, False, '') ppds, pge, generation, suffix = match.groups() return (int(ppds), int(pge), int(generation), suffix is not None, suffix or '')
# Duplicate groups soc_file_tree has already reported in this process, keyed # by (orb_track, product, file paths): a build lists the SOC tree several # times, and one warning per group is enough, while a further re-release # changes the key and is reported again. A stopgap until superseded granules # are tracked in the build log (#47). _REPORTED_DUPLICATE_FILES = set() def _report_duplicate_granule_files(flist: pd.DataFrame) -> None: """Warn once per granule product that has more than one file in a listing. ``soc_file_tree`` keys files by orbit/granule/track and keeps the highest-ranked file of each product (``gedi_release_rank``: PPDS, then PGE, then generation, then the suffix). A second file is a re-release within the same GEDI version, or an internal variant such as ``_SGS``. Nothing re-indexes a granule whose file changes: rows already in a database stay from the file they were built from, while variables added and products filled later are read from the file chosen here. Parameters ---------- flist : pandas.DataFrame The listing, with ``file_paths``, ``orb_track`` and ``prod`` columns, sorted so that each product's file to use comes last. """ files = flist.drop_duplicates('file_paths') # one path listed twice is not a second file dup = files[files.duplicated(['orb_track', 'prod'], keep=False)] if dup.empty: return new = [] for (orb_track, prod), paths in dup.groupby(['orb_track', 'prod'], sort=True)['file_paths']: paths = list(paths) # in rank order: the pivot keeps the last one key = (orb_track, prod, tuple(paths)) if key not in _REPORTED_DUPLICATE_FILES: _REPORTED_DUPLICATE_FILES.add(key) new.append((paths[-1], paths[:-1])) if not new: return for used, others in new: logger.debug(f"Duplicate granule files: using {used} over {', '.join(others)}") used, others = new[0] more = f" (and {len(new) - 1} more, listed at DEBUG level)" if len(new) > 1 else "" logger.warning( f"{len(new)} granule product(s) have more than one file in the SOC listing (a re-release within " f"the same GEDI version, or an internal variant such as _SGS). The latest release (PPDS, then PGE, " f"generation and suffix) is used, " f"e.g. {os.path.basename(used)} over {', '.join(os.path.basename(o) for o in others)}{more}. " f"Rows already in a database are not re-indexed from a newer file, and variables added or " f"products filled later read the file used here. Remove the files you don't want, or exclude " f"them (exclude=, gh3_build --exclude, which the build log keeps for later runs and gh3_doctor)." )
[docs] def soc_file_tree( file_struct: Union[str, List[str], List[EarthAccessFile]], to_list: bool = False, glob_kwargs: Optional[Dict] = None, exclude: Optional[List[str]] = None, require_all: bool = True, ) -> Union[Dict[str, Dict[str, str]], List[Dict[str, str]]]: """ Build a structured tree of GEDI SOC files grouped by orbit/track. Organizes GEDI HDF5 files into a nested structure for easy access to related files across different products (L2A, L2B, L4A, etc.). Parameters ---------- file_struct : str, list of str, or list of EarthAccessFile Either a directory path containing GEDI files, or a list of file paths or EarthAccessFile objects. to_list : bool, default False If True, return a list of dicts instead of a nested dict. glob_kwargs : dict, optional Keyword arguments passed to gedi_file_glob() for filtering files (e.g., version, orbit). Pass ``version`` whenever the tree may hold more than one GEDI release: granules are keyed by orbit/track only, so two releases of the same granule would collide under one key. exclude : list of str, optional fnmatch-style patterns matched against each file's basename. Any file whose basename matches any pattern is dropped from the result. Useful to exclude internal/SGS variants (e.g. ``['*_SGS.h5']``) that share the SOC tree with public release files. require_all : bool, default True Keep only granules that have a file for every product found in the listing. ``False`` keeps every granule with whatever products it has (its dict simply lacks the others) — for callers that judge completeness against the products they request, e.g. a build admitting granules whose later products are not published yet. Returns ------- dict or list If to_list=False: dict keyed by orbit_track identifier, with values being dicts mapping product codes to file paths. A product with more than one file maps to its latest release (``gedi_release_rank``), and the duplicates are logged once per process. If to_list=True: list of product dicts. Raises ------ GediValidationError When the listed files span more than one GEDI release (``_V00N``). One release per tree is the contract; a mixed listing would silently pair files of different releases under one granule key. Examples -------- >>> tree = soc_file_tree('/path/to/soc') >>> tree['O12345_01_T00001'] {'L2A': '/path/to/GEDI02_A_...h5', 'L4A': '/path/to/GEDI04_A_...h5'} >>> # Exclude NASA SGS internal files from discovery >>> tree = soc_file_tree('/path/to/soc', exclude=['*_SGS.h5']) """ direct_access = False if isinstance(file_struct, str) and os.path.isdir(file_struct): glob_pattern = gedi_file_glob(**glob_kwargs) if glob_kwargs else 'GEDI*.h5' # Always walk the SOC tree on the dask cluster. We intentionally do # not consume ``_soc_manifest.txt`` here: external population paths # (manual rsync, NASA delivery) bypass the producer-driven refresh # and would leave the manifest stale relative to disk, silently # narrowing every downstream scan. ``walk_soc_parallel`` fans the # year/doy scandir across the registered dask Client, so the cost # of always walking is bounded. from .parallel import walk_soc_parallel file_list = walk_soc_parallel(file_struct, pattern=glob_pattern) elif (direct_access := isinstance(file_struct[0], EarthAccessFile)): file_list = [i.path for i in file_struct] elif isinstance(file_struct[0], str): file_list = file_struct else: raise GediValidationError("file_struct must be a directory or a list of file paths (or s3 links)") if exclude: import fnmatch before = len(file_list) keep_idx = [ i for i, p in enumerate(file_list) if not any(fnmatch.fnmatch(os.path.basename(p), pat) for pat in exclude) ] if len(keep_idx) < before: file_list = [file_list[i] for i in keep_idx] if direct_access: file_struct = [file_struct[i] for i in keep_idx] # Library is silent about exclusions on purpose: soc_file_tree # is called multiple times per build and the CLI emits one # user-facing summary at the existing_h5 filter point. if not file_list: return [] if to_list else {} file_array = np.array(file_struct) if direct_access else np.array(file_list) flist = pd.DataFrame({'file_paths': file_list, 'file_links': file_array}) fidx = flist.file_paths.str.extract(r'.*GEDI(\d{2})_([A-Z])_.*_(O\d+_\d{2}_T\d+).*\.h5$') valid = fidx.notna().all(axis=1) flist = flist.loc[valid].copy() fidx = fidx.loc[valid] releases = sorted(flist.file_paths.str.extract(r'_V(\d{3})[^/]*\.h5$')[0].dropna().unique()) if len(releases) > 1: raise GediValidationError( f"SOC listing mixes GEDI releases {['V' + r for r in releases]}; granules are keyed by " f"orbit/track only, so files of different releases would be paired silently. Pin one " f"release (glob_kwargs={{'version': N}}, or a version-filtered file list)." ) flist['prod'] = 'L' + fidx[0].astype(int).astype(str) + fidx[1] flist['orb_track'] = fidx[2] rank = pd.DataFrame([gedi_release_rank(p) for p in flist.file_paths], index=flist.index, columns=['_ppds', '_pge', '_gen', '_has_suffix', '_suffix']) flist = pd.concat([flist, rank], axis=1) # Each product's latest release sorts last, for the pivot's 'last'. Ties # (one file copied to two directories) fall back to path order. flist = flist.sort_values(['orb_track', 'prod', *rank.columns, 'file_paths']) _report_duplicate_granule_files(flist) flist = flist.pivot_table(index='orb_track', columns='prod', values='file_links', aggfunc='last') if require_all: flist = flist.dropna() soc_tree = flist.T.to_dict() else: soc_tree = {k: {p: f for p, f in v.items() if isinstance(f, str) or not pd.isna(f)} for k, v in flist.T.to_dict().items()} if to_list: soc_tree = list(soc_tree.values()) return soc_tree
[docs] def gedi_subset(source_file, dest_file, variables, subset_beams=None): beams = [b for b in GEDI_BEAMS if b in subset_beams] if subset_beams else list(GEDI_BEAMS) beam_variables = [f"{b}/{v}" for b in beams for v in variables] h5_copy_subset(source_file, dest_file, beam_variables) if os.path.exists(dest_file): return dest_file return None
[docs] def gedi_file_glob(orbit=None, orbit_granule=None, track=None, product: int=None, level: str=None, ppds: int=None, pge:int=None, generation:int=None, version:int=None): str_build = lambda x, digits=2: '*' if x is None else f"{x:0{digits}d}" lev_str = '*' if level is None else level.upper() prd_str = str_build(product) orb_str = str_build(orbit, 5) ogr_str = str_build(orbit_granule) trk_str = str_build(track, 5) pge_str = str_build(pge, 3) gen_str = str_build(generation) ver_str = str_build(version, 3) ppd_str = str_build(ppds) return f"GEDI{prd_str}_{lev_str}_*_O{orb_str}_{ogr_str}_T{trk_str}_{ppd_str}_{pge_str}_{gen_str}_V{ver_str}*.h5"
_PRESET_KEYWORDS = frozenset({'minimal', 'min', 'default', 'def'}) _SPEC_KEYWORDS = _PRESET_KEYWORDS | {'all', '*'}
[docs] def preset_extra_names(spec): """Variable names given alongside a preset keyword in a product spec. ``['default', 'energy_total']`` → ``['energy_total']``. Specs without a preset keyword, and the keywords themselves (``all``/``*`` included), give ``[]``. The names are the user's, not the preset's: callers validate them like an explicit list. """ if not isinstance(spec, list) or not _PRESET_KEYWORDS.intersection(spec): return [] return [v for v in spec if v not in _SPEC_KEYWORDS]
[docs] def gedi_vars_expand(product_vars, version=None): """Expand per-product variable specifications to variable lists, in place. Parameters ---------- product_vars : dict ``{product: spec}``. A spec is ``None`` or ``[]`` (every variable), ``['*']`` / ``['all']`` (every variable), a single path to a file listing variables, a list naming a preset (``minimal``/``min`` or ``default``/``def``), or a plain list of variable names or wildcards. Names given alongside a preset are kept and added after the preset's variables (:func:`preset_extra_names`): ``['default', 'energy_total']`` is the default list plus ``energy_total``. ``minimal`` takes precedence over ``default`` when both are given, and ``all``/``*`` next to a preset add nothing. version : int, optional GEDI release whose preset lists to use. Returns ------- dict ``product_vars``, with each spec replaced by its variable list (``None`` for every variable). """ for prod, vars in product_vars.items(): if vars is None: continue if isinstance(vars, list) and len(vars) == 0: # Bare flag (e.g. -l2a with no args) → dump everything product_vars[prod] = None elif os.path.isfile(vars[0]) and len(vars) == 1: with open(vars[0], 'r') as f: product_vars[prod] = [line.strip() for line in f if line.strip() and not line.startswith('#')] elif "minimal" in vars or "min" in vars: # Copy: callers append/purge essentials and quality flags in # place, which must never rewrite the module-level preset table. preset = list(_get_versioned(_GEDI_MIN_VARS[prod], version)) product_vars[prod] = list(dict.fromkeys(preset + preset_extra_names(vars))) elif 'default' in vars or 'def' in vars: with open(get_default_vars_file(prod, version=version), 'r') as f: preset = [line.strip() for line in f if line.strip() and not line.startswith('#')] product_vars[prod] = list(dict.fromkeys(preset + preset_extra_names(vars))) elif "*" in vars or "all" in vars: product_vars[prod] = None elif isinstance(vars, list): continue else: raise GediProductError(f"Unknown variable specification for product {prod}: {vars}") return product_vars
[docs] def expand_var_wildcards(var_specs, available_vars): """Expand fnmatch-style wildcard patterns in variable specifications. Supports ``*``, ``?``, ``[seq]``, ``[!seq]``. Non-pattern specs pass through unchanged. Raises :class:`GediValidationError` if a pattern matches nothing. """ import fnmatch expanded = [] for spec in var_specs: if any(c in spec for c in ('*', '?', '[', ']')): matched = fnmatch.filter(available_vars, spec) if not matched: raise GediValidationError( f"Wildcard pattern '{spec}' matched no available variables" ) expanded.extend(matched) else: expanded.append(spec) return list(dict.fromkeys(expanded)) # deduplicate, preserve order
[docs] def gedi_vars_from_h5(gedi_file): with h5py.File(gedi_file, 'r') as f: b = [i for i in f.keys() if i.upper().startswith('BEAM')][0] pl_info = h5_info(gedi_file, root=b) return pl_info.path.str.replace(b,'').str.lstrip('/').tolist()
_static_vars_cache: Dict = {}
[docs] def gedi_vars_static(product, version=None): """Return the canonical variable list for a GEDI ``(product, version)`` from the static manifest shipped in :mod:`gedih3.data` — no I/O on any target file. NASA release files of the same ``(product, version)`` always carry an identical dataset schema, so this is equivalent to :func:`gedi_vars_from_h5` for canonical release files but free. Returns ``None`` when no static manifest exists for the requested combination; callers should fall back to :func:`gedi_vars_from_h5`. Note: the result reflects a NASA release file's schema. Files that were previously subset (e.g. via :func:`gedi_subset` or S3 ETL mode) contain a *subset* of these variables — for those, use :func:`gedi_vars_from_h5` to introspect what is actually present. """ key = (product, version) if key in _static_vars_cache: return _static_vars_cache[key] try: with open(get_default_vars_file(product, version=version)) as f: vars_list = [ln.strip() for ln in f if ln.strip() and not ln.startswith('#')] except (FileNotFoundError, ValueError): vars_list = None _static_vars_cache[key] = vars_list return vars_list
[docs] def check_soc_file_vars(soc_file, available_products): file_products = {} for prod in available_products.keys(): if prod in soc_file: try: available_vars = gedi_vars_from_h5(soc_file[prod]) except Exception as e: # A single broken granule (truncated download, no BEAM # group, etc.) must not abort the whole validation bag. # _filter_granules will drop the file via h5_is_valid. logger.warning( f"validate_soc_files: skipping unreadable {prod} file " f"{soc_file[prod]}: {type(e).__name__}: {e}" ) continue file_products[prod] = available_vars return file_products
@dask.delayed def _check_soc_file_vars(soc_file, available_products): return check_soc_file_vars(soc_file, available_products)
[docs] def validate_soc_files(product_vars: Dict, soc_dir: str = GH3_DEFAULT_SOC_DIR, version: Optional[int] = None, exclude: Optional[List[str]] = None): """Validate ``product_vars`` against the shipped static manifests. Only authoritative for **canonical NASA release files** validated against the per-product manifest in ``src/gedih3/data/GEDI*_DATASETS_*.txt`` — that is, for the fresh-build ``default`` request (Regime A) or for a resume where the user explicitly re-requests ``default`` for a product (Regime C). On all other resume paths the build log is the contract, and ``gh3_build.py`` bypasses this check entirely. Callers are responsible for filtering ``product_vars`` to only the products they actually want to validate against the static manifest — passing the full union of existing + new vars on a resume will produce false negatives whenever the shipped manifest has drifted relative to the list saved in the build log. """ glob_kwargs = {'version': version} if version is not None else None soc_files = soc_file_tree(soc_dir, to_list=True, glob_kwargs=glob_kwargs, exclude=exclude) if not soc_files: return False, {"error": "No SOC files found in directory"} from .config import get_default_vars_file available_products = {} for prod in soc_files[0].keys(): try: with open(get_default_vars_file(prod, version=version)) as f: available_products[prod] = [ ln.strip() for ln in f if ln.strip() and not ln.startswith('#') ] except (FileNotFoundError, ValueError) as e: logger.warning( f"validate_soc_files: no static manifest for {prod} v{version}; " f"skipping variable check for this product ({e})" ) available_products[prod] = [] validation_report = { "available_products": available_products, "missing_products": [], "missing_variables": {}, "can_skip": True } for prod, required_vars in product_vars.items(): if prod not in available_products: validation_report["missing_products"].append(prod) validation_report["can_skip"] = False elif required_vars is not None: try: required_vars = expand_var_wildcards(required_vars, available_products[prod]) except GediValidationError: pass # unmatched patterns will surface as missing vars below missing_vars = [v for v in required_vars if v not in set(available_products[prod])] if missing_vars: validation_report["missing_variables"][prod] = missing_vars validation_report["can_skip"] = False if not validation_report["can_skip"]: error_msg = "Cannot continue due to missing data.\n" if validation_report.get("missing_products"): error_msg += f"Missing products: {validation_report['missing_products']}\n" if validation_report.get("missing_variables"): for prod, missing_vars in validation_report["missing_variables"].items(): error_msg += f"Missing variables in {prod}: {missing_vars}\n" validation_report['error_msg'] = error_msg.strip() return validation_report
[docs] class GEDIFile: """ Parser for GEDI HDF5 filenames. Extracts metadata from GEDI filename convention including orbit, granule, track, version, and date information. Attributes ---------- file_path : str Full path to the GEDI file full_name : str Basename of the file product : str Product identifier (e.g., 'GEDI02') level : str Processing level (e.g., 'A', 'B') date : datetime Acquisition date and time product_code : str Combined product code (e.g., 'L2A', 'L4A') orbit : int Orbit number orbit_granule : int Granule number within orbit track : int Ground track number version : int Data version number year : str Year string (e.g., '2019') doy : str Day-of-year string (e.g., '338') mission_week : int Mission week number since GEDI launch (2018-12-13) suffix : str or None Extra filename parts after version field, if any Examples -------- >>> gf = GEDIFile('/path/to/GEDI02_A_2020123120000_O12345_01_T00001_02_003_01_V002.h5') >>> gf.orbit 12345 >>> gf.version 2 """ def __init__(self, file_path: Union[str, Path, EarthAccessFile]): if isinstance(file_path, EarthAccessFile): file_path = file_path.path self.parse_file(file_path)
[docs] def __str__(self) -> str: return yaml.dump(self)
[docs] def parse_file(self, f: str) -> None: # check if f is str or s3 link self.file_path = f self.file_size = (os.path.getsize(f) / 1e9) if os.path.exists(f) else None self.full_name = os.path.basename(f) f_base = re.sub(r'\.h5$', '', self.full_name) fl = f_base.split('_') self.product = fl[0] self.level = fl[1] self.date = datetime.strptime(fl[2], '%Y%j%H%M%S') self.date_str = fl[2] self.doy_date_str = self.date.strftime('%Y%m%d') self.julian_date_str = self.date.strftime('%Y%j') self.time_str = self.date.strftime('%H%M%S') self.orbit = int(fl[3][1:]) self.orbit_granule = int(fl[4]) self.track = int(fl[5][1:]) self.positioning = int(fl[6]) self.pge = int(fl[7]) self.generation = int(fl[8]) self.version = int(fl[9][1:]) self.product_code = f"L{self.product[-1]}{self.level}" self.year = str(self.date.year) self.doy = self.date.strftime('%j') self.mission_week = (self.date - GEDI_MISSION_START).days // 7 + 1 self.suffix = '_'.join(fl[10:]) if len(fl) > 10 else None
[docs] def get_glob_pattern(self, product:int=1, level:str='B', ppds:int=2): return gedi_file_glob(self.orbit, self.orbit_granule, self.track, product, level, ppds)
[docs] @classmethod def from_filename(cls, file_path): return cls(file_path)
[docs] def search_file(self, soc_dir: str = None, product:int=1, level:str='B', other_files=None): # other_files = e.g. http paths from direct s3 pattern = self.get_glob_pattern(product, level) if other_files is None: if soc_dir is None: soc_dir = soc_from_file(self.file_path) files = glob.glob(os.path.join(soc_dir, '**', pattern), recursive=True) else: files = [f for f in other_files if re.match(pattern.replace('*','.*'), os.path.basename(f))] if not files: return None if len(files) == 1: return files[0] parsed_files = [GEDIFile.from_filename(f) for f in files] version_matches = [f for f in parsed_files if f.version == self.version] if version_matches: version_matches.sort(key=lambda x: gedi_release_rank(x.file_path), reverse=True) return version_matches[0].file_path parsed_files.sort(key=lambda x: (x.version, *gedi_release_rank(x.file_path)), reverse=True) return parsed_files[0].file_path
[docs] class GEDIShot(GEDIFile): """ Decoder for GEDI shot numbers. Extracts the orbit, beam, granule and shot index encoded in GEDI shot numbers. A shot number has 18 digits, ``OOOOOBBRRGNNNNNNNN``: orbit (5), beam (2), reserved (2), granule within the orbit (1), shot index (8). The ground track is not encoded. Attributes ---------- shot : int or array The shot number(s) orbit : int or array Orbit number orbit_granule : int or array Granule number within orbit track : None Not encoded in the shot number, so file globs match any track (orbit and granule identify the file). beam : int or array Beam number (0-7) beam_str : str or list Beam identifier string (e.g., 'BEAM0101') power : bool or array True if power beam (beam > 3) shot_index : int or array Shot index within the granule Examples -------- >>> shot = GEDIShot(19560500300012345) >>> shot.orbit, shot.orbit_granule, shot.beam_str, shot.shot_index (1956, 3, 'BEAM0101', 12345) """ def __init__(self, shot: Union[int, np.ndarray], build_glob: bool = False): self.parse_shot(shot) if build_glob: self.get_glob_pattern()
[docs] def parse_shot(self, shot: Union[int, np.ndarray]) -> None: self.is_scalar = np.isscalar(shot) self.shot = shot self.orbit = shot // 10000000000000 self.orbit_granule = shot % 1000000000 // 100000000 self.track = None # not encoded; ``shot // 1e11`` is the orbit and beam digits self.beam = shot % 10000000000000 // 100000000000 self.power = self.beam > 3 self.reserved = shot % 1000000000000 // 1000000000 % 100 self.shot_index = shot % 100000000 if self.is_scalar: self.beam_str = f'BEAM{self.beam:04b}' else: self.beam_str = [f'BEAM{b:04b}' for b in self.beam]
[docs] def get_glob_pattern(self, product: int=1, level: str='B', ppds: int=2, pge:int=None, generation:int=None, version:int=None): """Glob pattern(s) of the GEDI file(s) holding these shots (any track). Returns ------- str or list of str One pattern for a scalar shot; for an array, one per distinct granule, sorted. Also stored as ``file_glob_pattern``. """ if self.is_scalar: self.file_glob_pattern = gedi_file_glob(self.orbit, self.orbit_granule, self.track, product, level, ppds, pge, generation, version) else: self.file_glob_pattern = [gedi_file_glob(self.orbit[i], self.orbit_granule[i], self.track, product, level, ppds, pge, generation, version) for i in range(len(self.shot))] self.file_glob_pattern = sorted(set(self.file_glob_pattern)) return self.file_glob_pattern
[docs] def search_file(self, soc_dir: str): """Files under ``soc_dir`` matching ``file_glob_pattern`` (built if absent). Returns ------- list of str Sorted paths; empty if none match. """ # A SOC tree is <soc>/<year>/<doy>/: walk it, as GEDIFile.search_file does # (_soc_manifest.txt is write-only, so smart_glob does not apply). patterns = getattr(self, 'file_glob_pattern', None) or self.get_glob_pattern() if self.is_scalar: patterns = [patterns] files = set() for fp in patterns: files.update(glob.glob(os.path.join(soc_dir, '**', fp), recursive=True)) return sorted(files)
[docs] @njit def wfm_pad(wave, n_bins=1420, cval=0): bin_diff = len(wave) - n_bins if bin_diff == 0: return wave if bin_diff < 0: padded = np.full(n_bins, cval, dtype=wave.dtype) padded[:len(wave)] = wave return padded if bin_diff > 0: return wave[:n_bins]
[docs] @njit def process_waveforms(starts, ends, noises, wfs, n_bins=1420): n_waves = len(starts) waves = np.zeros((n_waves, n_bins), dtype=np.float32) for i in range(n_waves): start = starts[i] end = ends[i] noise = noises[i] if start < 0 or end > len(wfs): waves[i] = np.full(n_bins, noise) continue wf = wfs[start:end] waves[i] = wfm_pad(wf, n_bins, cval=noise) return waves
[docs] def wfm_extract(h5_file, beam, idx, tx=False): init = 't' if tx else 'r' noises = h5_file[f'/{beam}/noise_mean_corrected'][:][idx] starts = h5_file[f'/{beam}/{init}x_sample_start_index'][:][idx] - 1 counts = h5_file[f'/{beam}/{init}x_sample_count'][:][idx] ends = starts + counts wfs = h5_file[f'/{beam}/{init}xwaveform'][:] return process_waveforms(starts, ends, noises, wfs)
def _validate_h5_columns(columns: List[str]) -> List[str]: """ Validate and normalize the column list for HDF5 extraction. Ensures shot_number is included and adds waveform dependencies. Parameters ---------- columns : list of str Requested column names Returns ------- list of str Normalized column list with dependencies added """ columns = list(columns) # Make a copy if 'shot_number' not in columns: columns.append('shot_number') if 'rxwaveform' in columns: columns += ['noise_mean_corrected', 'rx_sample_start_index', 'rx_sample_count'] # dict.fromkeys preserves insertion order; list(set(...)) is hash-seed # dependent and produced different orders per worker process, breaking # the driver-derived canonical schema for L1B builds. columns = list(dict.fromkeys(columns)) if 'txwaveform' in columns: columns += ['noise_mean_corrected', 'tx_sample_start_index', 'tx_sample_count'] columns = list(dict.fromkeys(columns)) return columns def _get_beams_to_load( h5_file: h5py.File, shots: Optional[np.ndarray], which_beams: Optional[List[str]] ) -> tuple: """ Determine which beams to load based on shots and beam filters. Parameters ---------- h5_file : h5py.File Open HDF5 file handle shots : array, optional Specific shot numbers to extract which_beams : list of str, optional Specific beams to load Returns ------- tuple (beams_to_load, all_beams, shot_beams_array_or_none) """ all_beams = [k for k in h5_file.keys() if k.startswith('BEAM')] beams = all_beams shot_beams = None if shots is not None: shot_beams = np.uint16(shots % 10000000000000 // 100000000000) beams = [f'BEAM{b:04b}' for b in np.unique(shot_beams)] if which_beams is not None: beams = [b for b in beams if b in which_beams] return beams, all_beams, shot_beams def _extract_beam_data( h5_file: h5py.File, beam: str, columns: List[str], shots: Optional[np.ndarray], shot_beams: Optional[np.ndarray], include_source: bool ) -> Optional[pd.DataFrame]: """ Extract data from a single beam in an HDF5 file. Parameters ---------- h5_file : h5py.File Open HDF5 file handle beam : str Beam identifier (e.g., 'BEAM0101') columns : list of str Variables to extract shots : array, optional Specific shot numbers to extract shot_beams : array, optional Beam numbers for each shot (pre-computed) include_source : bool Add root_beam column Returns ------- pd.DataFrame or None DataFrame with extracted data, or None if no matching shots """ shot_ids = h5_file[f"{beam}/shot_number"][:] if shots is not None: kint = int(beam[-4:], base=2) k_shots = shots[shot_beams == kint] idx = np.where(np.isin(shot_ids, k_shots)) else: idx = np.arange(0, len(shot_ids)) if len(idx) == 0: return None dfs = {} for j in columns: try: if is_wave := (j == 'rxwaveform' or j == 'txwaveform'): is_tx = j == 'txwaveform' d = wfm_extract(h5_file, beam, idx, is_tx) else: d = h5_file[f"{beam}/{j}"][:][idx] except KeyError as e: # Normalize h5py's two missing-path error shapes ("object 'X' # doesn't exist" vs "component not found") into the single form # _MISSING_VAR_RE picks up, so _classify_load_h5_failure tags the # failure as missing_var with the actual variable name and the # end-of-build recovery advisory groups it correctly. raise KeyError(f"object '{j}' doesn't exist (h5py: {e})") from e if d.ndim == 2: for col in range(d.shape[-1]): jj = f"{j}_{col:0{4 if is_wave else 3}d}" dfs[jj] = d[:, col] else: dfs[j] = d df = pd.DataFrame(dfs) if include_source: df = df.assign(root_beam=beam) return df def _build_dataframe( beam_frames: List[pd.DataFrame], fpath: Union[str, Path, EarthAccessFile], include_source: bool, dropna: bool ) -> pd.DataFrame: """ Build the final DataFrame from extracted beam data. Parameters ---------- beam_frames : list of pd.DataFrame DataFrames from each beam fpath : str, Path, or EarthAccessFile Source file path (for root_file column) include_source : bool Add root_file column dropna : bool Drop rows with NaN values Returns ------- pd.DataFrame Combined DataFrame indexed by shot_number """ full_df = pd.concat(beam_frames) full_df = full_df.set_index('shot_number') if include_source: full_df = full_df.assign( root_file=os.path.basename(fpath.path if isinstance(fpath, EarthAccessFile) else fpath) ) if dropna: full_df = full_df.dropna() return full_df
[docs] def load_h5( fpath: Union[str, Path, EarthAccessFile], columns: List[str], which_beams: Optional[List[str]] = None, shots: Optional[np.ndarray] = None, include_source: bool = True, dropna: bool = True ) -> pd.DataFrame: """ Load data from a GEDI HDF5 file into a pandas DataFrame. Parameters ---------- fpath : str, Path, or EarthAccessFile Path to the GEDI HDF5 file columns : list of str Variable names to extract from the file which_beams : list of str, optional Specific beams to load (e.g., ['BEAM0101', 'BEAM1000']). If None, loads all beams. shots : array, optional Specific shot numbers to extract. If None, loads all shots. include_source : bool, default True Add root_file and root_beam columns to output dropna : bool, default True Drop rows with NaN values Returns ------- pd.DataFrame DataFrame indexed by shot_number containing requested variables """ f = h5py.File(fpath, mode='r', locking=False) # Validate and normalize columns columns = _validate_h5_columns(columns) # Determine which beams to load beams, all_beams, shot_beams = _get_beams_to_load(f, shots, which_beams) # Handle case where no beams match if len(beams) == 0: f.close() return load_h5(fpath=fpath, columns=columns, include_source=include_source, which_beams=all_beams[:1]).head(0) # Extract data from each beam beam_frames = [] for beam in beams: beam_df = _extract_beam_data(f, beam, columns, shots, shot_beams, include_source) if beam_df is not None: beam_frames.append(beam_df) f.close() # Build final DataFrame return _build_dataframe(beam_frames, fpath, include_source, dropna)
[docs] def load_h5_merged( prod_files: Dict[str, str], product_vars: Dict[str, List[str]], which_beams: Optional[List[str]] = None, shots: Optional[np.ndarray] = None, dropna: bool = True, suffix_all: bool = False ) -> Optional[pd.DataFrame]: """ Load and merge data from multiple GEDI product files. Combines data from different GEDI products (L2A, L2B, L4A, etc.) into a single DataFrame, joining on shot_number. Parameters ---------- prod_files : dict Mapping of product codes to file paths (e.g., {'L2A': '/path/to/l2a.h5'}) product_vars : dict Mapping of product codes to lists of variable names to extract which_beams : list of str, optional Specific beams to load shots : array, optional Specific shot numbers to extract dropna : bool, default True Drop rows with NaN values suffix_all : bool, default False If True, suffix all columns with product code (e.g., 'rh_l2a'). Required when variables have the same name across products. Returns ------- pd.DataFrame or None Merged DataFrame indexed by shot_number, or None if no data """ frames = [] for i, (p, vars) in enumerate(product_vars.items()): ppath = prod_files.get(p) if ppath is None: continue idf = load_h5(fpath=ppath, columns=vars, include_source = suffix_all or i==0, which_beams=which_beams, shots=shots, dropna=dropna) suffix = f"_{p.lower()}" if suffix_all: idf = idf.rename(columns=lambda x: x if x.endswith(suffix) else f"{x}{suffix}") frames.append(idf) if not frames: return None if not suffix_all: all_cols = [col for frame in frames for col in frame.columns] if len(all_cols) != len(set(all_cols)): raise GediVariableError("Duplicate columns detected. Remove duplicates or use suffix_all=True to avoid conflicts.") df = pd.concat(frames, axis=1, join='inner') return df
[docs] def dask_h5_merged( prod_files_list: List[Dict[str, str]], product_vars: Dict[str, List[str]], which_beams: Optional[List[str]] = None, shots: Optional[np.ndarray] = None, dropna: bool = True, suffix_all: bool = False, by_beam: bool = False ) -> dask.dataframe.DataFrame: """ Create a Dask DataFrame from multiple GEDI product file sets. Lazily loads and merges GEDI data for distributed processing. Parameters ---------- prod_files_list : list of dict List of product file dictionaries from soc_file_tree() product_vars : dict Mapping of product codes to variable lists which_beams : list of str, optional Specific beams to load shots : array, optional Specific shot numbers to extract dropna : bool, default True Drop rows with NaN values suffix_all : bool, default False Suffix all columns with product code by_beam : bool, default False If True, create separate partitions for each beam (more parallelism) Returns ------- dask.dataframe.DataFrame Lazy Dask DataFrame for distributed processing """ # Probe files in order to derive the dask metadata schema. A single broken # granule at the head of the list (corrupt HDF5, missing variable inside a # BEAM, etc.) must not abort the whole build — skip it with a warning and # try the next. Per-partition errors at runtime are already swallowed by # the `_load_h5_merged` closure below. _meta = None _skipped = 0 for i, _pf in enumerate(prod_files_list): try: _meta = load_h5_merged( _pf, product_vars=product_vars, which_beams=GEDI_BEAMS[:1], dropna=dropna, suffix_all=suffix_all, ).head(0) if i > 0: logger.warning( f"Skipped {i} broken file(s) while deriving metadata schema; using {_pf} as the schema sample" ) break except Exception as e: _skipped += 1 logger.warning(f"Skipping broken file for metadata sample: {_pf} ({e})") continue if _meta is None: raise GediFileError( f"Could not derive a metadata schema: all {_skipped} candidate file(s) failed to read" ) def _load_h5_merged(prod_files, **kwargs): try: return load_h5_merged(prod_files, **kwargs) except Exception as e: logger.warning(f"Error loading file combination: {prod_files}") return _meta if by_beam: beams = GEDI_BEAMS if which_beams is None else which_beams def load_by_beam(pfiles_beam_tuple, product_vars, shots, dropna, suffix_all): pfiles, beam = pfiles_beam_tuple return _load_h5_merged(pfiles, product_vars=product_vars, which_beams=[beam], shots=shots, dropna=dropna, suffix_all=suffix_all) file_beam_combinations = list(itertools.product(prod_files_list, beams)) return dask.dataframe.from_map(load_by_beam, file_beam_combinations, product_vars=product_vars, shots=shots, dropna=dropna, suffix_all=suffix_all, meta=_meta) return dask.dataframe.from_map(_load_h5_merged, prod_files_list, which_beams=which_beams, shots=shots, product_vars=product_vars, dropna=dropna, suffix_all=suffix_all, meta=_meta)
[docs] def add_special_columns(df, lon_col:str=None, lat_col:str=None, dat_col:str=None): if dat_col: df = df.assign(datetime=pd.to_datetime(df[dat_col] + GEDI_START_DATE.timestamp(), unit='s').dt.as_unit('s')) if lon_col and lat_col: df = gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df[lon_col], df[lat_col], crs='EPSG:4326')) return df.copy()