Source code for pycsamt.stratagem.process

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

Data-processing classes for Stratagem AMT surveys.

:class:`StaticShiftCorrector`
    Estimates and removes the static-shift effect from apparent-resistivity
    curves using the Adaptive Moving-Average (AMA) spatial filter
    (Kouadio et al., 2024; Torres-Verdín & Bostick, 1992).  Delegates to
    :func:`~pycsamt.emtools.ss.estimate_ss_ama` and
    :func:`~pycsamt.emtools.ss.apply_ss_factors`.

:class:`NoiseRemover`
    Multi-stage noise-removal pipeline: powerline notch filter →
    Hampel outlier filter → optional log-frequency smoothing.
    Delegates to :mod:`pycsamt.emtools.remove_noise`.

Both classes follow the ``fit() → out()`` pattern: ``fit()`` applies
corrections **in-place** on the Z arrays of the supplied EDIFile objects,
stores the result in :attr:`edi_objects_`, and returns ``self`` for
chaining.  ``out()`` either returns the processed objects or writes them
to a directory.

.. note::
   Because emtools processing functions modify impedance tensors in-place,
   passing the same EDIFile list through multiple processors sequentially
   is safe.  If you need to preserve the originals, pass ``copy=True`` to
   :meth:`fit`.
"""

from __future__ import annotations

from copy import deepcopy
from pathlib import Path

import pandas as pd

from ..api.property import MetadataMixin, PyCSAMTObject
from ..emtools.remove_noise import (
    hampel_filter_freq,
    notch_powerline,
    smooth_logfreq,
)
from ..emtools.ss import apply_ss_factors, estimate_ss_ama
from ..exceptions import NotFittedError

__all__ = ["NoiseRemover", "StaticShiftCorrector"]


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


def _write_edis(
    edi_objects: list,
    savepath: Path,
    overwrite: bool,
    verbose: int,
    tag: str,
) -> list[Path]:
    """Write a list of EDIFile objects to *savepath*."""
    savepath.mkdir(parents=True, exist_ok=True)
    written: list[Path] = []
    for edi in edi_objects:
        fname = (
            edi.path.name
            if getattr(edi, "path", None) is not None
            else f"{edi.station or 'station'}.edi"
        )
        out_path = savepath / fname
        if out_path.exists() and not overwrite:
            written.append(out_path)
            continue
        try:
            edi.write(new_edifn=fname, savepath=str(savepath))
            written.append(out_path)
        except Exception as exc:
            if verbose:
                print(f"[{tag}] write failed {fname}: {exc}")
    if verbose:
        print(f"[{tag}] wrote {len(written)} files → {savepath}")
    return written


# ---------------------------------------------------------------------------
# StaticShiftCorrector
# ---------------------------------------------------------------------------


[docs] class StaticShiftCorrector(PyCSAMTObject, MetadataMixin): """Estimate and remove static-shift from Stratagem AMT impedance data. Implements the AMA (Adaptive Moving-Average) spatial filter to estimate per-station static-shift factors and correct the impedance tensor amplitudes accordingly. The correction is applied **in-place** on ``EDIFile.Z.z``. Use ``copy=True`` in :meth:`fit` to preserve originals. Parameters ---------- sort_by : {'lon', 'lat', 'name'}, default ``'lon'`` Spatial ordering of stations for the AMA spatial average. Use ``'lon'`` for E-W profiles, ``'lat'`` for N-S profiles. half_window : int, default 3 Number of neighbour stations on each side used in the AMA spatial average. weights : {'tri', 'gauss', 'uniform'}, default ``'tri'`` Distance-weighting scheme for AMA neighbours. pband : tuple of (float, float), optional Period range ``(T_min, T_max)`` in seconds used when estimating the shift factor. Useful for restricting the estimation to a band free of near-surface distortion. max_skew : float or None, default 6.0 Phase-tensor skew threshold: stations with median |β| above this are excluded from the spatial average (strong 3-D distortion). Set to ``None`` to disable. verbose : int, default 0 Attributes ---------- factors_ : pandas.DataFrame Per-station shift factors with columns ``station``, ``delta_log10_rho``, ``fac_rho``, ``fac_z``, ``n_used``. edi_objects_ : list of EDIFile Corrected EDI objects (in-place modified unless ``copy=True``). Examples -------- >>> from pycsamt.stratagem.process import StaticShiftCorrector >>> sc = StaticShiftCorrector(sort_by="lon", half_window=3).fit(edis) >>> sc.factors_.head() >>> paths = sc.out("2/2EDISS") """ __repr_fields__ = ( "sort_by", "half_window", "weights", "max_skew", "n_stations_", ) def __init__( self, *, sort_by: str = "lon", half_window: int = 3, weights: str = "tri", pband: tuple | None = None, max_skew: float | None = 6.0, verbose: int = 0, ) -> None: self.sort_by = sort_by self.half_window = half_window self.weights = weights self.pband = pband self.max_skew = max_skew self.verbose = verbose # ------------------------------------------------------------------
[docs] def fit( self, edi_objects: list, *, copy: bool = False, ) -> StaticShiftCorrector: """Estimate and apply static-shift corrections. Parameters ---------- edi_objects : list of EDIFile copy : bool, default False Deep-copy Z data before correcting. Returns ------- self """ if copy: edi_objects = [deepcopy(e) for e in edi_objects] self.n_stations_ = len(edi_objects) # estimate shift factors (api=False forces plain DataFrame). # estimate_ss_ama calls build_phase_tensor_table unconditionally; # if Z data contains NaN (e.g. after FrequencyFilter), that can raise # LinAlgError. We catch it and fall back to unit factors with a warning. try: self.factors_ = estimate_ss_ama( edi_objects, sort_by=self.sort_by, half_window=self.half_window, weights=self.weights, pband=self.pband, max_skew=self.max_skew, api=False, verbose=self.verbose, ) except Exception as exc: print( f"[StaticShiftCorrector] WARNING — AMA estimation failed " f"({type(exc).__name__}: {exc}).\n" f" Tip: run StaticShiftCorrector BEFORE FrequencyFilter so " f"that Z data is complete during spatial averaging.\n" f" No static-shift correction applied." ) stations = [ edi.station or f"S{i:03d}" for i, edi in enumerate(edi_objects) ] self.factors_ = pd.DataFrame( { "station": stations, "delta_log10_rho": 0.0, "fac_rho": 1.0, "fac_z": 1.0, "n_used": 0, } ) else: # apply correction in-place on Z only when estimation succeeded apply_ss_factors( edi_objects, self.factors_, key="fac_z", inplace=True, verbose=self.verbose, ) self.edi_objects_ = edi_objects if self.verbose: med_fac = float(self.factors_["fac_z"].median()) print( f"[StaticShiftCorrector] corrected {self.n_stations_} stations, " f"median fac_z={med_fac:.3f}" ) return self
# ------------------------------------------------------------------
[docs] def out( self, savepath: str | Path | None = None, *, overwrite: bool = False, ) -> list | list[Path]: """Return corrected EDI objects or write them to *savepath*. Parameters ---------- savepath : path-like, optional When ``None`` returns the list of :class:`~pycsamt.seg.edi.EDIFile` objects; otherwise writes files and returns a list of :class:`pathlib.Path`. overwrite : bool, default False Returns ------- list of EDIFile or list of Path """ if not hasattr(self, "edi_objects_"): raise NotFittedError("Call fit() first.") if savepath is None: return self.edi_objects_ return _write_edis( self.edi_objects_, Path(savepath).expanduser().resolve(), overwrite, self.verbose, "StaticShiftCorrector", )
# --------------------------------------------------------------------------- # NoiseRemover # ---------------------------------------------------------------------------
[docs] class NoiseRemover(PyCSAMTObject): """Multi-stage noise-removal pipeline for Stratagem AMT data. Applies three sequential filters to the impedance tensor: 1. **Powerline notch** — masks (interpolates) the mains frequency and its harmonics. Controlled by ``mains_hz`` and ``n_harm``. 2. **Hampel outlier filter** — identifies and replaces frequency-domain spike outliers using a median-absolute-deviation test. 3. **Log-frequency smoothing** (optional) — applies a triangular or Gaussian kernel along the log-frequency axis. All corrections are applied **in-place** on ``EDIFile.Z.z``. Parameters ---------- mains_hz : float, default 50.0 Mains frequency (Hz). Use 60.0 for North American data. n_harm : int, default 30 Number of powerline harmonics to notch. tol_hz : float, default 0.08 Frequency tolerance (Hz) around each harmonic for the notch filter. notch_mode : {'interp', 'zero', 'nan'}, default ``'interp'`` How to handle notched bins: ``'interp'`` interpolates across them (recommended), ``'nan'`` flags them as missing. hampel_win : int, default 3 Half-window size for the Hampel outlier filter (in frequency bins). hampel_nsig : float, default 3.0 Outlier threshold in units of median absolute deviation. smooth : bool, default False Enable log-frequency smoothing (stage 3). smooth_win : int, default 3 Smoothing half-window. Values above 4 may trigger a known shape issue in :func:`~pycsamt.emtools.remove_noise.smooth_logfreq` for short frequency vectors; keep ≤ 3 unless you have verified your data. verbose : int, default 0 Attributes ---------- edi_objects_ : list of EDIFile Denoised EDI objects (in-place modified unless ``copy=True``). Examples -------- >>> from pycsamt.stratagem.process import NoiseRemover >>> nr = NoiseRemover(mains_hz=50.0, smooth=True, smooth_win=3) >>> nr.fit(edis) >>> paths = nr.out("2/2EDID") """ __repr_fields__ = ( "mains_hz", "n_harm", "hampel_win", "smooth", "n_stations_", ) def __init__( self, *, mains_hz: float = 50.0, n_harm: int = 30, tol_hz: float = 0.08, notch_mode: str = "interp", hampel_win: int = 3, hampel_nsig: float = 3.0, smooth: bool = False, smooth_win: int = 3, verbose: int = 0, ) -> None: self.mains_hz = mains_hz self.n_harm = n_harm self.tol_hz = tol_hz self.notch_mode = notch_mode self.hampel_win = hampel_win self.hampel_nsig = hampel_nsig self.smooth = smooth self.smooth_win = smooth_win self.verbose = verbose # ------------------------------------------------------------------
[docs] def fit( self, edi_objects: list, *, copy: bool = False, ) -> NoiseRemover: """Apply the noise-removal pipeline. Parameters ---------- edi_objects : list of EDIFile copy : bool, default False Deep-copy Z data before processing. Returns ------- self """ if copy: edi_objects = [deepcopy(e) for e in edi_objects] self.n_stations_ = len(edi_objects) # ── stage 1: powerline notch ────────────────────────────────── notch_powerline( edi_objects, mains_hz=self.mains_hz, n_harm=self.n_harm, tol_hz=self.tol_hz, mode=self.notch_mode, inplace=True, verbose=self.verbose, ) # ── stage 2: Hampel outlier filter ─────────────────────────── hampel_filter_freq( edi_objects, win=self.hampel_win, nsig=self.hampel_nsig, inplace=True, verbose=self.verbose, ) # ── stage 3: log-frequency smoothing (optional) ─────────────── if self.smooth: try: smooth_logfreq( edi_objects, win=self.smooth_win, inplace=True, verbose=self.verbose, ) except (ValueError, IndexError) as exc: if self.verbose: print( f"[NoiseRemover] smooth_logfreq skipped (win=" f"{self.smooth_win}): {exc}" ) self.edi_objects_ = edi_objects if self.verbose: print( f"[NoiseRemover] processed {self.n_stations_} stations " f"(notch + hampel" + (" + smooth)" if self.smooth else ")") ) return self
# ------------------------------------------------------------------
[docs] def out( self, savepath: str | Path | None = None, *, overwrite: bool = False, ) -> list | list[Path]: """Return denoised EDI objects or write them to *savepath*. Parameters ---------- savepath : path-like, optional overwrite : bool, default False Returns ------- list of EDIFile or list of Path """ if not hasattr(self, "edi_objects_"): raise NotFittedError("Call fit() first.") if savepath is None: return self.edi_objects_ return _write_edis( self.edi_objects_, Path(savepath).expanduser().resolve(), overwrite, self.verbose, "NoiseRemover", )