Source code for gedih3.cli.gh3_build_ducklake
#! 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
import sys
import shutil
import pathlib
import argparse
[docs]
def get_cmd_args():
from gedih3.cliutils import add_verbosity_args
p = argparse.ArgumentParser(
description=(
"Build a DuckLake metadata table from an existing H3 parquet database "
"to enable SQL queries on GEDI data via DuckDB. "
"Files are expected to follow the layout: "
"h3_XX=*/year=*/<hex>.<year>.0.parquet "
"where XX is the H3 partition level stored in the build log."
)
)
p.add_argument(
"-d", "--database",
dest="database",
type=str,
default=None,
help="H3 database directory (default: GH3_DEFAULT_H3_DIR)",
)
p.add_argument(
"-t", "--tmpdir",
dest="tmpdir",
type=str,
default=None,
help="temporary directory for DuckLake data files (default: GH3_DEFAULT_TMP_DIR/ducklake_temp)",
)
add_verbosity_args(p)
return p.parse_args()
[docs]
def get_file_list(root_dir):
"""List the parquet files of the H3 database.
Uses the manifest-aware ``smart_glob`` (the `_manifest.txt` sentinel is
O(1) on the GPFS metadata server) instead of guessing filenames from a
year range and the ``<hex>.<year>.0.parquet`` naming contract — the
actual listing also catches fragments the guess would miss.
Parameters
----------
root_dir : str or pathlib.Path
Root directory of the H3 database.
Returns
-------
list of pathlib.Path
Paths to parquet files, ordered so the first element is guaranteed
to exist (used to infer the table schema).
"""
from gedih3.gh3driver import gh3_read_meta
from gedih3.utils import smart_glob, smart_join
root_dir = pathlib.Path(root_dir)
part_level = gh3_read_meta("h3_partition_level", gh3_root_dir=str(root_dir))
if part_level is None:
part_level = 3
pattern = smart_join(str(root_dir), f"h3_{part_level:02d}=*/year=*/*.parquet")
files = [pathlib.Path(f) for f in sorted(smart_glob(pattern))]
# Guarantee the first element exists — it is used to create the table
# schema (a stale manifest may list files that were since removed).
while files and not files[0].exists():
files.pop(0)
return files
[docs]
def main():
from gedih3.config import GH3_DEFAULT_H3_DIR, GH3_DEFAULT_TMP_DIR
from gedih3.cliutils import setup_logging, print_banner, print_success, cli_exception_handler, resolve_path_args
from gedih3 import sqlutils
import tqdm
args = get_cmd_args()
logger = setup_logging(args, __name__)
print_banner("GEDI DuckLake Builder Tool", logger=logger)
resolve_path_args(args, ['database', 'tmpdir'], logger=logger)
database = pathlib.Path(args.database or GH3_DEFAULT_H3_DIR)
tmpdir = args.tmpdir or f"{GH3_DEFAULT_TMP_DIR}/ducklake_temp"
with cli_exception_handler(args, logger=logger):
logger.info("Generating list of parquet files ...")
files = get_file_list(database)
if not files:
logger.error(f"No parquet files found in {database}")
sys.exit(2)
logger.info(f"Found {len(files)} files to process")
con = sqlutils.init_duckdb(temp_directory=tmpdir)
# The DATA_PATH stores no actual data and can be deleted after load
# because the data lives in the existing parquet files.
con.sql(f"""--sql
ATTACH 'ducklake:gedi.ducklake' AS gedi_dl (
DATA_PATH '{tmpdir}');
""")
# Build schema from the first file. Geometry is excluded because
# ducklake only supports GeoParquet V2, but the H3 files are V1.
con.sql(f"""--sql
CREATE OR REPLACE TABLE gedi_dl.data AS
SELECT * EXCLUDE geometry
FROM read_parquet('{files[0]}', hive_partitioning=true)
WITH NO DATA;
""")
for file in tqdm.tqdm(files, desc="Loading parquet files", disable=args.quiet):
# fmt: off
if file.exists():
con.execute(f"CALL ducklake_add_data_files('gedi_dl', 'data', '{file.as_posix()}', ignore_extra_columns => true);")
# fmt: on
ducklake_dest = database / "gedi.ducklake"
logger.info(f"Saving DuckLake metadata to {ducklake_dest} ...")
shutil.move("gedi.ducklake", ducklake_dest)
print_success(f"DuckLake metadata saved to {ducklake_dest}", logger=logger)
if __name__ == "__main__":
main()