Source code for gedih3.logger

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

_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):
    """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).
    """
    out = {
        'granules': [],
        'date_min': None,
        'date_max': None,
        'h3_partitions': [],
        'columns': [],
        'l2a_version': None,
        'column_dtypes': {},
        'partition_products': set(),
    }
    cols_union: set = set()
    for mf in glob.glob(os.path.join(partition_dir, f'*{meta_filename}')):
        try:
            fmeta = json_read(mf) or {}
        except Exception:
            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] 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 DB's version, not the package fallback (v2). Otherwise # `gh3_download -l4c default` against a v3 download tree expands # against the v2 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) 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 soc_files = soc_file_tree(self._PARENT_DIR, to_list=True) 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] 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 ) } 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 (v2). # Otherwise `gh3_build -l4c default` against an existing v3 DB # expands against the v2 manifest, requesting variable names that # don't exist in the v3 h5 files and aborting at validation. 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) if product_vars: product_vars = gedi_vars_expand(product_vars, version=effective_version) self.updating = False self.source_mode = source_mode self.build_start_time = datetime.now(timezone.utc) self.previous_status = self.log_data.get('status') 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 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 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.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: 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. """ 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 '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. """ # Keys not part of the original granule identity. ``products`` was # added by the per-product status extension; both must be stripped so # _filter_granules' dict equality keeps matching legacy entries. _strip = {'status', 'products'} 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 [ {k: v for k, v in g.items() if k not in _strip} for g in self.granule_info if g.get('status') in ('INDEXED', None) ] return None
[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 """ 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', None) for g in self.granule_info): return False return True
[docs] def set_post_build_info(self): # 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: 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 # the first non-empty per-column dtype map. 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 = {} # 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(): observed_dtypes.setdefault(col_name, dtype) 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, ): if isinstance(res, Exception): continue _fold(res) else: for pd in partition_dirs: _fold(_scan_partition_meta_post_build_info( pd, meta_filename=PARTITION_META_FILENAME, active_products=active_products, )) # 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 self.date_range = (date_min, date_max) self.h3_columns = sorted(observed_columns) # Empty dict when every partition metadata predates this field. self.h3_columns_dtypes = dict(observed_dtypes) 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']) if key in indexed_keys: g['status'] = 'INDEXED' g['products'] = _per_product_status_from_observed( active_products, gran_observed_products.get(key, set()) ) # 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 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 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 = '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'] 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: 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