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.
They live here (not in :mod:`gedih3.utils`) so the doctor introduces no edits
to currently-working tools. Promotion to a shared utility is a one-line move
if other modules ever need them.

- :func:`parquet_fill_columns` is the **fill** counterpart of
  :func:`gedih3.utils.parquet_join_columns`. Where the latter drops columns
  that already exist in the base file, this one merges them via
  :meth:`pandas.Series.combine_first` so non-NaN values in the base are
  preserved and NaN values get filled from the patch.
- :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


[docs] def parquet_fill_columns( base_file: str, patch_files: List[str], ofile: Optional[str] = None, key_col: str = 'shot_number', tmp_suffix: str = '.fill.tmp', ) -> str: """Fill NaN values in ``base_file`` from one or more ``patch_files``. For each column shared by the base and patch: - existing non-NaN values in the base are preserved. - NaN values in the base are replaced where the patch provides a value for the same ``key_col``. Columns present only in the patch are appended (left-join). Columns present only in the base are unchanged. The output is streamed row-group by row-group; only one row group plus the full patch frames are held in memory at once. Patches are read in full, indexed by ``key_col``, so callers should pre-restrict patches to one partition's relevant rows. Parameters ---------- base_file : str Existing parquet file. Determines schema base, row order, row group size. patch_files : list of str Parquet files containing ``key_col`` plus the columns to merge in. ofile : str, optional Output path. Defaults to ``base_file`` (in-place rewrite via temp). key_col : str, default 'shot_number' Join key (column, not index). tmp_suffix : str Suffix for the temp file used during atomic rewrite. Returns ------- str Path to the written file. """ import pandas as pd import pyarrow as pa import pyarrow.parquet as pq if not patch_files: raise ValueError("patch_files must contain at least one path") if ofile is None: ofile = base_file # pre_buffer=True coalesces per-row-group column-chunk reads on # shared GPFS — same lesson as parquet_merge_files (commit e8a966b). base_pf = pq.ParquetFile(base_file, pre_buffer=True) base_schema = base_pf.schema_arrow base_cols = list(base_schema.names) base_col_set = set(base_cols) # Load patch frames; track columns shared with base (filled) vs new (appended). fill_data = {} # col_name -> Series indexed by key_col append_data = {} # patch_file -> DataFrame of new columns indexed by key_col appended_fields = [] # arrow Fields for the combined schema for pf_path in patch_files: pf_schema = pq.read_schema(pf_path) pf_cols = list(pf_schema.names) if key_col not in pf_cols: raise ValueError(f"patch file {pf_path!r} missing key column {key_col!r}") fill_cols = [c for c in pf_cols if c in base_col_set and c != key_col] new_cols = [c for c in pf_cols if c not in base_col_set and c != key_col] if not fill_cols and not new_cols: continue cols_to_load = [key_col] + fill_cols + new_cols df = pd.read_parquet(pf_path, columns=cols_to_load) df = df.set_index(key_col) df = df[~df.index.duplicated(keep='first')] for c in fill_cols: # Last patch wins for fills targeting the same column. fill_data[c] = df[c] if new_cols: append_data[pf_path] = df[new_cols] for c in new_cols: base_col_set.add(c) appended_fields.append(pf_schema.field(c)) combined_schema = base_schema for f in appended_fields: combined_schema = combined_schema.append(f) if base_schema.metadata: combined_schema = combined_schema.with_metadata(base_schema.metadata) pardir = os.path.dirname(ofile) or '.' os.makedirs(pardir, exist_ok=True) temp_ofile = ofile + tmp_suffix with pq.ParquetWriter(temp_ofile, combined_schema, compression='zstd') as writer: for rg_idx in range(base_pf.metadata.num_row_groups): batch = base_pf.read_row_group(rg_idx).to_pandas() idx_name = batch.index.name if idx_name: batch = batch.reset_index() batch_indexed = batch.set_index(key_col) # Fill: combine_first on shared columns. Existing non-NaN in the base # wins; NaN in the base gets filled from the patch. for col, patch_series in fill_data.items(): if col in batch_indexed.columns: aligned = patch_series.reindex(batch_indexed.index) batch_indexed[col] = batch_indexed[col].combine_first(aligned) # Append: left-join new columns. for new_df in append_data.values(): batch_indexed = batch_indexed.join(new_df, how='left') batch = batch_indexed.reset_index() if idx_name: batch = batch.set_index(idx_name) cols_to_select = [c for c in combined_schema.names if c in batch.columns] batch = batch[cols_to_select] writer.write_table(pa.Table.from_pandas(batch, schema=combined_schema)) base_pf.close() del base_pf # Drain pyarrow's allocator now so the temp buffers don't drag into # the next worker task. Mirrors parquet_merge_files (utils.py). release_arrow_pool() try: os.replace(temp_ofile, ofile) except OSError: if os.path.exists(temp_ofile): os.unlink(temp_ofile) raise return ofile
[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