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
import datetime as dt
[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):
"""Generate a list of all expected parquet files in the H3 database.
Parameters
----------
root_dir : str or pathlib.Path
Root directory of the H3 database.
Returns
-------
list of pathlib.Path
Paths to parquet files that exist on disk, ordered so the first element
is guaranteed to exist (used to infer the table schema).
"""
from gedih3.gh3driver import gh3_read_meta
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
folders = list(root_dir.glob(f"h3_{part_level:02d}=*"))
years = range(2019, dt.datetime.now().year + 1)
files = []
for f in folders:
hex_id = f.name.split("=")[1]
for y in years:
files.append(f / f"year={y}" / f"{hex_id}.{y}.0.parquet")
# Guarantee the first element exists — it is used to create the table schema.
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()