# 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 os, glob
from typing import Dict
from datetime import datetime, timezone
from .config import GEDI_PRODUCTS, GH3_DEFAULT_DOWNLOAD_DIR, GH3_DEFAULT_SOC_DIR, GH3_DEFAULT_H3_DIR, BUILD_LOG_FILENAME, PARTITION_META_FILENAME
from .exceptions import GediValidationError
from .utils import now, json_read, json_write, read_vector_file, to_geojson, from_geojson, parse_spatial, merge_spatial, parse_temporal, get_package_version
from .h3utils import intersect_h3_geometries
from .gedidriver import (GEDIFile, gedi_vars_expand, soc_file_tree, check_soc_file_vars, validate_soc_files,
write_soc_manifest, gedi_release_rank, gedi_release_tag)
from .gh3driver import gh3_list_files
_VALID_STATUSES = (
'DOWNLOADING',
'PROCESSING',
'PARTITIONING',
'MERGING',
'COMPLETED',
'FAILED',
'INTERRUPTED',
'UNKNOWN'
)
# Per-granule per-product status vocabulary (used by gh3_doctor and lazy upgrade
# of legacy log entries). Top-level granule 'status' remains the worst per-product
# value for backwards compatibility with readers that ignore the products map.
PRODUCT_STATUS_INDEXED = 'INDEXED'
PRODUCT_STATUS_PARTIAL_NAN = 'PARTIAL_NAN'
PRODUCT_STATUS_MISSING_COLUMN = 'MISSING_COLUMN'
PRODUCT_STATUS_MISSING_SOURCE = 'MISSING_SOURCE'
PRODUCT_STATUS_FAILED = 'FAILED'
PRODUCT_STATUS_PENDING = 'PENDING'
# Top-level granule status (never a per-product status): the granule was read in
# full by Stage 1 under the recorded scope and yielded zero rows (e.g. entirely
# outside a land-mask ROI). Skipped like INDEXED while the scope is unchanged.
# Set only on positive evidence by ``H3BuildLogger.mark_no_data``; a granule
# found on disk becomes INDEXED again. Valid only for the scope it was recorded
# under: any spatial/temporal widening reopens it (the logger demotes it to
# PENDING when it detects one), as does merging a log that never examined it.
GRANULE_STATUS_NO_DATA = 'NO_DATA'
_VALID_PRODUCT_STATUSES = (
PRODUCT_STATUS_INDEXED,
PRODUCT_STATUS_PARTIAL_NAN,
PRODUCT_STATUS_MISSING_COLUMN,
PRODUCT_STATUS_MISSING_SOURCE,
PRODUCT_STATUS_FAILED,
PRODUCT_STATUS_PENDING,
)
def _products_from_columns(columns, active_products):
"""Return the subset of ``active_products`` whose suffix appears in ``columns``.
A product P is considered present if any column ends with ``_<p>`` (the
suffix convention used by `_add_variables_to_year_file` and `load_h5_merged`).
"""
if not columns or not active_products:
return set()
cols_lower = [str(c).lower() for c in columns]
return {p for p in active_products if any(c.endswith(f"_{p.lower()}") for c in cols_lower)}
def _per_product_status_from_observed(active_products, observed_products):
"""Map active products to INDEXED if observed, else MISSING_COLUMN.
PARTIAL_NAN is not derived here because it requires a row-level scan;
it is populated by gh3_doctor after `_load_filters_from_log`.
"""
return {
p: PRODUCT_STATUS_INDEXED if p in observed_products else PRODUCT_STATUS_MISSING_COLUMN
for p in active_products
}
def _scan_partition_meta_post_build_info(partition_dir, *, meta_filename, active_products, verify=False):
"""Worker: read PARTITION_META JSONs under one h3_* partition and return
the compact aggregate the driver needs to fold into set_post_build_info.
Module-level so it pickles for dask. Per-file errors are non-fatal — the
affected file contributes nothing, matching the legacy serial behavior
(any json_read raise would have aborted the whole scan; we instead skip).
With ``verify=True`` the result also carries ``'unverified'``: True when
this partition's metadata may not list every granule it holds (an
unreadable sidecar, no sidecar at all, or fewer per-year sidecars than
parquet files). ``set_post_build_info`` uses it to refuse the "granule
absent from every partition" inference behind ``NO_DATA``.
"""
out = {
'granules': [],
'date_min': None,
'date_max': None,
'h3_partitions': [],
'columns': [],
'l2a_version': None,
'column_dtypes': {},
'partition_products': set(),
'unverified': False,
}
cols_union: set = set()
metas = glob.glob(os.path.join(partition_dir, f'*{meta_filename}'))
if verify:
n_year_meta = len(glob.glob(os.path.join(partition_dir, '*', f'*{meta_filename}')))
n_year_data = len(glob.glob(os.path.join(partition_dir, '*', '*.parquet')))
if not metas or n_year_meta < n_year_data:
out['unverified'] = True
for mf in metas:
try:
fmeta = json_read(mf) or {}
except Exception:
out['unverified'] = True
continue
gran = fmeta.get('granules', []) or []
drange = fmeta.get('date_range')
cols = fmeta.get('columns', []) or []
if drange:
if out['date_min'] is None or drange[0] < out['date_min']:
out['date_min'] = drange[0]
if out['date_max'] is None or drange[1] > out['date_max']:
out['date_max'] = drange[1]
h3p = fmeta.get('h3_partition')
if h3p is not None:
out['h3_partitions'].append(h3p)
out['granules'].extend(gran)
out['partition_products'].update(_products_from_columns(cols, active_products))
if out['l2a_version'] is None and fmeta.get('l2a_version') is not None:
out['l2a_version'] = fmeta.get('l2a_version')
cols_union.update(cols)
cd = fmeta.get('column_dtypes') or {}
for col_name, dtype in cd.items():
out['column_dtypes'].setdefault(col_name, dtype)
out['columns'] = list(cols_union)
return out
[docs]
def load_log_data(file_path):
if os.path.exists(file_path):
return json_read(file_path)
return {}
[docs]
def merge_temporal(existing, new):
if existing is None:
return None, None
if new is None:
return existing, None
new = parse_temporal(new)
start1, end1 = existing
start2, end2 = new
new_start = None if None in (start1, start2) else min(start1, start2)
new_end = None if None in (end1, end2) else max(end1, end2)
t_full = (new_start, new_end)
if t_full == (None, None):
return t_full, t_full
t_diff = list(t_full)
if new_start is not None and new_start >= start1 and end1 is not None:
t_diff[0] = end1
if new_end is not None and new_end <= end1 and start1 is not None:
t_diff[1] = start1
t_diff = tuple(t_diff)
if t_full == t_diff or t_diff[0] >= t_diff[1]:
t_diff = None
return t_full, t_diff
[docs]
def merge_product_vars(existing_product_vars, new_product_vars=None):
if not new_product_vars:
return existing_product_vars, None
merged_product_vars = existing_product_vars.copy()
update_prods = set()
for prod, vars_list in new_product_vars.items():
if prod not in merged_product_vars:
merged_product_vars[prod] = vars_list
update_prods.add(prod)
elif merged_product_vars[prod] is None or vars_list is None:
merged_product_vars[prod] = None
if vars_list is None and existing_product_vars[prod] is not None:
update_prods.add(prod)
else:
existing_set = set(merged_product_vars[prod])
new_set = set(vars_list)
merged_set = existing_set | new_set
merged_product_vars[prod] = list(merged_set)
if merged_set != existing_set:
update_prods.add(prod)
new_product_vars = None
if len(update_prods) > 0:
new_product_vars = {k:val for k, val in merged_product_vars.items() if k in update_prods}
return merged_product_vars, new_product_vars
[docs]
def resolve_soc_version(soc_dir):
"""Return the GEDI data version a local SOC tree was populated with, or None.
A-priori lookup — no HDF5 open, no recursive walk:
1. the download log's ``gedi_version`` (``gedih3_download_log.json`` at
the SOC root), written by every gh3 producer;
2. else the ``_V00N`` field of the first ``GEDI*.h5`` found by a bounded
``year/doy`` scandir (externally populated trees: manual rsync, NASA
delivery).
Used by ``gh3_build`` / ``gh3_download`` to pin a *fresh* build or
resumed download to the release already on disk when the user did not
pass ``--gedi-version``; the package default (``GEDI_DEFAULT_VERSION``)
only applies when nothing on disk answers the question.
"""
if not soc_dir or not os.path.isdir(soc_dir):
return None
log_path = os.path.join(soc_dir, SOCDownloadLogger._LOG_FILE_NAME)
if os.path.isfile(log_path):
try:
v = json_read(log_path).get('gedi_version')
if v is not None:
return int(v)
except (OSError, ValueError, TypeError):
pass
try:
with os.scandir(soc_dir) as years:
year_dirs = sorted(
(e.path for e in years if e.is_dir() and e.name.isdigit()),
reverse=True,
)
except OSError:
return None
for ydir in year_dirs:
try:
with os.scandir(ydir) as doys:
doy_dirs = sorted((e.path for e in doys if e.is_dir()), reverse=True)
except OSError:
continue
for ddir in doy_dirs:
try:
with os.scandir(ddir) as files:
for e in files:
if e.name.startswith('GEDI') and e.name.endswith('.h5'):
try:
return GEDIFile(e.path).version
except Exception:
continue
except OSError:
continue
return None
[docs]
class SOCDownloadLogger:
_LOG_FILE_NAME = 'gedih3_download_log.json'
_PARENT_DIR = GH3_DEFAULT_SOC_DIR
def __init__(self, product_vars, spatial=None, temporal=None, version=None, dir=None):
if dir is not None:
self._PARENT_DIR = dir
self.gedi_version = version
self.s3_access = False
self.log_file = os.path.join(self._PARENT_DIR, self._LOG_FILE_NAME)
self.log_data = load_log_data(self.log_file)
self.updating = False
# Resume-mode `default`/`minimal` expansion must target the
# existing tree's version, not the package fallback
# (GEDI_DEFAULT_VERSION). Otherwise `gh3_download -l4c default`
# against a v2 download tree expands against the v3 manifest and
# writes the wrong variable names. Peek the log before expansion
# so an absent `--gedi-version` CLI arg adopts the persisted version.
effective_version = version
if version is None and self.log_data:
effective_version = self.log_data.get('gedi_version', version)
persisted_version = self.log_data.get('gedi_version') if self.log_data else None
if version is not None and persisted_version is not None and int(version) != int(persisted_version):
# A SOC tree *can* hold two releases side by side (filenames carry
# the version and every consumer globs by it), so this is not
# fatal — but the log records one version, and a later
# `gh3_build -i` with no --gedi-version pins to whatever is
# written there. Say so loudly.
from .logging_config import get_logger
get_logger(__name__).warning(
f"GEDI version mismatch: download log at {self._PARENT_DIR} records "
f"version {persisted_version}, but version {version} was requested. "
f"The tree will hold both releases and the log will now record "
f"version {version}; prefer one SOC directory per release."
)
if product_vars:
product_vars = gedi_vars_expand(product_vars, version=effective_version)
if not self.log_data:
self.product_vars = product_vars
self.spatial = parse_spatial(spatial)
self.temporal = parse_temporal(temporal)
self.new_spatial = None
self.new_temporal = None
self.new_product_vars = None
return
self._load_filters_from_log()
self.updating = True
self.new_spatial = None
self.new_temporal = None
self.new_product_vars = None
if temporal is not None:
self.temporal, self.new_temporal = merge_temporal(self.temporal, temporal)
if spatial is not None:
self.spatial, self.new_spatial = merge_spatial(self.spatial, spatial)
if product_vars is not None:
self.product_vars, self.new_product_vars = merge_product_vars(self.product_vars, product_vars)
def _load_filters_from_log(self):
self.product_vars = self.log_data.get('products', {})
self.product_vars = {k:val.get('variables') for k,val in self.product_vars.items()}
self.spatial = parse_spatial(self.log_data.get('spatial_filter'))
self.temporal = parse_temporal(self.log_data.get('temporal_filter'))
self.gedi_version = self.log_data.get('gedi_version', self.gedi_version)
self.s3_access = self.log_data.get('s3_access', False)
if 'granules' in self.log_data:
self.granule_info = self.log_data.get('granules')
[docs]
def register_pending_granules(self, granule_list):
"""Register granules as PENDING. Called before download starts.
Parameters
----------
granule_list : list of dict
Each dict must have 'orbit', 'granule', 'track' keys.
"""
if not hasattr(self, 'granule_info'):
self.granule_info = []
existing = {(g['orbit'], g['granule'], g['track']) for g in self.granule_info}
for g in granule_list:
key = (g['orbit'], g['granule'], g['track'])
if key not in existing:
self.granule_info.append({**g, 'status': 'PENDING'})
existing.add(key)
[docs]
def update_granule_status(self, gran_key, status):
"""Update status of a single granule (does NOT auto-save).
Caller controls save frequency via explicit save_log() calls.
Parameters
----------
gran_key : dict
Dict with 'orbit', 'granule', 'track' keys.
status : str
'DOWNLOADED' or 'FAILED'.
"""
if not hasattr(self, 'granule_info'):
return
key = (gran_key['orbit'], gran_key['granule'], gran_key['track'])
for g in self.granule_info:
if (g['orbit'], g['granule'], g['track']) == key:
g['status'] = status
break
[docs]
def set_post_download_info(self):
"""Scan SOC directory and record downloaded granules.
Refreshes ``_soc_manifest.txt`` first so subsequent
``soc_file_tree`` calls (here and on the next resume) skip the
recursive glob over the SOC tree. This is the same pattern the
H3 database uses via ``MANIFEST_FILENAME``.
"""
try:
n_files = write_soc_manifest(self._PARENT_DIR)
except OSError:
# Manifest is an optimization, not a correctness gate; if the
# SOC root is read-only or transiently unavailable the
# downstream glob fallback still produces correct results.
# Defer the count to the post-glob fallback below.
n_files = None
# One release per listing: the tree may hold another release side
# by side, and this log records only the release it tracks.
version = self.gedi_version if self.gedi_version is not None else resolve_soc_version(self._PARENT_DIR)
glob_kwargs = {'version': version} if version is not None else None
soc_files = soc_file_tree(self._PARENT_DIR, to_list=True, glob_kwargs=glob_kwargs)
if n_files is None:
# Manifest write failed; recover the count from the discovery
# step so downstream summary lines stay correct.
n_files = sum(len(d) for d in soc_files)
self.n_files = n_files
granule_info = []
for soc in soc_files:
first_file = list(soc.values())[0]
gfile = GEDIFile(first_file)
gran = {'orbit': gfile.orbit, 'granule': gfile.orbit_granule, 'track': gfile.track}
if gran not in granule_info:
granule_info.append(gran)
if self.gedi_version is None:
self.gedi_version = gfile.version
# Merge with existing tracked granules (preserve status from real-time tracking)
if hasattr(self, 'granule_info') and self.granule_info:
existing = {(g['orbit'], g['granule'], g['track']): g for g in self.granule_info}
for g in granule_info:
key = (g['orbit'], g['granule'], g['track'])
if key in existing:
existing[key].update({k: v for k, v in g.items() if k != 'status'})
if existing[key].get('status') != 'FAILED':
existing[key]['status'] = 'DOWNLOADED'
else:
existing[key] = {**g, 'status': 'DOWNLOADED'}
self.granule_info = list(existing.values())
else:
self.granule_info = [{**g, 'status': 'DOWNLOADED'} for g in granule_info]
[docs]
def get_finished_granules(self):
"""Return skip list when resuming with same filters.
Only returns successfully completed granules, with the status
field stripped for backward compatibility with _filter_granules()
dict comparison. Legacy logs without status fields are treated
as successful.
"""
if (hasattr(self, 'granule_info')
and getattr(self, 'new_product_vars', None) is None
and getattr(self, 'new_temporal', None) is None):
return [
{k: v for k, v in g.items() if k != 'status'}
for g in self.granule_info
if g.get('status') in ('DOWNLOADED', None)
]
return None
[docs]
def get_temporal(self):
if not self.updating or self.new_temporal is None:
return self.temporal
if self.new_spatial is None and self.new_product_vars is None:
return self.new_temporal
return self.temporal
[docs]
def get_spatial(self):
if not self.updating or self.new_spatial is None:
return self.spatial
if self.new_product_vars is None and self.new_temporal is None:
return self.new_spatial
return self.spatial
[docs]
def get_product_vars(self):
if not self.updating or self.new_product_vars is None:
return self.product_vars
if self.new_spatial is None and self.new_temporal is None:
return self.new_product_vars
return self.product_vars
[docs]
def to_dict(self, status):
if status not in _VALID_STATUSES:
raise ValueError(f"Invalid status '{status}'. Must be one of {_VALID_STATUSES}")
product_logs = self.log_data.get('products', {})
for prod in self.get_product_vars().keys():
product_logs[prod] = product_logs.get(prod, {})
product_logs[prod]['status'] = status
product_logs[prod]['last_modified'] = now()
vars_list = self.product_vars.get(prod)
product_logs[prod]['variables'] = sorted(vars_list) if vars_list else vars_list
log_dict = {
'metadata': {
'package_version': get_package_version()
},
'gedi_version': self.gedi_version,
'status': status,
'last_modified': now(),
'spatial_filter': None if self.spatial is None else to_geojson(self.spatial),
'temporal_filter': self.temporal,
"s3_access": self.s3_access,
'products': product_logs
}
if hasattr(self, 'granule_info'):
log_dict['granules'] = self.granule_info
return log_dict
[docs]
def save_log(self, status):
json_write(self.to_dict(status), self.log_file, mode='w', rewrite=True)
[docs]
def stored_exclude(log_data) -> list:
"""The source exclusions a build log keeps (``gh3_build --exclude``).
A single pattern written by hand as a string is one pattern, never its
characters (``'*'`` alone would exclude every file).
Raises
------
GediValidationError
If ``exclude`` is neither a string nor a list of strings.
"""
value = (log_data or {}).get('exclude') or []
if isinstance(value, str):
value = [value]
if not isinstance(value, list) or not all(isinstance(p, str) for p in value):
raise GediValidationError(
f"The build log's 'exclude' must be a list of glob patterns (gh3_build --exclude), got {value!r}")
return list(value)
[docs]
class H3BuildLogger:
_LOG_FILE_NAME = BUILD_LOG_FILENAME
_PARENT_DIR = GH3_DEFAULT_H3_DIR
def __init__(self, product_vars, spatial=None, temporal=None, res:int=12, part:int=3, version:int=None, dir=None, source_mode=None):
if dir is not None:
self._PARENT_DIR = dir
# Capture which products were requested via the `default` keyword
# before expansion replaces it with the literal manifest list. This
# is the bit the resume-path validator gate needs in `gh3_build.py`:
# only the static manifest in `src/gedih3/data/GEDI0*_DATASETS_*.txt`
# is the contract for a `default` request; any other shape (explicit
# list, file path, `*`/`all`, or unmodified resume) treats the build
# log as authoritative and skips manifest consultation entirely.
# Runtime-only — never persisted to disk.
self.default_products = {
prod for prod, v in (product_vars or {}).items()
if isinstance(v, list) and any(
isinstance(s, str) and s in ('default', 'def') for s in v
)
}
# Superset of `default_products` covering every preset keyword
# (`default`/`minimal`/`all`), as opposed to a literal user-supplied
# name, file path, or wildcard. gh3_build's Stage-2 pre-flight
# sample check must skip these too: their variable names come from
# an internal manifest/table, not something the user typed, so a
# mismatch there means the GEDI-version assumption used to resolve
# the preset was wrong — not a user typo — and downstream variable
# resolution (`_expand_product_vars`) already re-derives the
# correct version-specific names regardless. Runtime-only.
self.preset_products = {
prod for prod, v in (product_vars or {}).items()
if isinstance(v, list) and any(
isinstance(s, str) and s in ('default', 'def', 'minimal', 'min', 'all', '*')
for s in v
)
}
# Names the user typed next to a preset keyword (`-l2a default
# energy_total` → {'L2A': ['energy_total']}). Expansion merges them
# into the preset list, but they are not in the static manifest:
# gh3_build's pre-flight checks them against a sample HDF5 instead.
# Runtime-only.
from .gedidriver import preset_extra_names
self.preset_extra_vars = {
prod: names for prod, v in (product_vars or {}).items()
if (names := preset_extra_names(v))
}
self.log_file = os.path.join(self._PARENT_DIR, self._LOG_FILE_NAME)
self.log_data = load_log_data(self.log_file)
# Resume-mode `default`/`minimal` expansion must target the
# existing DB's gedi_version, not the package fallback
# (GEDI_DEFAULT_VERSION). Otherwise `gh3_build -l4c default`
# against an existing v2 DB expands against the v3 manifest,
# requesting variable names that don't exist in the v2 h5 files
# and aborting at validation. Peek the log before expansion so an
# absent `--gedi-version` CLI arg adopts the persisted version.
# A database is single-version by contract: every product in it
# comes from the same GEDI release (enforced by the mismatch check
# below and by the per-version SOC glob in the build).
effective_version = version
if version is None and self.log_data:
effective_version = self.log_data.get('gedi_version', version)
if product_vars:
product_vars = gedi_vars_expand(product_vars, version=effective_version)
self.updating = False
# Set by gh3_build once every stage ran under this run's update: its
# final save (MERGING when merges failed) must not keep it pending.
self.update_ran = False
self.source_mode = source_mode
self.build_start_time = datetime.now(timezone.utc)
self.previous_status = self.log_data.get('status')
# Phased updates (gh3_build --allow-missing-products): once chosen for
# a database, persisted in its log so later runs keep admitting L2A
# before the later products are published.
self.allow_missing_products = bool(self.log_data.get('allow_missing_products'))
# Source exclusions (gh3_build --exclude): persisted, so every later run
# and every listing that takes no --exclude (variable adds, gh3_doctor)
# leaves out the same files the database was built without.
self.exclude = stored_exclude(self.log_data)
if not self.log_data:
self.product_vars = product_vars
self.spatial = parse_spatial(spatial)
self.temporal = parse_temporal(temporal)
# Fresh-build fall-back: argparse defaults are now None (so the
# resume-mismatch check below can be strict). Apply the package
# defaults here when the user didn't specify them.
self.res = res if res is not None else 12
self.part = part if part is not None else 3
self.gedi_version = version
self.new_spatial = None
self.new_temporal = None
self.new_product_vars = None
self.requested_expansion = False
return
self._load_filters_from_log()
if version is not None and self.gedi_version is not None and version != self.gedi_version:
raise GediValidationError(
f"GEDI version mismatch: existing database is version {self.gedi_version}, "
f"but version {version} was requested. Different versions require separate databases."
)
# H3 levels are baked into the database layout (partition
# directory names + per-shot index column). Changing them
# silently on resume produces an inconsistent DB; refuse to
# start, matching the gedi_version pattern above. Only fires
# when the user explicitly passed -h3r/-h3p (argparse defaults
# are None so a naked resume on a non-default DB is safe).
if res is not None and self.res is not None and res != self.res:
raise GediValidationError(
f"H3 resolution mismatch: existing database has h3_resolution_level={self.res}, "
f"but -h3r {res} was requested. The H3 index level is baked into the partition "
f"layout — different levels require separate databases. Drop -h3r to resume against "
f"the existing level, or build a fresh database in a different output directory."
)
if part is not None and self.part is not None and part != self.part:
raise GediValidationError(
f"H3 partition mismatch: existing database has h3_partition_level={self.part}, "
f"but -h3p {part} was requested. The H3 partition level is baked into the directory "
f"layout — different levels require separate databases. Drop -h3p to resume against "
f"the existing level, or build a fresh database in a different output directory."
)
self.updating = True
self.new_spatial = None
self.new_temporal = None
self.new_product_vars = None
prev_temporal, prev_spatial = self.temporal, self.spatial
if temporal is not None:
self.temporal, self.new_temporal = merge_temporal(self.temporal, temporal)
if spatial is not None:
self.spatial, self.new_spatial = merge_spatial(self.spatial, spatial)
if product_vars is not None:
self.product_vars, self.new_product_vars = merge_product_vars(self.product_vars, product_vars)
# The merges report a symmetric difference, so a subset or identical
# request also yields a ``new_*``; only a union beyond what was stored
# widens the scope.
widened = False
if self.new_spatial is not None and prev_spatial is not None:
try:
added = self.spatial.union_all().difference(prev_spatial.union_all())
widened = added.area > 0
except Exception:
added, widened = None, True # cannot tell: reopen, keep the symmetric difference (a superset)
if not widened:
# A narrower or identical region adds nothing (a database's
# scope never shrinks): this is a plain update over the stored
# region. Left set, the symmetric difference made it a
# "spatial-only expansion" over stored-minus-requested, and a
# granule new to the database lost its rows outside that (#42).
self.new_spatial = None
elif added is not None:
# Only the area the request adds: the symmetric difference also
# holds the stored region outside the request (all of it, for a
# disjoint request), which Stage 1 then re-read for nothing.
import geopandas as gpd
self.new_spatial = gpd.GeoDataFrame(geometry=[added], crs=self.spatial.crs)
elif self.new_spatial is not None:
widened = True
if self.new_temporal is not None and not widened:
widened = (tuple(self.temporal or ()) != tuple(prev_temporal or ()))
# NO_DATA holds for the scope it was recorded under. Widening space or
# time (also in a mixed update) makes every such granule unproven; do it
# here so the first save_log of this run persists it, whatever happens
# to Stage 1. A variable-only update runs no Stage 1: zero rows stay so.
if widened:
n_reopen = 0
for g in getattr(self, 'granule_info', None) or []:
if g.get('status') == GRANULE_STATUS_NO_DATA:
g['status'] = 'PENDING'
n_reopen += 1
if n_reopen:
from .logging_config import get_logger
get_logger(__name__).info(
f"{n_reopen} NO_DATA granule(s) reopened: the requested region/dates widen the "
f"scope they were recorded under")
# What this run itself asks to add, before an interrupted run's pending
# update is merged in: gh3_build must not take its merge-only shortcut
# over a widening nothing has read yet.
self.requested_expansion = self.new_spatial is not None or self.new_temporal is not None
self._restore_pending_update()
def _restore_pending_update(self):
"""Resume an update that a previous run started but never completed (#42, #52).
The first ``save_log`` of an expansion already stores the widened
region/dates, so a plain resume would see no ``new_spatial`` /
``new_temporal``: Stage 1 would read the whole scope while skipping
every INDEXED granule, and their rows in the added area would never be
extracted. ``to_dict`` persists the added area as ``_pending_update``
until Stage 1 has run under it; this puts it back into ``new_spatial`` /
``new_temporal``, so every getter answers as it did in the interrupted
run (and Stage 1's scope fingerprint matches its sentinels). An
expansion requested by this run is combined with it: reading a superset
of the added area costs a re-read, never rows.
The same first save stores the new products/variables in the log's
``products``, so a resume would also see no ``new_product_vars``: a
mixed update killed in Phase 1 (or a variable update killed before
Stage 2 started) never ran Stage 2, and the granules already indexed
never got the new columns (#52). They are restored the same way, so the
resume is again a mixed (or variable-only) update.
"""
pending = self.log_data.get('_pending_update')
if not pending:
return
product_vars = pending.get('product_vars')
if product_vars:
self.product_vars = merge_product_vars(self.product_vars or {}, product_vars)[0]
self.new_product_vars = (product_vars if self.new_product_vars is None
else merge_product_vars(self.new_product_vars, product_vars)[0])
spatial = parse_spatial(pending.get('spatial'))
if spatial is not None:
self.new_spatial = (spatial if self.new_spatial is None
else merge_spatial(self.new_spatial, spatial)[0])
temporal = parse_temporal(pending.get('temporal'))
if temporal is not None:
self.new_temporal = (temporal if self.new_temporal is None
else merge_temporal(self.new_temporal, temporal)[0])
from .logging_config import get_logger
get_logger(__name__).debug(
"Build log holds an interrupted update: its added area and variables are processed again")
def _load_filters_from_log(self):
self.product_vars = self.log_data.get('products', {})
self.product_vars = {k:val.get('variables') for k,val in self.product_vars.items()}
self.spatial = parse_spatial(self.log_data.get('spatial_filter'))
self.temporal = parse_temporal(self.log_data.get('temporal_filter'))
self.res = self.log_data.get('h3_resolution_level')
self.part = self.log_data.get('h3_partition_level')
self.gedi_version = self.log_data.get('gedi_version')
if 'granules' in self.log_data:
self.granule_info = self.log_data.get('granules')
# Lazy upgrade: legacy entries lacking the `products` map get one
# derived from their top-level status, matching pre-doctor semantics
# (granule INDEXED ⇒ all then-listed products were INDEXED). The
# on-disk file is only rewritten on the next save_log() call.
active_products = list(self.product_vars.keys())
for g in self.granule_info:
if 'products' not in g and active_products and g.get('status') != GRANULE_STATUS_NO_DATA:
existing_status = g.get('status', PRODUCT_STATUS_INDEXED)
g['products'] = {p: existing_status for p in active_products}
if 'h3_columns' in self.log_data:
self.h3_columns = self.log_data.get('h3_columns')
if 'h3_columns_dtypes' in self.log_data:
self.h3_columns_dtypes = dict(self.log_data.get('h3_columns_dtypes') or {})
if 'h3_partition_ids' in self.log_data:
self.h3_partition_ids = self.log_data.get('h3_partition_ids')
if 'date_range' in self.log_data:
self.date_range = self.log_data.get('date_range')
[docs]
def get_temporal(self):
if not self.updating or self.new_temporal is None:
return self.temporal
if self.new_spatial is None and self.new_product_vars is None:
return self.new_temporal
return self.temporal
[docs]
def get_spatial(self):
if not self.updating or self.new_spatial is None:
return self.spatial
if self.new_product_vars is None and self.new_temporal is None:
return self.new_spatial
return self.spatial
[docs]
def get_product_vars(self):
if not self.updating or self.new_product_vars is None:
return self.product_vars
if self.new_spatial is None and self.new_temporal is None:
return self.new_product_vars
return self.product_vars
def _adding_h3_parts(self):
# No expansion requested → not adding partitions, period. This must
# be checked BEFORE the h3_partition_ids attribute guard: on a resume
# after the first write phase was killed before persisting the
# partition list, `h3_partition_ids` is absent on the loaded log even
# though we are resuming the same spatial plan. Returning True here
# would disable the skip filter in get_finished_granules and cause
# the resume to re-process every already-INDEXED granule.
if self.new_spatial is None:
return False
if not hasattr(self, 'h3_partition_ids'):
# Expansion requested but we have no recorded partition set to
# compare against — treat the entire new set as "added".
return True
new_h3_parts = set(intersect_h3_geometries(self.new_spatial, res=self.part))
existing_parts = set(self.h3_partition_ids)
return not new_h3_parts.issubset(existing_parts)
[docs]
def register_pending_granules(self, granule_list):
"""Register granules as PENDING. Called before build starts.
Parameters
----------
granule_list : list of dict
Each dict must have 'orbit', 'granule', 'track' keys, and may have
``releases``: ``{product: tag}`` (:func:`gedi_release_tag`) of the
source files the build lists for the granule. A newer release
within the GEDI version is detected against the recorded tags
(#47), so a tag is never replaced here: a granule not indexed yet
gains tags only for products it has none for (rows a killed run
left on disk may come from the file recorded first, and an older
tag is reported later rather than a newer one hiding stale rows).
An ``INDEXED`` or ``NO_DATA`` entry, and one registered before
tags were recorded (no ``releases`` key, so rows on disk may come
from any file), are left as they are. No tag means the source is
unknown: an older log, a product added by a variable update, or
an S3-sourced build (which registers no granules).
"""
if not hasattr(self, 'granule_info'):
self.granule_info = []
index = {(g['orbit'], g['granule'], g['track']): g for g in self.granule_info}
for g in granule_list:
key = (g['orbit'], g['granule'], g['track'])
entry = index.get(key)
if entry is None:
index[key] = {**g, 'status': 'PENDING'}
if 'releases' in g:
index[key]['releases'] = dict(g['releases'])
self.granule_info.append(index[key])
elif (g.get('releases') and 'releases' in entry
and entry.get('status') not in ('INDEXED', GRANULE_STATUS_NO_DATA, None)):
for product, tag in g['releases'].items():
entry['releases'].setdefault(product, tag)
[docs]
def update_granule_status(self, gran_key, status):
"""Update status of a single granule (does NOT auto-save).
Caller controls save frequency via explicit save_log() calls.
Parameters
----------
gran_key : dict
Dict with 'orbit', 'granule', 'track' keys.
status : str
'INDEXED' or 'PENDING'.
"""
if not hasattr(self, 'granule_info'):
return
key = (gran_key['orbit'], gran_key['granule'], gran_key['track'])
for g in self.granule_info:
if (g['orbit'], g['granule'], g['track']) == key:
g['status'] = status
break
[docs]
def get_finished_granules(self):
"""Return skip list when resuming with same filters.
Only returns successfully completed granules, with the status and
products fields stripped for backward compatibility with
``_filter_granules()`` dict comparison (which expects bare
``{'orbit', 'granule', 'track'}`` dicts). Legacy logs without status
fields are treated as successful.
``NO_DATA`` granules (read in full, zero rows) are skipped under
exactly the conditions ``INDEXED`` ones are. They never stay skipped
past a scope change that could make them non-empty: ``__init__``
demotes every ``NO_DATA`` granule to ``PENDING`` as soon as the
requested region or dates widen the stored ones, by any area, before
this is consulted.
"""
if (hasattr(self, 'granule_info')
and getattr(self, 'new_product_vars', None) is None
and getattr(self, 'new_temporal', None) is None
and not self._adding_h3_parts()):
return self._finished_granule_list()
return None
def _finished_granule_list(self):
# Keys not part of the original granule identity (``products``: the
# per-product statuses; ``releases``: the source files). Callers get
# bare {'orbit', 'granule', 'track'} dicts, the form legacy logs hold.
_strip = {'status', 'products', 'releases'}
return [
{k: v for k, v in g.items() if k not in _strip}
for g in getattr(self, 'granule_info', None) or []
if g.get('status') in ('INDEXED', GRANULE_STATUS_NO_DATA, None)
]
[docs]
def reads_added_area_only(self):
"""True when Stage 1 reads only the region this update adds (a spatial-only expansion)."""
return (getattr(self, 'updating', False)
and getattr(self, 'new_spatial', None) is not None
and getattr(self, 'new_product_vars', None) is None
and getattr(self, 'new_temporal', None) is None)
[docs]
def stage1_only_granules(self):
"""Granules Stage 1 may read in this run, or None when it may read any.
A spatial-only expansion hands Stage 1 only the added region
(:meth:`get_spatial`). That is exactly what a granule finished under
the stored region still needs. Any other granule (new to the database,
``PENDING``, ``FAILED``, a ``NO_DATA`` reopened by the widening) would
have its rows outside the added region dropped, while its rows inside
made it ``INDEXED`` and kept later builds from ever reading the rest
(#42). Such granules are left as they are for the next build, which
reads them over the whole region.
Returns
-------
list of dict or None
``{'orbit', 'granule', 'track'}`` dicts, as for
:meth:`get_finished_granules`.
"""
if not self.reads_added_area_only():
return None
return self._finished_granule_list()
[docs]
def is_up_to_date(self):
"""Check if database already matches requested parameters.
Returns True when all of these hold:
- Log exists (updating=True)
- No new products, spatial, or temporal changes detected
- No pending variable update from a previous crash
- All tracked granules are INDEXED (or NO_DATA: read, nothing to index)
"""
if not self.updating:
return False
if getattr(self, 'new_product_vars', None) is not None:
return False
if getattr(self, 'new_spatial', None) is not None:
return False
if getattr(self, 'new_temporal', None) is not None:
return False
if self.log_data.get('_pending_variable_update'):
return False
if hasattr(self, 'granule_info') and self.granule_info:
if any(g.get('status') not in ('INDEXED', GRANULE_STATUS_NO_DATA, None)
for g in self.granule_info):
return False
return True
[docs]
def set_post_build_info(self, verify_observed=False):
"""Fold partition metadata into the log: granule status, columns, dates.
Also records ``self._observed_granule_keys``: the ``(orbit, granule,
track)`` of every granule the partition metadata lists, or ``None``
when that listing cannot be trusted to be complete (no partitions, an
unreadable scan). With ``verify_observed=True`` the scan additionally
checks each partition's sidecars against its parquet files and leaves
``None`` on any mismatch; :meth:`mark_no_data` requires a set.
"""
self._observed_granule_keys = None
scan_trusted = True
# Enumerate partition dirs via os.scandir — replaces the prior
# glob.glob('*/*<meta>') which paid 10k+ GPFS metadata round-trips
# in a single driver thread. We then dispatch per-partition JSON
# reads in parallel via parallel_map so the scan plateaus at
# ~seconds instead of tens of minutes on continental-scale DBs.
try:
partition_dirs = sorted(
e.path for e in os.scandir(self._PARENT_DIR)
if e.is_dir(follow_symlinks=False) and e.name.startswith('h3_')
)
except OSError:
return
if not partition_dirs:
# Listed fine and holds no partition: no granule has rows here.
self._observed_granule_keys = set()
return
# Collect indexed granules from partition metadata
indexed_granules = []
h3_parts = []
date_min = None
date_max = None
# Per-granule observed product set: union of products whose suffix is
# present in the columns of any partition that contains the granule.
# Used to derive per-granule per-product status (INDEXED vs MISSING_COLUMN).
active_products = set(self.product_vars.keys())
gran_observed_products = {}
# Schema accumulators: track the union of columns observed and,
# per column, how many partitions store each dtype. Aggregating inside
# the loop (rather than reading the last fmeta) survives mixed
# builds where some partitions predate ``column_dtypes`` — the
# last metadata in glob order may be a legacy partition without
# the field, and reading from it would silently zero the cache
# for the whole DB.
observed_columns: set = set()
observed_dtypes: dict = {} # {column: {dtype: n_partitions}}
# Stream per-partition results. parallel_map yields (item, result)
# pairs as workers finish; we fold them into the aggregates with
# identical semantics to the legacy serial loop.
def _fold(part_result):
nonlocal date_min, date_max
if part_result.get('date_min') is not None:
if date_min is None or part_result['date_min'] < date_min:
date_min = part_result['date_min']
if part_result.get('date_max') is not None:
if date_max is None or part_result['date_max'] > date_max:
date_max = part_result['date_max']
h3_parts.extend(part_result.get('h3_partitions', []))
partition_products = part_result.get('partition_products') or set()
seen_keys = set()
for g in part_result.get('granules', []):
try:
key = (g['orbit'], g['granule'], g['track'])
except (KeyError, TypeError):
continue
if key not in seen_keys:
indexed_granules.append(g)
seen_keys.add(key)
gran_observed_products.setdefault(key, set()).update(partition_products)
if self.gedi_version is None and part_result.get('l2a_version') is not None:
self.gedi_version = part_result['l2a_version']
observed_columns.update(part_result.get('columns', []))
for col_name, dtype in (part_result.get('column_dtypes') or {}).items():
counts = observed_dtypes.setdefault(col_name, {})
counts[dtype] = counts.get(dtype, 0) + 1
from .utils import get_dask_client
client = None
try:
client = get_dask_client()
except Exception:
pass
if client is not None and len(partition_dirs) > 100:
from .parallel import parallel_map
for _, res in parallel_map(
partition_dirs,
_scan_partition_meta_post_build_info,
desc='Scanning partition metadata',
unit='part',
meta_filename=PARTITION_META_FILENAME,
active_products=active_products,
verify=verify_observed,
):
if isinstance(res, Exception):
scan_trusted = False
continue
if res.get('unverified'):
scan_trusted = False
_fold(res)
else:
for pd in partition_dirs:
try:
_res = _scan_partition_meta_post_build_info(
pd,
meta_filename=PARTITION_META_FILENAME,
active_products=active_products,
verify=verify_observed,
)
except Exception:
scan_trusted = False
continue
if _res.get('unverified'):
scan_trusted = False
_fold(_res)
# Deduplicate against any granules that may already have appeared
# in a prior in-memory accumulation — the per-partition seen_keys
# only dedupes within one partition's result.
_seen = set()
_deduped = []
for g in indexed_granules:
try:
k = (g['orbit'], g['granule'], g['track'])
except (KeyError, TypeError):
continue
if k in _seen:
continue
_seen.add(k)
_deduped.append(g)
indexed_granules = _deduped
if scan_trusted:
self._observed_granule_keys = {(g['orbit'], g['granule'], g['track']) for g in indexed_granules}
self.date_range = (date_min, date_max)
self.h3_columns = sorted(observed_columns)
# Empty dict when every partition metadata predates this field.
# A column stored in several dtypes records the one most partitions
# hold (never "whichever partition was scanned first"): updates align
# new data to this, so it must describe the bulk of the database.
self.h3_columns_dtypes = {c: max(n, key=n.get) for c, n in observed_dtypes.items()}
mixed = {c: n for c, n in observed_dtypes.items() if len(n) > 1}
if mixed:
from .logging_config import get_logger
desc = '; '.join(f"{c}: {n}" for c, n in sorted(mixed.items()))
get_logger(__name__).warning(
f"{len(mixed)} column(s) are stored in more than one dtype across partitions "
f"(partitions per dtype): {desc}. Recording the majority dtype. "
f"Run gh3_doctor --check dtype_drift to compare against the source."
)
self.h3_partition_ids = sorted(h3_parts)
# Merge with existing tracked granules (preserve PENDING for unindexed)
indexed_keys = {(g['orbit'], g['granule'], g['track']) for g in indexed_granules}
if hasattr(self, 'granule_info') and self.granule_info:
for g in self.granule_info:
key = (g['orbit'], g['granule'], g['track'])
# MERGE_FAILED stays: the granule is in the metadata of the
# partitions that merged, not of the one that lost its rows.
if key in indexed_keys and g.get('status') != 'MERGE_FAILED':
g['status'] = 'INDEXED'
products = _per_product_status_from_observed(
active_products, gran_observed_products.get(key, set())
)
# MISSING_SOURCE and a backfill's FAILED stay: the partition
# carries the product's columns (null for this granule)
# until a backfill fills them.
for p, s in (g.get('products') or {}).items():
if s in (PRODUCT_STATUS_MISSING_SOURCE, PRODUCT_STATUS_FAILED) and p in products:
products[p] = s
g['products'] = products
# else: keep existing status (PENDING)
# Add any newly discovered granules not previously tracked
existing_keys = {(g['orbit'], g['granule'], g['track']) for g in self.granule_info}
for g in indexed_granules:
key = (g['orbit'], g['granule'], g['track'])
if key not in existing_keys:
self.granule_info.append({
**g,
'status': 'INDEXED',
'products': _per_product_status_from_observed(
active_products, gran_observed_products.get(key, set())
),
})
else:
self.granule_info = [
{
**g,
'status': 'INDEXED',
'products': _per_product_status_from_observed(
active_products,
gran_observed_products.get((g['orbit'], g['granule'], g['track']), set()),
),
}
for g in indexed_granules
]
self.granule_info = sorted(self.granule_info, key=lambda g: (g.get('orbit', 0), g.get('granule', 0), g.get('track', 0)))
[docs]
def stage1_full_scope(self):
"""True when Stage 1 reads under the whole database scope.
``get_spatial`` / ``get_temporal`` hand Stage 1 only the ADDED area (or
dates) on a spatial-only (temporal-only) expansion, so a granule empty
under that diff may hold rows in the old scope. Zero rows then proves
nothing, and :meth:`mark_no_data` refuses. Full scope: a fresh build,
a plain resume or update (the log already holds the union), and a mixed
update's Phase 1. Mirrors the branches of the two getters.
"""
if not getattr(self, 'updating', False):
return True
no_prod = getattr(self, 'new_product_vars', None) is None
temporal_only = (getattr(self, 'new_temporal', None) is not None and no_prod
and getattr(self, 'new_spatial', None) is None)
return not (self.reads_added_area_only() or temporal_only)
[docs]
def mark_no_data(self, listed_keys, incomplete_keys, empty_keys):
"""Record granules Stage 1 read in full that hold no rows as ``NO_DATA``.
Call after the final :meth:`set_post_build_info` of a build whose
Stage 1 drained fully; ``incomplete_keys`` is that run's set of
granules with any task not proven complete, ``empty_keys`` those whose
every beam task is proven complete AND empty. Nothing is marked
without a trustworthy observed set (``set_post_build_info`` could not
verify the partition metadata) or when Stage 1 did not run under the
full database scope (:meth:`stage1_full_scope`). In doubt a granule
stays PENDING: that costs a re-read, a wrong ``NO_DATA`` costs rows.
* ``PENDING`` -> ``NO_DATA`` when listed, in ``empty_keys`` and absent
from every partition's metadata (the last guard matters for tasks
emptied by the skip check, whose rows are already in the database);
* ``NO_DATA`` -> ``PENDING`` when listed and incomplete this run;
* ``MERGE_FAILED`` is never touched.
``NO_DATA`` drops the ``products`` map and ``fill_attempts``: there
is nothing to fill. Does not save. Returns ``{'marked', 'reopened'}``.
Parameters
----------
listed_keys : iterable of tuple
Granules this run listed for Stage 1.
incomplete_keys : iterable of tuple
Granules with any task not proven complete this run.
empty_keys : iterable of tuple
Granules whose every beam task is proven complete and empty.
"""
out = {'marked': 0, 'reopened': 0}
observed = getattr(self, '_observed_granule_keys', None)
if observed is None or listed_keys is None or incomplete_keys is None or empty_keys is None:
return out
listed = set(listed_keys)
incomplete = set(incomplete_keys)
empty = set(empty_keys)
may_mark = self.stage1_full_scope()
for g in getattr(self, 'granule_info', None) or []:
key = (g['orbit'], g['granule'], g['track'])
if key not in listed:
continue
status = g.get('status')
if status == 'MERGE_FAILED':
continue
if key in incomplete:
if status == GRANULE_STATUS_NO_DATA:
g['status'] = 'PENDING'
out['reopened'] += 1
continue
if may_mark and status == 'PENDING' and key in empty and key not in observed:
g['status'] = GRANULE_STATUS_NO_DATA
g.pop('products', None)
g.pop('fill_attempts', None)
out['marked'] += 1
return out
[docs]
def to_dict(self, status):
if status not in _VALID_STATUSES:
raise ValueError(f"Invalid status '{status}'. Must be one of {_VALID_STATUSES}")
product_logs = self.log_data.get('products', {})
for prod in self.get_product_vars().keys():
product_logs[prod] = product_logs.get(prod, {})
product_logs[prod]['status'] = status
product_logs[prod]['last_modified'] = now()
vars_list = self.product_vars.get(prod)
product_logs[prod]['variables'] = sorted(vars_list) if vars_list else vars_list
# Calculate build duration
build_duration = None
if self.build_start_time:
build_duration = (datetime.now(timezone.utc) - self.build_start_time).total_seconds()
log_dict = {
'metadata': {
'package_version': get_package_version()
},
'gedi_version': self.gedi_version,
'h3_resolution_level': self.res,
'h3_partition_level': self.part,
'status': status,
'previous_status': self.previous_status,
'source_mode': self.source_mode,
'last_modified': now(),
'build_duration_seconds': build_duration,
'spatial_filter': None if self.spatial is None else to_geojson(self.spatial),
'temporal_filter': self.temporal,
'products': product_logs
}
if hasattr(self, 'granule_info'):
log_dict['granules'] = self.granule_info
if hasattr(self, 'h3_columns'):
log_dict['h3_columns'] = self.h3_columns
if getattr(self, 'h3_columns_dtypes', None):
log_dict['h3_columns_dtypes'] = self.h3_columns_dtypes
if hasattr(self, 'h3_partition_ids'):
log_dict['h3_partition_ids'] = self.h3_partition_ids
if getattr(self, 'allow_missing_products', False):
log_dict['allow_missing_products'] = True
if getattr(self, 'exclude', None):
log_dict['exclude'] = list(self.exclude)
if hasattr(self, 'date_range'):
log_dict['date_range'] = self.date_range
# Append to update history on terminal statuses
if status in ('COMPLETED', 'FAILED', 'INTERRUPTED'):
action = getattr(self, 'update_action', None) or (
'variable_update' if getattr(self, 'new_product_vars', None) and getattr(self, 'new_spatial', None) is None and getattr(self, 'new_temporal', None) is None else 'build')
update_entry = {
'timestamp': now(),
'action': action,
'status': status,
'duration_seconds': build_duration,
'new_products': sorted(self.new_product_vars.keys()) if getattr(self, 'new_product_vars', None) else None,
}
existing_history = self.log_data.get('update_history', [])
existing_history.append(update_entry)
log_dict['update_history'] = existing_history[-50:] # Keep last 50
# Preserve pending variable update state for crash recovery (P0-B)
if '_pending_variable_update' in self.log_data:
log_dict['_pending_variable_update'] = self.log_data['_pending_variable_update']
# A granule replacement under way (#47): the tmp dir holding its journal,
# so any later run finds it whatever its --tmpdir.
if '_pending_replacement' in self.log_data:
log_dict['_pending_replacement'] = self.log_data['_pending_replacement']
# Pending until the run's stages have run, or until Stage 2 starts: its
# own _pending_variable_update then describes all the remaining work.
# See _restore_pending_update.
new_spatial = getattr(self, 'new_spatial', None)
new_temporal = getattr(self, 'new_temporal', None)
new_product_vars = getattr(self, 'new_product_vars', None)
if (status != 'COMPLETED' and '_pending_variable_update' not in log_dict
and not getattr(self, 'update_ran', False)
and (new_spatial is not None or new_temporal is not None or new_product_vars)):
log_dict['_pending_update'] = {
'spatial': None if new_spatial is None else to_geojson(new_spatial),
'temporal': None if new_temporal is None else list(new_temporal),
'product_vars': new_product_vars or None,
}
return log_dict
[docs]
def save_log(self, status):
json_write(self.to_dict(status), self.log_file, mode='w', rewrite=True)
[docs]
def get_granule_product_status(self, gran_key, product):
"""Return per-product status for a granule, or None if unknown.
Parameters
----------
gran_key : dict or tuple
Either ``{'orbit', 'granule', 'track'}`` or ``(orbit, granule, track)``.
product : str
Product code (e.g. 'L4A').
"""
if not hasattr(self, 'granule_info'):
return None
if isinstance(gran_key, dict):
key = (gran_key['orbit'], gran_key['granule'], gran_key['track'])
else:
key = tuple(gran_key)
for g in self.granule_info:
if (g['orbit'], g['granule'], g['track']) == key:
return (g.get('products') or {}).get(product)
return None
[docs]
def get_product_gaps(self, active_products=None, gap_statuses=None):
"""List granules where any active product is not INDEXED.
Returns a list of ``(gran_key_dict, [missing_products])`` pairs.
Used by gh3_doctor as the fast-path audit (log-only, no parquet scan).
Parameters
----------
active_products : iterable of str, optional
Products to check. Defaults to ``self.product_vars.keys()``.
gap_statuses : iterable of str, optional
Per-product statuses considered as gaps. Defaults to everything except
``INDEXED`` (i.e. PARTIAL_NAN, MISSING_COLUMN, MISSING_SOURCE, FAILED, PENDING).
"""
if not hasattr(self, 'granule_info'):
return []
if active_products is None:
active_products = list(self.product_vars.keys())
active_products = list(active_products)
if gap_statuses is None:
gap_statuses = {
PRODUCT_STATUS_PARTIAL_NAN,
PRODUCT_STATUS_MISSING_COLUMN,
PRODUCT_STATUS_MISSING_SOURCE,
PRODUCT_STATUS_FAILED,
PRODUCT_STATUS_PENDING,
}
else:
gap_statuses = set(gap_statuses)
gaps = []
for g in self.granule_info:
if g.get('status') == GRANULE_STATUS_NO_DATA:
continue # no rows, so no product columns to fill
products_map = g.get('products') or {}
missing = []
for p in active_products:
# Unknown for this entry → treat as PENDING so the doctor
# inspects rather than silently skipping.
status = products_map.get(p, PRODUCT_STATUS_PENDING)
if status in gap_statuses:
missing.append(p)
if missing:
gaps.append(({'orbit': g['orbit'], 'granule': g['granule'], 'track': g['track']}, missing))
return gaps
[docs]
def mark_granule_product(self, gran_key, product, status):
"""Set per-product status for a granule (does not auto-save).
Caller is responsible for invoking ``save_log()`` to persist.
Used by gh3_doctor after a fix or verification step.
"""
if status not in _VALID_PRODUCT_STATUSES:
raise GediValidationError(
f"Invalid per-product status '{status}'. Must be one of {_VALID_PRODUCT_STATUSES}"
)
if not hasattr(self, 'granule_info'):
return False
if isinstance(gran_key, dict):
key = (gran_key['orbit'], gran_key['granule'], gran_key['track'])
else:
key = tuple(gran_key)
for g in self.granule_info:
if (g['orbit'], g['granule'], g['track']) == key:
products_map = g.get('products')
if products_map is None:
products_map = {}
g['products'] = products_map
products_map[product] = status
return True
return False
[docs]
def set_product_statuses(self, updates):
"""Bulk per-product status update (does not auto-save).
``updates`` maps ``(orbit, granule, track)`` to ``{product: status}``.
One pass over ``granule_info`` whatever the number of updates, unlike
repeated :meth:`mark_granule_product` calls. Returns the count applied.
"""
for statuses in updates.values():
bad = set(statuses.values()) - set(_VALID_PRODUCT_STATUSES)
if bad:
raise GediValidationError(f"Invalid per-product status {sorted(bad)}")
n = 0
for g in getattr(self, 'granule_info', None) or []:
statuses = updates.get((g['orbit'], g['granule'], g['track']))
if statuses:
g.setdefault('products', {}).update(statuses)
n += len(statuses)
return n
[docs]
def superseded_releases(self, soc_entries):
"""Indexed granules whose listed source differs from the release read.
Compares each ``INDEXED`` or ``NO_DATA`` granule's recorded
``releases`` with the file a listing would read now. Log-only, no I/O.
A product whose rows name several releases (``mixed_releases``, see
:meth:`adopt_row_releases`) compares its oldest, so a listing of the
newer one reports it until it is re-indexed whole. A product with no
recorded tag is unknown and never reported. A log
with no GEDI version reports nothing: tags carry no version, so a
file of another release would read as a re-release.
Parameters
----------
soc_entries : iterable of dict
``{product: path}`` dicts: per granule, as ``soc_file_tree(...,
to_list=True)`` returns them, or one per file. Where a granule
product is listed more than once, its latest release
(``gedi_release_rank``) is the one compared, the file a build
reads. Only granules with a recorded tag are ranked.
Returns
-------
dict
``{'newer': {key: {product: (recorded, listed)}}, 'older': {...}}``
keyed by ``(orbit, granule, track)``. ``newer``: a re-release
within the GEDI version that the rows do not come from (#47).
``older``: only an older release than the one read is listed;
it is never applied, but variables added and products filled
later would read it.
"""
index = {(g['orbit'], g['granule'], g['track']): {'releases': self.compared_releases(g)}
for g in getattr(self, 'granule_info', None) or []
if (g.get('releases') or g.get('mixed_releases'))
and g.get('status') in ('INDEXED', GRANULE_STATUS_NO_DATA)}
index = {k: v for k, v in index.items() if v['releases']}
out = {'newer': {}, 'older': {}}
if not index or not getattr(self, 'gedi_version', None):
return out
latest = {} # (key, product) -> listed tag
for entry in soc_entries:
for product, path in entry.items():
path = str(getattr(path, 'path', path))
fl = os.path.basename(path).split('_')
try:
key = (int(fl[3][1:]), int(fl[4]), int(fl[5][1:]))
except (IndexError, ValueError):
continue
if product not in (index.get(key) or {}).get('releases', {}):
continue
listed = gedi_release_tag(path)
best = latest.get((key, product))
if listed is not None and (best is None or gedi_release_rank(listed) > gedi_release_rank(best)):
latest[(key, product)] = listed
for (key, product), listed in sorted(latest.items()):
recorded = index[key]['releases'][product]
if listed != recorded:
side = 'newer' if gedi_release_rank(listed) > gedi_release_rank(recorded) else 'older'
out[side].setdefault(key, {})[product] = (recorded, listed)
return out
[docs]
def pending_product_fills(self, statuses=(PRODUCT_STATUS_MISSING_SOURCE,)):
"""Indexed granules still waiting for products published after them.
``{(orbit, granule, track): [product, ...]}`` for every ``INDEXED``
granule with a product in one of ``statuses``. The default,
``MISSING_SOURCE``, is what ``gh3_build`` backfills automatically;
``gh3_doctor`` also passes ``FAILED`` (fills given up on, see
:meth:`record_fill_results`) to retry them on demand. Log-only, no I/O.
"""
statuses = set(statuses)
out = {}
for g in getattr(self, 'granule_info', None) or []:
if g.get('status') != 'INDEXED':
continue
missing = [p for p, s in (g.get('products') or {}).items() if s in statuses]
if missing:
out[(g['orbit'], g['granule'], g['track'])] = missing
return out
[docs]
def record_releases(self, releases):
"""Record the source releases a write is about to read (does not save).
Parameters
----------
releases : dict
``{(orbit, granule, track): {product: tag}}`` (tags from
``gedi_release_tag``), e.g. from ``_build_fill_products``'
``releases_callback`` before it writes. As in
:meth:`register_pending_granules`, a product that has a tag keeps
it (rows already written may come from that release), and an
entry recorded before tags existed (no ``releases`` key) stays
unknown: its other products' rows may come from any file.
Returns
-------
int
Number of tags added.
"""
n = 0
for g in getattr(self, 'granule_info', None) or []:
tags = releases.get((g['orbit'], g['granule'], g['track']))
if tags and 'releases' in g:
known = g['releases']
for product, tag in tags.items():
if product not in known:
known[product] = tag
n += 1
return n
[docs]
@staticmethod
def compared_releases(granule, newest=False):
"""``{product: tag}`` a granule's rows are compared by: its recorded
``releases``, plus the oldest (``newest=True``: the newest) of the
releases a product's rows name when they name several
(``mixed_releases``). Log-only."""
out = {p: (max if newest else min)(tags, key=gedi_release_rank)
for p, tags in (granule.get('mixed_releases') or {}).items() if tags}
out.update(granule.get('releases') or {})
return out
[docs]
def row_release_targets(self):
"""Indexed granules whose releases were never recorded nor read from their rows (#67).
``INDEXED`` granules with no ``releases`` key: a database built before
gedih3 0.20.10 recorded none, and an entry registered by such a version
before it was indexed keeps none (its rows may come from any file).
Their rows name their source files (``root_file_<prod>``), so
:meth:`adopt_row_releases` takes them from there. Empty when the
database has no source column. Log-only.
Returns
-------
set of tuple
``(orbit, granule, track)`` keys.
"""
columns = set(self.log_data.get('h3_columns') or [])
if not any(f"root_file_{p.lower()}" in columns for p in self.product_vars or {}):
return set()
return {(g['orbit'], g['granule'], g['track']) for g in getattr(self, 'granule_info', None) or []
if g.get('status') == PRODUCT_STATUS_INDEXED and 'releases' not in g}
[docs]
def adopt_row_releases(self, releases, keys, mixed=None):
"""Record the releases the database's own rows were read from (does not save).
Parameters
----------
releases : dict
``{(orbit, granule, track): {product: tag}}`` from the rows'
``root_file_<prod>`` columns (``gh3builder.releases_from_rows``),
for products whose rows name a single release. Unlike
:meth:`record_releases` (the files a write is about to read), these
are what the rows hold.
keys : iterable of tuple
The granules whose rows were all read (:meth:`row_release_targets`
less those an unreadable file may hold). Each gets a ``releases``
entry, empty when its rows named none, so it is not read again; a
product not ``INDEXED`` for it stays out.
mixed : dict, optional
``{key: {product: [tags]}}`` for products whose rows name several
releases (a replacement left half done). Kept apart as the
granule's ``mixed_releases``, never as its release: reported by
``gh3_doctor --check superseded_granules``, compared by its oldest
(:meth:`superseded_releases`), and dropped when it is re-indexed.
Returns
-------
int
Number of tags added, mixed ones included.
"""
keys, n, mixed = set(keys), 0, mixed or {}
for g in getattr(self, 'granule_info', None) or []:
key = (g['orbit'], g['granule'], g['track'])
if key not in keys or g.get('status') != PRODUCT_STATUS_INDEXED:
continue
known = g.setdefault('releases', {})
indexed = [p for p in set(releases.get(key) or {}) | set(mixed.get(key) or {})
if p not in known and p not in (g.get('mixed_releases') or {})
and (g.get('products') or {}).get(p, PRODUCT_STATUS_INDEXED) == PRODUCT_STATUS_INDEXED]
for product in sorted(indexed):
if product in (mixed.get(key) or {}):
g.setdefault('mixed_releases', {})[product] = list(mixed[key][product])
else:
known[product] = releases[key][product]
n += 1
return n
[docs]
def mixed_release_granules(self):
"""``{(orbit, granule, track): {product: [tags]}}``: granules whose rows
name several releases of a product (``mixed_releases``). Log-only."""
return {(g['orbit'], g['granule'], g['track']): dict(g['mixed_releases'])
for g in getattr(self, 'granule_info', None) or []
if g.get('mixed_releases') and g.get('status') in ('INDEXED', GRANULE_STATUS_NO_DATA)}
[docs]
def record_fill_results(self, result, max_attempts=3, count_failures=True):
"""Apply a ``_build_fill_products`` result to per-product statuses.
* ``filled`` → ``INDEXED`` (attempt count cleared);
* ``unlocated`` (the product file exists but no database file lists the
granule, so it can never fill) → ``FAILED`` at once;
* ``failed`` (read or merge error, or no row matched) → retried by the
next runs; after ``max_attempts`` consecutive failures → ``FAILED``,
so one bad file does not make every later build rescan. ``FAILED``
is not backfilled automatically; ``gh3_doctor --fix backfill``
retries it. ``count_failures=False`` leaves failures untouched.
Does not save the log. Returns ``{key: [products]}`` newly ``FAILED``.
"""
index = {(g['orbit'], g['granule'], g['track']): g for g in getattr(self, 'granule_info', None) or []}
given_up = {}
for key, prods in (result.get('filled') or {}).items():
g = index.get(tuple(key))
if g is None:
continue
for p in prods:
g.setdefault('products', {})[p] = PRODUCT_STATUS_INDEXED
(g.get('fill_attempts') or {}).pop(p, None)
if 'fill_attempts' in g and not g['fill_attempts']:
del g['fill_attempts']
for key, prods in (result.get('unlocated') or {}).items():
g = index.get(tuple(key))
if g is None:
continue
for p in prods:
g.setdefault('products', {})[p] = PRODUCT_STATUS_FAILED
given_up.setdefault(tuple(key), []).append(p)
if count_failures:
for key, prods in (result.get('failed') or {}).items():
g = index.get(tuple(key))
if g is None:
continue
attempts = g.setdefault('fill_attempts', {})
for p in prods:
attempts[p] = attempts.get(p, 0) + 1
if attempts[p] >= max_attempts:
g.setdefault('products', {})[p] = PRODUCT_STATUS_FAILED
given_up.setdefault(tuple(key), []).append(p)
return given_up