Source code for gedih3.cli.gh3_update

#! 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

"""
GEDI Dataset Update Tool

Add new columns to an existing simplified dataset (created by gh3_extract).
Two modes:

Mode 1: Add columns from the source H3 database via shot_number join.
Mode 2: Merge columns from another simplified dataset via shot_number join.

Both modes modify the target dataset in place.
"""

import os
import re
import argparse


def _query_filter_columns(query_filter, available_columns):
    """Extract column names referenced in a pandas query filter string.

    Returns only identifiers that match known database columns,
    excluding Python keywords and numeric literals.
    """
    if not query_filter:
        return []
    available = set(available_columns)
    # Match backtick-quoted identifiers (may contain '/' or other special chars)
    backtick_tokens = set(re.findall(r'`([^`]+)`', query_filter))
    # Match regular word tokens
    word_tokens = set(re.findall(r'\b([a-zA-Z_]\w*)\b', query_filter))
    all_tokens = backtick_tokens | word_tokens
    python_keywords = {'and', 'or', 'not', 'in', 'True', 'False', 'None',
                       'is', 'nan', 'NaN', 'inf', 'Inf'}
    return [t for t in all_tokens if t in available and t not in python_keywords]


_QUERY_KEYWORDS = {'and', 'or', 'not', 'in', 'is', 'True', 'False', 'None',
                   'nan', 'NaN', 'inf', 'Inf'}


def _query_required_names(query_filter):
    """Return every identifier a pandas query needs to resolve as a column.

    Unlike ``_query_filter_columns`` (which intersects against a known column
    set), this returns the *raw* set of names the query references, so the
    caller can detect names that are absent from a target schema. String
    literals are stripped first so their contents aren't mistaken for columns.
    """
    if not query_filter:
        return set()
    # Backtick-quoted names are unambiguously columns (may contain '/').
    backtick_tokens = set(re.findall(r'`([^`]+)`', query_filter))
    # Drop quoted string literals so e.g. `name == 'Brazil'` doesn't flag 'Brazil'.
    no_strings = re.sub(r"'[^']*'", '', query_filter)
    no_strings = re.sub(r'"[^"]*"', '', no_strings)
    word_tokens = set(re.findall(r'\b([a-zA-Z_]\w*)\b', no_strings))
    return (backtick_tokens | word_tokens) - _QUERY_KEYWORDS


def _make_plain_reader(fmt, columns=None):
    """Create a plain pandas reader (no geometry) for merge sources."""
    import pandas as pd
    if fmt == 'parquet':
        return lambda f: pd.read_parquet(f, columns=columns)
    elif fmt == 'feather':
        return lambda f: pd.read_feather(f, columns=columns)
    else:
        raise ValueError(f"Unsupported format for plain reader: {fmt}")


