Source code for gedih3.doctor.parquet_ops

# 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

"""Streaming parquet operations specific to gh3_doctor.

Both functions process parquet files row-group by row-group to bound memory.

- :func:`parquet_fill_columns` now lives in :mod:`gedih3.utils` (the build's
  product backfill uses it too) and is re-exported here: the **fill**
  counterpart of :func:`gedih3.utils.parquet_join_columns` — null/NaN base
  cells take the patch value, existing values are never overwritten.
- :func:`parquet_dedup_partition` rewrites a single parquet file dropping
  duplicate ``shot_number`` rows, keeping the first occurrence.
"""

from __future__ import annotations

import os
from typing import List, Optional

from ..utils import release_arrow_pool, parquet_fill_columns  # noqa: F401  (re-export)


[docs] def parquet_dedup_partition( pq_file: str, key_col: str = 'shot_number', keep: str = 'first', tmp_suffix: str = '.dedup.tmp', ) -> int: """Rewrite a parquet file dropping duplicate ``key_col`` rows. Streams row-group by row-group: only one row group plus the cumulative set of seen keys is held in memory. ``shot_number`` is int64 so the seen-set cost is ~8 bytes/row. Parameters ---------- pq_file : str Parquet file to rewrite in-place (via temp + atomic rename). key_col : str, default 'shot_number' Column to deduplicate on. keep : {'first', 'last'}, default 'first' Which duplicate to keep. ``'last'`` requires a second pass. tmp_suffix : str Suffix for the temp file used during atomic rewrite. Returns ------- int Number of rows dropped. """ import pyarrow as pa import pyarrow.parquet as pq if keep not in ('first', 'last'): raise ValueError(f"keep must be 'first' or 'last', got {keep!r}") pf = pq.ParquetFile(pq_file, pre_buffer=True) schema = pf.schema_arrow if keep == 'last': # Walk once to find the last row-group index for each key, then walk # again writing only those rows. Memory cost: one int64 set + one int set. last_rg_for_key = {} for rg_idx in range(pf.metadata.num_row_groups): keys = pf.read_row_group(rg_idx, columns=[key_col]).column(key_col).to_pylist() for k in keys: last_rg_for_key[k] = rg_idx # Group keys to drop by row group: every row whose RG isn't its last # must be excluded. Easier path: invert into a "keep" set per RG. keep_set_per_rg = {} for k, rg in last_rg_for_key.items(): keep_set_per_rg.setdefault(rg, set()).add(k) else: keep_set_per_rg = None seen = set() pardir = os.path.dirname(pq_file) or '.' os.makedirs(pardir, exist_ok=True) temp_ofile = pq_file + tmp_suffix dropped = 0 with pq.ParquetWriter(temp_ofile, schema, compression='zstd') as writer: for rg_idx in range(pf.metadata.num_row_groups): table = pf.read_row_group(rg_idx) keys = table.column(key_col).to_pylist() if keep == 'first': mask = [] for k in keys: if k in seen: mask.append(False) else: seen.add(k) mask.append(True) else: rg_keep = keep_set_per_rg.get(rg_idx, set()) mask = [k in rg_keep for k in keys] # For 'last' keep semantics, also collapse intra-RG duplicates # by tracking which keys we've already emitted in this RG. emitted = set() final_mask = [] for keep_row, k in zip(mask, keys): if keep_row and k not in emitted: emitted.add(k) final_mask.append(True) else: final_mask.append(False) mask = final_mask kept_rows = sum(mask) dropped += len(mask) - kept_rows if kept_rows == len(mask): writer.write_table(table) elif kept_rows > 0: writer.write_table(table.filter(pa.array(mask))) # kept_rows == 0: write nothing for this row group pf.close() del pf release_arrow_pool() try: os.replace(temp_ofile, pq_file) except OSError: if os.path.exists(temp_ofile): os.unlink(temp_ofile) raise return dropped