Source code for pycsamt.stratagem.qc

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
stratagem.qc
============

Quality-control and frequency-filtering classes for Stratagem AMT surveys.

:class:`QualityController`
    Station-level QC: builds a per-station report (SNR, fraction of good
    frequencies, phase-tensor skew) and flags stations that fall below
    configurable thresholds.  Optionally enriches the report with
    hardware-level stack counts from a :class:`~pycsamt.stratagem.io.StratagemRawReader`.

:class:`FrequencyFilter`
    Frequency-level editing: removes incoherent, low-SNR, or out-of-band
    frequency bins.  When a :class:`~pycsamt.stratagem.io.StratagemRawReader`
    is supplied, hardware-measured zero-stack rows are also masked before
    any statistical criteria are applied.

Both classes delegate their core algorithms to :mod:`pycsamt.emtools.qc`,
:mod:`pycsamt.emtools.frequency`, and
:mod:`pycsamt.emtools.remove_noise` — they add only the Stratagem-specific
wiring (hardware mask alignment, station-index mapping, result persistence).
"""

from __future__ import annotations

from copy import deepcopy
from pathlib import Path

import numpy as np

from ..api.property import MetadataMixin, PyCSAMTObject
from ..emtools._core import _iter_items
from ..emtools.frequency import select_band
from ..emtools.qc import build_qc_table, qc_flags
from ..emtools.remove_noise import mask_incoherent_freqs
from ..exceptions import NotFittedError

__all__ = ["FrequencyFilter", "QualityController"]


# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------


def _extract_edis(sites_or_list) -> list:
    """Extract EDIFile objects from a Sites wrapper or a plain list."""
    result = []
    for item in _iter_items(sites_or_list):
        edi = getattr(item, "edi", None)
        if edi is not None and getattr(edi, "Z", None) is not None:
            result.append(edi)
        else:
            result.append(item)
    return result


def _align_hardware_mask(
    raw_freqs: np.ndarray,
    raw_mask: np.ndarray,  # shape (n_raw_freqs,) for one station
    edi_freqs: np.ndarray,
) -> np.ndarray:
    """Map a hardware SNR mask from the raw frequency grid to an EDI grid.

    Uses nearest-neighbour matching in linear frequency space.  A raw mask
    value of False propagates to all EDI frequencies that map to that raw bin.

    Parameters
    ----------
    raw_freqs : ndarray, shape (n_raw,)
    raw_mask  : ndarray of bool, shape (n_raw,)
    edi_freqs : ndarray, shape (n_edi,)

    Returns
    -------
    ndarray of bool, shape (n_edi,)
        True where the nearest raw frequency bin had valid data.
    """
    if raw_freqs.size == 0:
        return np.ones(edi_freqs.size, dtype=bool)

    edi_mask = np.ones(edi_freqs.size, dtype=bool)
    for k, ef in enumerate(edi_freqs):
        nearest = int(np.argmin(np.abs(raw_freqs - ef)))
        edi_mask[k] = bool(raw_mask[nearest])
    return edi_mask


def _apply_freq_mask_to_edi(edi, keep: np.ndarray) -> None:
    """Zero out impedance tensor rows where *keep* is False (in-place).

    Parameters
    ----------
    edi : EDIFile
    keep : ndarray of bool, shape (n_freqs,)
        True = keep this frequency row; False = set to NaN.
    """
    z = getattr(edi.Z, "z", None)
    if z is None:
        return
    mask = ~keep
    if not np.any(mask):
        return
    z2 = z.copy()
    z2[mask] = np.nan
    edi.Z.z = z2

    z_err = getattr(edi.Z, "z_err", None)
    if z_err is not None:
        ze2 = z_err.copy()
        ze2[mask] = np.nan
        try:
            edi.Z.z_err = ze2
        except Exception:
            # z_err setter may trigger rho/phase recomputation on NaN Z;
            # bypass via internal attribute when that fails
            try:
                edi.Z.__dict__["_z_err"] = ze2
            except Exception:
                pass

    tip = getattr(edi.Tip, "tipper", None)
    if tip is not None and tip.shape[0] == z.shape[0]:
        t2 = tip.copy()
        t2[mask] = np.nan
        try:
            edi.Tip.tipper = t2
        except Exception:
            pass


# ---------------------------------------------------------------------------
# QualityController
# ---------------------------------------------------------------------------


[docs] class QualityController(PyCSAMTObject, MetadataMixin): """Station-level quality-control report for Stratagem AMT surveys. Wraps :func:`~pycsamt.emtools.qc.build_qc_table` and :func:`~pycsamt.emtools.qc.qc_flags` with optional hardware-level enrichment from a :class:`~pycsamt.stratagem.io.StratagemRawReader`. Parameters ---------- min_frac_ok : float, default 0.6 Minimum fraction of valid (non-NaN) impedance rows; stations below this are flagged ``low_coverage``. min_snr_med : float, default 2.0 Minimum median SNR; stations below this are flagged ``low_snr``. max_skew_med : float, default 6.0 Maximum median absolute phase-tensor skew angle (°); stations exceeding this are flagged ``high_skew``. include_skew : bool, default True Include phase-tensor skew in the report. Requires a valid impedance tensor. verbose : int, default 0 Attributes ---------- report_ : pandas.DataFrame Per-station QC metrics. Columns: ``station``, ``n_freq``, ``n_ok``, ``frac_ok``, ``snr_med``, ``pmin``, ``pmax``, and (when ``include_skew=True``) ``skew_med``, ``skew_iqr``. When a ``StratagemRawReader`` is supplied to :meth:`fit`, three additional columns are appended: ``hw_freqs``, ``hw_usable_freqs``, ``hw_coverage``. flags_ : pandas.DataFrame Per-station flag strings in the ``flags`` column. Examples -------- >>> from pycsamt.stratagem import EDIBatch, CoordinateInjector >>> from pycsamt.stratagem.qc import QualityController >>> batch = EDIBatch("2/2EDI").fit() >>> inj = CoordinateInjector(epsg=32649).fit(batch, "2.csv") >>> qc = QualityController().fit(inj.edi_objects_) >>> qc.report_.head() >>> qc.summary() """ __repr_fields__ = ( "min_frac_ok", "min_snr_med", "max_skew_med", "n_stations_", ) def __init__( self, *, min_frac_ok: float = 0.6, min_snr_med: float = 2.0, max_skew_med: float = 6.0, include_skew: bool = True, verbose: int = 0, ) -> None: self.min_frac_ok = min_frac_ok self.min_snr_med = min_snr_med self.max_skew_med = max_skew_med self.include_skew = include_skew self.verbose = verbose # ------------------------------------------------------------------
[docs] def fit( self, edi_objects: list, raw_reader: StratagemRawReader | None = None, ) -> QualityController: """Build the QC report. Parameters ---------- edi_objects : list of EDIFile Stations to assess. Typically from :attr:`~pycsamt.stratagem.gis_correct.CoordinateInjector.edi_objects_` or :attr:`~pycsamt.stratagem.io.EDIBatch.edi_objects_`. raw_reader : StratagemRawReader, optional When supplied, hardware stack counts and SNR masks are joined into :attr:`report_` as extra columns ``hw_freqs``, ``hw_usable_freqs``, and ``hw_coverage``. Returns ------- self """ self.n_stations_ = len(edi_objects) self.report_ = build_qc_table( edi_objects, include_skew=self.include_skew, api=False, verbose=self.verbose, ) self.flags_ = qc_flags( edi_objects, min_frac_ok=self.min_frac_ok, min_snr_med=self.min_snr_med, max_skew_med=self.max_skew_med, verbose=self.verbose, ) # ── optional hardware enrichment ────────────────────────────── if raw_reader is not None and hasattr(raw_reader, "snr_mask_"): self._enrich_with_hardware(edi_objects, raw_reader) if self.verbose: n_flagged = int((self.flags_["flags"] != "").sum()) print( f"[QualityController] {len(self.report_)} stations assessed, " f"{n_flagged} flagged" ) return self
def _enrich_with_hardware(self, edi_objects: list, raw_reader) -> None: """Append hardware coverage columns to report_.""" # Use station-number matching rather than index alignment. edi_to_raw = raw_reader.match_to_edis(edi_objects) hw_freqs, hw_usable, hw_cov = [], [], [] for i in range(len(edi_objects)): if i in edi_to_raw: raw_idx = edi_to_raw[i] mask = raw_reader.snr_mask_[raw_idx] hw_freqs.append(int(raw_reader.n_freqs_)) hw_usable.append(int(mask.sum())) hw_cov.append(float(mask.mean())) else: hw_freqs.append(None) hw_usable.append(None) hw_cov.append(None) # align on station column if lengths match if len(hw_freqs) == len(self.report_): self.report_ = self.report_.copy() self.report_["hw_freqs"] = hw_freqs self.report_["hw_usable_freqs"] = hw_usable self.report_["hw_coverage"] = hw_cov # ------------------------------------------------------------------
[docs] def summary(self) -> str: """Return a compact text summary of the QC results.""" if not hasattr(self, "report_"): raise NotFittedError("Call fit() first.") r = self.report_ f = self.flags_ n_total = len(r) n_flagged = int((f["flags"] != "").sum()) lines = [ f"QualityController: {n_total} stations", f" flagged : {n_flagged} ({100 * n_flagged / max(1, n_total):.0f}%)", ] if not r.empty: lines += [ f" frac_ok : {r['frac_ok'].mean():.2f} mean", f" snr_med : {r['snr_med'].median():.1f} median", ] if "skew_med" in r.columns: lines.append( f" skew_med : {r['skew_med'].median():.1f}° median" ) # flag breakdown all_flags: dict[str, int] = {} for row_flags in f["flags"].dropna(): for flag in str(row_flags).split(","): flag = flag.strip() if flag: all_flags[flag] = all_flags.get(flag, 0) + 1 if all_flags: lines.append(" flag breakdown:") for k, v in sorted(all_flags.items(), key=lambda x: -x[1]): lines.append(f" {k}: {v}") return "\n".join(lines)
[docs] def flagged_stations(self) -> list[str]: """Return station names with at least one QC flag. Returns ------- list of str """ if not hasattr(self, "flags_"): raise NotFittedError("Call fit() first.") mask = self.flags_["flags"].astype(str).str.strip() != "" return self.flags_.loc[mask, "station"].tolist()
# --------------------------------------------------------------------------- # FrequencyFilter # ---------------------------------------------------------------------------
[docs] class FrequencyFilter(PyCSAMTObject): """Remove bad frequency bins from Stratagem AMT data. Combines three filtering strategies that are applied in order: 1. **Hardware mask** (optional) — zero-stack rows from raw Stratagem files are masked before any statistical analysis. Requires a fitted :class:`~pycsamt.stratagem.io.StratagemRawReader`. 2. **Band selection** — frequencies outside ``[fmin, fmax]`` are dropped. 3. **Incoherent-frequency mask** — frequencies that fail the SNR threshold across more than ``(1 - min_frac)`` of stations are masked. All masking is performed in-place on the ``EDIFile.Z.z`` arrays of the supplied objects. Use ``copy=True`` in :meth:`fit` to avoid mutating the originals. Parameters ---------- fmin : float, optional Lower frequency bound (Hz). Default: no lower bound. fmax : float, optional Upper frequency bound (Hz). Default: no upper bound. snr_thresh : float, default 2.5 Per-station SNR threshold for incoherent-frequency masking. min_frac : float, default 0.4 Minimum fraction of stations that must pass ``snr_thresh`` for a frequency to be retained. use_hardware_mask : bool, default True When a ``raw_reader`` is given to :meth:`fit`, apply the hardware SNR mask. verbose : int, default 0 Attributes ---------- edi_objects_ : list of EDIFile Filtered EDI objects (in-place modified unless ``copy=True``). n_masked_hw_ : int Number of (station, frequency) pairs masked by hardware SNR. n_masked_stat_ : int Number masked by the statistical incoherence criterion. n_dropped_band_ : int Number of frequency rows removed by band selection. Examples -------- >>> filt = FrequencyFilter(fmin=10.0, fmax=10000.0) >>> filt.fit(inj.edi_objects_, raw_reader=rdr) FrequencyFilter(fmin=10.0, fmax=10000.0, ...) >>> paths = filt.out("2/2EDIF") """ __repr_fields__ = ( "fmin", "fmax", "snr_thresh", "n_masked_hw_", "n_dropped_band_", ) def __init__( self, *, fmin: float | None = None, fmax: float | None = None, snr_thresh: float = 2.5, min_frac: float = 0.4, use_hardware_mask: bool = True, verbose: int = 0, ) -> None: self.fmin = fmin self.fmax = fmax self.snr_thresh = snr_thresh self.min_frac = min_frac self.use_hardware_mask = use_hardware_mask self.verbose = verbose # ------------------------------------------------------------------
[docs] def fit( self, edi_objects: list, raw_reader: StratagemRawReader | None = None, *, copy: bool = False, ) -> FrequencyFilter: """Apply frequency filters. Parameters ---------- edi_objects : list of EDIFile raw_reader : StratagemRawReader, optional Provides hardware SNR masks aligned to station order. copy : bool, default False When True, deep-copies the Z data of each EDIFile before masking so the originals are not mutated. Returns ------- self """ if copy: edi_objects = [deepcopy(e) for e in edi_objects] self.n_masked_hw_ = 0 self.n_dropped_band_ = 0 self.n_masked_stat_ = 0 # ── 1. hardware mask ────────────────────────────────────────── if ( self.use_hardware_mask and raw_reader is not None and hasattr(raw_reader, "snr_mask_") ): raw_freqs = raw_reader.freqs_ # Use station-number matching so that raw[i] aligns with the # correct EDI even when sequences start at different offsets # (e.g. raw stations 1-87, EDI files starting from station 2). edi_to_raw = raw_reader.match_to_edis(edi_objects) for j, edi in enumerate(edi_objects): if j not in edi_to_raw: continue raw_idx = edi_to_raw[j] edi_freqs = getattr(edi.Z, "freq", None) if edi_freqs is None or raw_freqs.size == 0: continue hw_keep = _align_hardware_mask( raw_freqs, raw_reader.snr_mask_[raw_idx], edi_freqs ) n_bad = int((~hw_keep).sum()) if n_bad: _apply_freq_mask_to_edi(edi, hw_keep) self.n_masked_hw_ += n_bad # ── 2. band selection ───────────────────────────────────────── if self.fmin is not None or self.fmax is not None: before_counts = [ int( np.sum( np.isfinite(getattr(e.Z, "z", np.array([])).ravel()) ) ) for e in edi_objects ] select_band( edi_objects, fmin=self.fmin, fmax=self.fmax, inplace=True, verbose=0, ) after_counts = [ int( np.sum( np.isfinite(getattr(e.Z, "z", np.array([])).ravel()) ) ) for e in edi_objects ] self.n_dropped_band_ = sum( max(0, b - a) for b, a in zip(before_counts, after_counts) ) # ── 3. incoherent-frequency mask ────────────────────────────── before_nan = sum( int(np.sum(~np.isfinite(getattr(e.Z, "z", np.array([])).ravel()))) for e in edi_objects ) mask_incoherent_freqs( edi_objects, snr_thresh=self.snr_thresh, min_frac=self.min_frac, inplace=True, verbose=0, ) after_nan = sum( int(np.sum(~np.isfinite(getattr(e.Z, "z", np.array([])).ravel()))) for e in edi_objects ) self.n_masked_stat_ = max(0, after_nan - before_nan) self.edi_objects_ = edi_objects if self.verbose: print( f"[FrequencyFilter] hw={self.n_masked_hw_} masked, " f"band_drop={self.n_dropped_band_}, " f"incoherent={self.n_masked_stat_}" ) return self
# ------------------------------------------------------------------
[docs] def out( self, savepath: str | Path | None = None, *, overwrite: bool = False, ) -> list | list[Path]: """Write filtered EDI files to disk or return objects. Parameters ---------- savepath : path-like, optional Output directory. When ``None``, returns the list of filtered :class:`~pycsamt.seg.edi.EDIFile` objects instead of writing to disk. overwrite : bool, default False Returns ------- list of EDIFile (when savepath is None) or list of Path """ if not hasattr(self, "edi_objects_"): raise NotFittedError("Call fit() first.") if savepath is None: return self.edi_objects_ out_dir = Path(savepath).expanduser().resolve() out_dir.mkdir(parents=True, exist_ok=True) written: list[Path] = [] for edi in self.edi_objects_: fname = ( edi.path.name if getattr(edi, "path", None) is not None else f"{edi.station or 'station'}.edi" ) out_path = out_dir / fname if out_path.exists() and not overwrite: written.append(out_path) continue try: edi.write(new_edifn=fname, savepath=str(out_dir)) written.append(out_path) except Exception as exc: if self.verbose: print(f"[FrequencyFilter] write failed {fname}: {exc}") if self.verbose: print(f"[FrequencyFilter] wrote {len(written)} files → {out_dir}") return written