[docs] def get_cmd_args(): """Parse command line arguments for dataset update""" from gedih3.cliutils import add_dask_args, add_verbosity_args, add_product_args, add_storage_args p = argparse.ArgumentParser( description="Add new columns to an existing simplified dataset", formatter_class=argparse.RawTextHelpFormatter ) # Target dataset (modified in place) p.add_argument("-d", "--dataset", dest="dataset", required=True, type=str, help="path to target simplified dataset directory (modified in place)") # Mode 1: Add columns from H3 database p.add_argument("-D", "--database", dest="database", type=str, default=None, help="source H3 database (default: from dataset metadata)") p.add_argument("-l", "--list", dest="list", nargs='+', type=str, default=None, help="variables to add (space-separated, file path, or wildcards like 'rh_*')") add_product_args(p, include_detail_level=False) # Mode 2: Merge from another dataset p.add_argument("-m", "--merge", dest="merge", type=str, default=None, help="path to another simplified dataset to merge columns from") # Dask, storage, and verbosity add_dask_args(p) add_storage_args(p) add_verbosity_args(p) return p.parse_args()
def _detect_shot_number_col(col_names): """Find the shot_number column name in a schema.""" sn_cols = [c for c in col_names if c.startswith('shot_number')] if not sn_cols: return None return sn_cols[0] def _join_new_columns(target_df, new_df, new_cols, sn_col): """Left join new columns onto target via shot_number, preserving all target rows.""" idx_name = target_df.index.name target_reset = target_df.reset_index() merge_subset = new_df[[sn_col] + new_cols].drop_duplicates(subset=[sn_col]) merged = target_reset.merge(merge_subset, on=sn_col, how='left') if idx_name: merged = merged.set_index(idx_name) return merged def _update_from_database(args, dataset_path, dataset_meta, logger): """Mode 1: Add columns from H3 database via shot_number join.""" import json import numpy as np import pandas as pd from dask.distributed import Client, progress import gedih3.gh3driver as gh3 from gedih3.cliutils import (collect_columns, parse_dask_args, detect_dataset_format, list_dataset_files, read_dataset_schema, make_dataset_reader, h3_col_name) # Determine source H3 database from gedih3.utils import smart_exists db_path = args.database or dataset_meta.get('source_database') if not db_path or not smart_exists(db_path): raise FileNotFoundError( f"Source H3 database not found: {db_path}\n" "Specify the H3 database with -D/--database" ) # If source_database points to a simplified dataset (not an H3 database), # walk back the metadata chain to find the original H3 database. from gedih3.config import BUILD_LOG_FILENAME, DATASET_META_FILENAME from gedih3.utils import smart_join build_log_path = smart_join(db_path, BUILD_LOG_FILENAME) if not smart_exists(build_log_path): chain_meta_path = smart_join(db_path, DATASET_META_FILENAME) if smart_exists(chain_meta_path): with open(chain_meta_path, 'r') as f: chain_meta = json.load(f) upstream = chain_meta.get('source_database') if upstream and smart_exists(smart_join(upstream, BUILD_LOG_FILENAME)): logger.info(f" source_database is a simplified dataset, tracing back to: {upstream}") db_path = upstream else: raise FileNotFoundError( f"source_database '{db_path}' is not an H3 database and its upstream " f"H3 database could not be found.\n" "Specify the H3 database explicitly with -D/--database" ) else: raise FileNotFoundError( f"Not an H3 database (no build log): {db_path}\n" "Specify the H3 database with -D/--database" ) logger.info(f" Source database: {db_path}") # Surface non-INDEXED granules in the source DB. Class A failures from # gh3_build (orbits whose HDF5 lacked a requested variable) leave gaps # that propagate silently into every downstream simplified dataset and # every gh3_update'd version — once the operator knows, they can # either re-run gh3_build with a trimmed var list to recover those # granules, or proceed knowingly. Read the source build log once and # count by status; cheap (single JSON read) and lives entirely in the # data we already opened above for db_path validation. try: with open(build_log_path, 'r') as _blf: _bl = json.load(_blf) _granules = _bl.get('granules') or [] if _granules: from collections import Counter _status_counts = Counter(g.get('status', 'UNKNOWN') for g in _granules) _non_indexed = sum( c for s, c in _status_counts.items() if s != 'INDEXED' ) if _non_indexed: logger.warning( f"Source database has {_non_indexed}/{len(_granules)} " f"non-INDEXED granule(s) — those shots will be ABSENT " f"from the joined columns. Breakdown: {dict(_status_counts)}. " f"Run `gh3_build` on the source database to retry; see " f"its end-of-build advisory for the recovery recipe." ) except (OSError, json.JSONDecodeError) as _e: logger.debug(f"Could not enumerate source-db granule status: {_e}") # Resolve new columns from product args # Set safe defaults for attributes collect_columns may access for attr, default in [('region', None), ('time_start', None), ('time_end', None), ('quality', False), ('geo', False), ('add_datetime', False)]: if not hasattr(args, attr): setattr(args, attr, default) args_db = argparse.Namespace(**vars(args)) args_db.database = db_path available_columns = gh3.gh3_read_meta('h3_columns', gh3_root_dir=db_path) new_cols = collect_columns(args_db, available_columns=available_columns) # Read target schema to filter out already-present columns fmt = detect_dataset_format(dataset_path) data_files = list_dataset_files(dataset_path, fmt) target_cols, _ = read_dataset_schema(data_files[0], fmt) sn_col = _detect_shot_number_col(target_cols) if not sn_col: raise ValueError( "Target dataset does not contain shot_number. " "Re-extract with the updated gh3_extract to include shot_number." ) # Filter to truly new columns new_cols = [c for c in new_cols if c not in target_cols and c != 'geometry' and c != 'datetime'] if not new_cols: logger.warning("No new columns to add — all requested columns already exist in the dataset.") return # Validate new columns exist in H3 database missing = [c for c in new_cols if c not in available_columns] if missing: raise ValueError(f"Columns not found in H3 database: {', '.join(missing)}") logger.info(f" New columns to add: {new_cols}") # Determine index type and partition column index_type = dataset_meta.get('index_type', 'h3') query_filter = dataset_meta.get('query_filter') # The stored query_filter was built against the *target's* source database. # When adding columns from a different database (db_path) that lacks some of # the filtered columns — e.g. the target was extracted with L2A/L4A quality # flags but we're now joining L1B columns from an L1B-only database — applying # the filter to that source would raise UndefinedVariableError. The left join # on shot_number already restricts the source rows to the (already-filtered) # target shots, so filtering the source is only a memory optimization: safe to # drop whenever it can't be applied. Apply it only when every name it # references is loadable from the source database. loadable_names = set(available_columns) | {sn_col} missing_names = sorted(_query_required_names(query_filter) - loadable_names) if missing_names: logger.info( f" Query filter references column(s) absent from the source database " f"({', '.join(missing_names)}); loading the source unfiltered and " f"relying on the shot_number left join to drop non-matching rows." ) effective_filter = None extra_filter_cols = [] else: effective_filter = query_filter # Extra columns needed by the query filter that aren't in new_cols. filter_cols = _query_filter_columns(query_filter, available_columns) extra_filter_cols = [c for c in filter_cols if c not in new_cols and c != sn_col] dask_kwargs = parse_dask_args(args) with Client(**dask_kwargs) as client: logger.info(f" Dask dashboard: {client.dashboard_link}") if index_type == 'h3': _update_h3_partitions( dataset_path, db_path, data_files, fmt, new_cols, sn_col, effective_filter, extra_filter_cols, dataset_meta, logger, args=args ) elif index_type == 'egi': _update_egi_partitions( dataset_path, db_path, data_files, fmt, new_cols, sn_col, effective_filter, extra_filter_cols, dataset_meta, logger, args=args ) else: raise ValueError(f"Unsupported index type: {index_type}") # Update metadata existing_cols = dataset_meta.get('columns', []) updated_cols = sorted(set(existing_cols + new_cols)) gh3.gh3_write_dataset_meta( opath=dataset_path, index_type=dataset_meta.get('index_type', 'h3'), index_level=dataset_meta.get('index_level'), columns=updated_cols, source_database=db_path, query_filter=query_filter, tool='gh3_update', file_format=fmt, **{k: v for k, v in dataset_meta.items() if k in ('egi_index_level', 'egi_partition_level', 'h3_partition_level')} ) logger.info(" Metadata updated.") def _update_h3_partitions(dataset_path, db_path, data_files, fmt, new_cols, sn_col, query_filter, extra_filter_cols, dataset_meta, logger, args=None): """Update H3-partitioned dataset files from source H3 database.""" import numpy as np import pandas as pd import geopandas as gpd import gedih3.gh3driver as gh3 from gedih3.cliutils import make_dataset_reader, h3_col_name, progress_iter from gedih3.utils import smart_exists, smart_join h3_part = gh3.gh3_read_meta('h3_partition_level', gh3_root_dir=db_path) h3_part_col = h3_col_name(h3_part) load_cols = [sn_col] + new_cols + extra_filter_cols reader = make_dataset_reader(fmt) n_updated = 0 with progress_iter(data_files, desc="Updating H3 partitions", args=args, unit="part") as bar: for fpath in bar: part_id = os.path.splitext(os.path.basename(fpath))[0] h3_dir = smart_join(db_path, f"{h3_part_col}={part_id}") target_df = reader(fpath) if len(target_df) == 0: continue if smart_exists(h3_dir): source_df = gh3.gh3_load_hex(h3_dir, columns=load_cols) if query_filter: from gedih3.cliutils import safe_query source_df = safe_query(source_df, query_filter) if extra_filter_cols: source_df = source_df.drop(columns=extra_filter_cols, errors='ignore') target_df = _join_new_columns(target_df, source_df, new_cols, sn_col) else: for c in new_cols: target_df[c] = np.nan gh3._write_dataframe(target_df, fpath, fmt) n_updated += 1 logger.info(f" Updated {n_updated}/{len(data_files)} partition files.") def _update_egi_partitions(dataset_path, db_path, data_files, fmt, new_cols, sn_col, query_filter, extra_filter_cols, dataset_meta, logger, args=None): """Update EGI-partitioned dataset files from source H3 database.""" import numpy as np import pandas as pd import geopandas as gpd import gedih3.gh3driver as gh3 from gedih3.cliutils import make_dataset_reader, progress_iter from gedih3.egi.config import egi_col_name from gedih3.utils import smart_exists, smart_join # Get EGI partition level from metadata egi_partition_level = dataset_meta.get('egi_partition_level', 12) egi_part_col = egi_col_name(egi_partition_level) # Prepare EGI↔H3 intersection at the dataset's partition level — the # egi_to_h3 keys must be the same level as the hashes encoded in the # partition filenames. egi_tiles, egi_to_h3, h3_part_col, _ = gh3._prepare_egi_loading( None, db_path, partition_level=egi_partition_level) load_cols = [sn_col] + new_cols + extra_filter_cols reader = make_dataset_reader(fmt) n_updated = 0 with progress_iter(data_files, desc="Updating EGI partitions", args=args, unit="part") as bar: for fpath in bar: part_id = os.path.splitext(os.path.basename(fpath))[0] # Filenames carry the EGI hash as a decimal string, but egi_to_h3 # is keyed by the numeric hash (egi_tiles.index values). try: h3_list = egi_to_h3.get(np.uint64(part_id), []) except ValueError: logger.warning(f" Skipping non-EGI partition filename: {fpath}") h3_list = [] target_df = reader(fpath) if len(target_df) == 0: continue source_dfs = [] for h3_id in h3_list: h3_dir = smart_join(db_path, f"{h3_part_col}={h3_id}") if smart_exists(h3_dir): df = gh3.gh3_load_hex(h3_dir, columns=load_cols) if len(df) > 0: source_dfs.append(df) if source_dfs: source_df = pd.concat(source_dfs, ignore_index=True) if query_filter: from gedih3.cliutils import safe_query source_df = safe_query(source_df, query_filter) if extra_filter_cols: source_df = source_df.drop(columns=extra_filter_cols, errors='ignore') target_df = _join_new_columns(target_df, source_df, new_cols, sn_col) else: for c in new_cols: target_df[c] = np.nan gh3._write_dataframe(target_df, fpath, fmt) n_updated += 1 logger.info(f" Updated {n_updated}/{len(data_files)} partition files.") def _update_from_merge(args, dataset_path, dataset_meta, logger): """Mode 2: Merge columns from another simplified dataset via shot_number join.""" import json import numpy as np import pandas as pd from dask.distributed import Client, progress import gedih3.gh3driver as gh3 from gedih3.cliutils import (parse_dask_args, detect_dataset_format, list_dataset_files, read_dataset_schema, make_dataset_reader, is_internal_column) from gedih3.config import DATASET_META_FILENAME merge_path = args.merge # Validate merge dataset from gedih3.utils import smart_exists, smart_join merge_meta_path = smart_join(merge_path, DATASET_META_FILENAME) if not smart_exists(merge_meta_path): raise FileNotFoundError(f"Not a simplified dataset (no {DATASET_META_FILENAME}): {merge_path}") with open(merge_meta_path, 'r') as f: merge_meta = json.load(f) # Validate same index type target_index_type = dataset_meta.get('index_type') merge_index_type = merge_meta.get('index_type') if target_index_type != merge_index_type: raise ValueError( f"Index type mismatch: target is '{target_index_type}', " f"merge source is '{merge_index_type}'" ) # Read schemas target_fmt = detect_dataset_format(dataset_path) merge_fmt = detect_dataset_format(merge_path) target_files = list_dataset_files(dataset_path, target_fmt) merge_files = list_dataset_files(merge_path, merge_fmt) target_cols, _ = read_dataset_schema(target_files[0], target_fmt) merge_cols, _ = read_dataset_schema(merge_files[0], merge_fmt) # Detect shot_number in both target_sn = _detect_shot_number_col(target_cols) merge_sn = _detect_shot_number_col(merge_cols) if not target_sn: raise ValueError( "Target dataset does not contain shot_number. " "Re-extract with the updated gh3_extract to include shot_number." ) if not merge_sn: raise ValueError("Merge dataset does not contain shot_number.") # Use same shot_number column name (they should match, but handle suffix differences) sn_col = target_sn # Identify new columns (non-duplicate, non-internal) new_cols = [c for c in merge_cols if c not in target_cols and c != 'geometry' and not is_internal_column(c)] if not new_cols: logger.warning("No new columns to merge — all columns already exist in the target dataset.") return logger.info(f" New columns to merge: {new_cols}") # Build merge file map: {partition_id: file_path} merge_map = {} for f in merge_files: part_id = os.path.splitext(os.path.basename(f))[0] merge_map[part_id] = f # Process each target file target_reader = make_dataset_reader(target_fmt) merge_reader_cols = [merge_sn] + new_cols # Use pandas (not geopandas) reader — geometry is not needed for the join # and gpd.read_parquet fails when the requested columns don't include geometry merge_reader = _make_plain_reader(merge_fmt, columns=merge_reader_cols) n_updated = 0 n_no_match = 0 # Check if partition names match between target and merge datasets matched_count = sum(1 for f in target_files if os.path.splitext(os.path.basename(f))[0] in merge_map) from gedih3.cliutils import progress_iter if matched_count == 0 and merge_map: # Partition names don't match — fall back to global shot_number join logger.info(" Partition names don't match — joining by shot_number across all files") merge_all = pd.concat([merge_reader(f) for f in merge_files], ignore_index=True) merge_all = merge_all.drop_duplicates(subset=[merge_sn]) if merge_sn != sn_col and merge_sn in merge_all.columns: merge_all = merge_all.rename(columns={merge_sn: sn_col}) with progress_iter(target_files, desc="Merging columns", args=args, unit="part") as bar: for fpath in bar: target_df = target_reader(fpath) if len(target_df) == 0: continue target_df = _join_new_columns(target_df, merge_all, new_cols, sn_col) gh3._write_dataframe(target_df, fpath, target_fmt) n_updated += 1 else: with progress_iter(target_files, desc="Merging partitions", args=args, unit="part") as bar: for fpath in bar: part_id = os.path.splitext(os.path.basename(fpath))[0] target_df = target_reader(fpath) if len(target_df) == 0: continue merge_file = merge_map.get(part_id) if merge_file and smart_exists(merge_file): merge_df = merge_reader(merge_file) # Rename shot_number if different between datasets if merge_sn != sn_col and merge_sn in merge_df.columns: merge_df = merge_df.rename(columns={merge_sn: sn_col}) target_df = _join_new_columns(target_df, merge_df, new_cols, sn_col) n_updated += 1 else: for c in new_cols: target_df[c] = np.nan n_no_match += 1 gh3._write_dataframe(target_df, fpath, target_fmt) logger.info(f" Updated {n_updated}/{len(target_files)} files ({n_no_match} with no matching merge partition).") # Update metadata existing_cols = dataset_meta.get('columns', []) updated_cols = sorted(set(existing_cols + new_cols)) gh3.gh3_write_dataset_meta( opath=dataset_path, index_type=dataset_meta.get('index_type', 'h3'), index_level=dataset_meta.get('index_level'), columns=updated_cols, source_database=dataset_meta.get('source_database'), query_filter=dataset_meta.get('query_filter'), tool='gh3_update', file_format=target_fmt, **{k: v for k, v in dataset_meta.items() if k in ('egi_index_level', 'egi_partition_level', 'h3_partition_level')} ) logger.info(" Metadata updated.")
[docs] def main(): args = get_cmd_args() from gedih3.cliutils import cli_exception_handler with cli_exception_handler(args): import json from gedih3.cliutils import setup_logging, print_banner, print_success, setup_storage, resolve_path_args logger = setup_logging(args, __name__) setup_storage(args, logger=logger) print_banner("GEDI Dataset Update Tool", logger=logger) resolve_path_args(args, ['dataset', 'database', 'merge'], logger=logger) from gedih3.config import DATASET_META_FILENAME dataset_path = args.dataset # Validate target dataset from gedih3.utils import smart_exists, smart_join meta_path = smart_join(dataset_path, DATASET_META_FILENAME) if not smart_exists(meta_path): raise FileNotFoundError( f"Not a simplified dataset (no {DATASET_META_FILENAME}): {dataset_path}\n" "gh3_update requires a dataset created by gh3_extract." ) with open(meta_path, 'r') as f: dataset_meta = json.load(f) logger.info(f" Target dataset: {dataset_path}") logger.info(f" Index type: {dataset_meta.get('index_type')}") # Determine mode has_product_args = any( getattr(args, k.lower(), None) is not None for k in ('L1B', 'L2A', 'L2B', 'L4A', 'L4C') ) has_list_arg = args.list is not None has_db_mode = has_product_args or has_list_arg has_merge_mode = args.merge is not None if has_db_mode and has_merge_mode: raise ValueError( "Cannot use both database mode (-l/-l2a/etc.) and merge mode (-m) simultaneously." ) if not has_db_mode and not has_merge_mode: raise ValueError( "Specify columns to add from H3 database (-l/-l2a/-l4a/etc.) " "or a dataset to merge (-m)." ) if has_db_mode: logger.info("Mode: Add columns from H3 database") _update_from_database(args, dataset_path, dataset_meta, logger) else: logger.info("Mode: Merge columns from another dataset") _update_from_merge(args, dataset_path, dataset_meta, logger) print_success("Dataset updated successfully", logger=logger)
if __name__ == '__main__': main()