Source code for gedih3.doctor.diagnoses.soc_health

# 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

"""soc_health diagnosis — invalid HDF5 files + download log drift.

Detects:
  - SOC HDF5 files that fail :func:`h5_is_valid`.
  - Download log entries marked DOWNLOADED but with the file missing or empty.

Remedies (safe):
  - Mark drifted entries FAILED so the next ``gh3_download --resume`` retries.
  - Invalid HDF5 files are reported only (could be in-progress); deletion is
    not auto-applied (see ``--delete-invalid`` future flag).

Performance pillar (v0.8.x lessons backport):
  * SOC file enumeration always walks the tree in parallel via
    :func:`gedih3.parallel.walk_soc_parallel`. The legacy
    ``_soc_manifest.txt`` shortcut was removed from the read path
    because external population (manual rsync, NASA delivery) bypasses
    the producer-driven refresh and silently narrows the doctor's view.
  * Per-file ``h5_is_valid`` checks are dispatched in parallel via
    :func:`gedih3.doctor.parallel.parallel_map` when a dask client is
    registered.
"""

from __future__ import annotations

import os

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


def _check_h5_file(path: str) -> dict:
    """Worker: validate one HDF5; return {'valid': bool, 'size': int, 'err': str|None}."""
    from ...utils import h5_is_valid
    try:
        valid = h5_is_valid(path)
        err = None if valid else 'not a valid GEDI HDF5'
    except Exception as e:
        valid = False
        err = f"{type(e).__name__}: {e}"
    try:
        size = os.path.getsize(path)
    except OSError:
        size = -1
    return {'valid': valid, 'size': size, 'err': err}


def _enumerate_soc_files(soc_dir: str) -> list:
    """Return every ``GEDI*.h5`` path under *soc_dir*.

    Always walks the tree in parallel via the registered dask Client.
    We intentionally do not consume ``_soc_manifest.txt`` here:
    external population paths (manual rsync, NASA delivery) bypass the
    producer-driven refresh and would leave the manifest stale relative
    to disk, silently narrowing the doctor's view. Returns the *full*
    file list — unlike :func:`soc_file_tree(..., to_list=True)`, which
    pivots by orbit/track and ``dropna()``s rows where any product is
    absent, silently excluding partial-download granules from
    downstream scans.
    """
    from ...parallel import walk_soc_parallel
    return walk_soc_parallel(soc_dir, pattern='GEDI*.h5')


[docs] def soc_health_check(ctx: DoctorContext) -> Report: findings = [] if not ctx.soc_dir or not os.path.isdir(ctx.soc_dir): return Report( name='soc_health', severity=Severity.INFO, summary='no SOC directory configured; skipping', ) soc_files = _enumerate_soc_files(ctx.soc_dir) # Per-file ``h5_is_valid`` is sub-second on a healthy file, so a # continental SOC tree (millions of files) would dispatch millions # of one-task-per-file dask futures — task-graph build and # scheduler overhead would dominate, and the cluster would barely # touch real work. Batch into chunks of 1000 so we ship at most a # few thousand tasks even on the largest trees. for f, result in parallel_map( soc_files, _check_h5_file, args=getattr(ctx, 'args', None), desc='soc_health: scanning HDF5 files', unit='file', batch_size=1000, ): if isinstance(result, Exception): findings.append({ 'kind': 'invalid_h5', 'path': f, 'size_bytes': -1, 'error': f"{type(result).__name__}: {result}", }) continue if not result['valid']: findings.append({ 'kind': 'invalid_h5', 'path': f, 'size_bytes': result['size'], 'error': result['err'], }) # Cross-check the download log against disk. try: from ...logger import SOCDownloadLogger soc_log = SOCDownloadLogger(product_vars=None, dir=ctx.soc_dir) ctx.soc_logger = soc_log except Exception as e: findings.append({'kind': 'download_log_unreadable', 'error': f"{type(e).__name__}: {e}"}) soc_log = None if soc_log is not None and getattr(soc_log, 'granule_info', None): for g in soc_log.granule_info: if g.get('status') != 'DOWNLOADED': continue # Granule entry doesn't directly name a file path; we infer per-product via SOC tree. # If any product's expected file is missing, flag drift. # A more thorough check would require the granule's file_path field. file_path = g.get('file_path') if file_path and (not os.path.exists(file_path) or os.path.getsize(file_path) == 0): findings.append({ 'kind': 'download_log_drift', 'granule': {'orbit': g.get('orbit'), 'granule': g.get('granule'), 'track': g.get('track')}, 'file_path': file_path, }) n_invalid = sum(1 for f in findings if f['kind'] == 'invalid_h5') n_drift = sum(1 for f in findings if f['kind'] == 'download_log_drift') severity = Severity.INFO if n_invalid: severity = Severity.WARN if any(f['kind'] == 'download_log_unreadable' for f in findings): severity = Severity.ERROR summary = f"{n_invalid} invalid HDF5 files; {n_drift} download-log drift entries" return Report(name='soc_health', severity=severity, findings=findings, summary=summary)
[docs] def soc_health_fix(ctx: DoctorContext, report: Report) -> Report: fixed = [] soc_log = getattr(ctx, 'soc_logger', None) for f in report.findings: kind = f.get('kind') if kind == 'download_log_drift' and soc_log is not None: try: soc_log.update_granule_status(f['granule'], 'FAILED') fixed.append({**f, 'action': 'marked_FAILED_for_retry'}) except Exception as e: fixed.append({**f, 'fix_error': f"{type(e).__name__}: {e}"}) else: # invalid_h5 and download_log_unreadable: no auto-fix fixed.append({**f, 'action': 'reported_only'}) if soc_log is not None: try: soc_log.save_log('UNKNOWN') except Exception: pass n_errors = sum(1 for x in fixed if 'fix_error' in x) report.applied = True report.findings = fixed if n_errors: report.severity = Severity.ERROR report.summary = f"{sum(1 for x in fixed if x.get('action','').startswith('marked_'))} download entries marked for retry; {n_errors} errors" return report
register('soc_health', 'invalid HDF5 + download log drift', scope='global', fix=soc_health_fix)(soc_health_check)