Source code for gedih3.doctor.diagnoses.superseded_granules

# 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

"""superseded_granules diagnosis — indexed granules whose source was re-released.

Detects (read-only, no fix):
  - ``newer_release``: the SOC tree holds a later release of a granule
    product (PPDS, PGE, generation or suffix, ``gedi_release_rank``) than
    the one its rows were read from. NASA re-releases granules within one
    GEDI version to fix faulty data; ``gh3_build --replace-granules``
    re-indexes them (#47).
  - ``older_release``: only an older release than the one read is listed.
    It is never applied, but variables added and products filled later read
    the listed file.
  - ``mixed_releases``: a granule's rows name more than one release of a
    product (a replacement left half done), as read from its rows and kept
    in the build log (``mixed_releases``) until it is re-indexed.

Compares the build log's recorded ``releases`` (gh3_build >= 0.20.10) with
the file a listing picks now, by granule key (orbit, granule, track) within
the database's GEDI version, leaving out the files the build excludes (the
log's ``exclude``, gh3_build --exclude). For granules indexed before releases
were recorded, the releases are read from the rows' ``root_file_<prod>``
columns first (``releases_from_rows``, #67), in memory only: ``gh3_build``
records them on its next run. A granule with neither is counted, not reported.

Performance: one parallel SOC walk (``soc_file_tree``) and a log-only
comparison; on a database built before 0.20.10, also one worker task per
(cell, year) file reading only its source columns. No HDF5 is opened.
"""

from __future__ import annotations

import os

from ..report import Report, DoctorContext, Severity
from ..runner import register


[docs] def superseded_granules_check(ctx: DoctorContext) -> Report: if not ctx.soc_dir: return Report(name='superseded_granules', severity=Severity.INFO, summary='no SOC directory configured; skipping') if not os.path.isdir(ctx.soc_dir): return Report(name='superseded_granules', severity=Severity.INFO, summary=f"{ctx.soc_dir} is not a local directory; detection covers local SOC trees only") log = ctx.h3_logger version = getattr(log, 'gedi_version', None) if not version: return Report(name='superseded_granules', severity=Severity.INFO, summary='the build log records no GEDI version; skipping') n_from_rows, failed, read_error = None, [], None keys = log.row_release_targets() if hasattr(log, 'row_release_targets') else set() if keys: # Read-only: the releases are adopted into a copy, never into the # shared logger a --fix would save; gh3_build records them. import copy from ...gh3builder import releases_from_rows columns = set(log.log_data.get('h3_columns') or []) from ...exceptions import GediError try: releases, mixed, unread, failed = releases_from_rows( ctx.h3_dir, [p for p in log.product_vars or {} if f"root_file_{p.lower()}" in columns], keys, version=version) except (GediError, OSError) as e: read_error = f"{type(e).__name__}: {e}" else: log = copy.copy(log) log.granule_info = copy.deepcopy(log.granule_info) n_from_rows = log.adopt_row_releases(releases, keys - unread, mixed=mixed) mixed = log.mixed_release_granules() if hasattr(log, 'mixed_release_granules') else {} granules = getattr(log, 'granule_info', None) or [] checked = [g for g in granules if g.get('status') in ('INDEXED', 'NO_DATA')] n_unknown = sum(1 for g in checked if not g.get('releases') and not g.get('mixed_releases')) if len(checked) == n_unknown and not failed and not read_error: return Report(name='superseded_granules', severity=Severity.INFO, summary=f"no recorded source releases to check ({n_unknown} granule(s) indexed before " f"gedih3 recorded them)") from ...gedidriver import soc_file_tree entries = soc_file_tree(ctx.soc_dir, to_list=True, require_all=False, glob_kwargs={'version': version}, exclude=getattr(log, 'exclude', None) or None) found = log.superseded_releases(entries) findings = [{'kind': 'mixed_releases', 'granule': {'orbit': k[0], 'granule': k[1], 'track': k[2]}, 'products': {p: {'rows': tags} for p, tags in sorted(prods.items())}} for k, prods in sorted(mixed.items())] for side, kind in (('newer', 'newer_release'), ('older', 'older_release')): for key, prods in sorted(found[side].items()): findings.append({ 'kind': kind, 'granule': {'orbit': key[0], 'granule': key[1], 'track': key[2]}, 'products': {p: {'indexed': r, 'listed': t} for p, (r, t) in sorted(prods.items())}, }) n_newer, n_older = len(found['newer']), len(found['older']) summary = f"{n_newer} granule(s) with a newer release in the SOC tree; {n_older} with only an older one" if mixed: summary += f"; {len(mixed)} with rows from more than one release" if n_from_rows is not None: summary += (f" (releases of granules indexed before gedih3 recorded them read from the rows: " f"{n_from_rows} tag(s); gh3_build records them on its next run)") if read_error: summary += f"; the releases of granules indexed before gedih3 recorded them could not be read ({read_error})" if failed: summary += (f"; {len(failed)} database file(s) unreadable (e.g. {failed[0] or 'a batch of files'}), " f"the granules they may hold not checked") if n_unknown: summary += f"; {n_unknown} granule(s) without a recorded release not checked" recommendations = [] if mixed: recommendations.append( "Rows of these granules come from more than one release of a product, as a replacement left half " "done would: re-index them from the newest with gh3_build -i <soc> -o <db> --replace-granules auto " "(or by key), restoring that file in the SOC tree first if it is gone") if n_newer: recommendations.append( "Rows of these granules come from the older release: re-index them with gh3_build -i <soc> -o <db> " "--replace-granules auto. Until then, variables added and products filled later read the newer " "files. Files matching the build's --exclude patterns (kept in its log) are left out") if failed: recommendations.append("Check the unreadable files with gh3_doctor --check parquet_health") if n_older: recommendations.append( "Restore the release these granules were indexed from before adding variables or filling products") severity = Severity.WARN if findings or failed or read_error else Severity.INFO return Report(name='superseded_granules', severity=severity, findings=findings, summary=summary, recommendations=recommendations)
register( 'superseded_granules', 'Indexed granules whose source file has a newer (or only an older) release in the SOC tree', scope='global', )(superseded_granules_check)