Source code for pycsamt.stratagem.survey

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

End-to-end Stratagem AMT survey processing pipeline.

:class:`StratagemSurvey` replaces the legacy ``watex``-based
``stratagem_edi_process_script.py`` with a single composable class that
covers the full post-acquisition workflow:

.. code-block:: text

   WinGLink EDI directory
     ↓  EDIBatch              load & natural-sort
     ↓  CoordinateInjector    inject GPS coords from CSV / XLS / XLSX
     ↓  QualityController     per-station QC report (optional)
     ↓  StaticShiftCorrector  AMA static-shift removal
     ↓  FrequencyFilter       band select + incoherent-freq masking
     ↓  NoiseRemover          powerline notch + Hampel + smoothing
     ↓  export()              write corrected EDIs
     ↓  rename()              standardise filenames (T2.000.edi …)

Every processing step is optional and chainable.  The pipeline stores
intermediate objects as attributes so individual results are always
accessible.

Usage example
-------------
Replicate the old ``stratagem_edi_process_script.py`` in four lines:

>>> from pycsamt.stratagem import StratagemSurvey
>>> sv = StratagemSurvey(
...     edi_dir="2/2EDI",
...     coord_file="2.csv",
...     raw_dir="原始数据/2HX",
...     epsg=32649,
... ).fit()
>>> sv.remove_static_shift().remove_noises().drop_frequencies(fmin=10.0)
>>> sv.export("2/2EDIP").rename(basename="T2.", dst_path="2/renamedEDIs")
"""

from __future__ import annotations

from pathlib import Path
from typing import Sequence, Union

from ..api.property import MetadataMixin, PyCSAMTObject
from ..emtools._core import ensure_sites
from ..exceptions import NotFittedError
from ..seg.edi import EDIFile
from ..site.base import Sites
from .gis_correct import CoordinateInjector
from .io import EDIBatch, StratagemRawReader
from .process import NoiseRemover, StaticShiftCorrector
from .qc import FrequencyFilter, QualityController
from .rename import EDIRenamer, EDIWriter

__all__ = ["StratagemSurvey"]

#: Accepted forms for the EDI source: a WinGLink export directory (uses
#: Stratagem's own natural 3-digit sort), or anything the conventional
#: ``pycsamt.emtools``/``pycsamt.site`` stack already understands (a
#: :class:`~pycsamt.site.base.Sites`, a list of
#: :class:`~pycsamt.seg.edi.EDIFile`, or an ``EDICollection``).
EdiSource = Union[str, Path, Sites, Sequence[EDIFile]]


[docs] class StratagemSurvey(PyCSAMTObject, MetadataMixin): """End-to-end Stratagem AMT survey processing pipeline. Parameters ---------- edi_dir : path-like, Sites, or sequence of EDIFile Directory of WinGLink-exported EDI files (uses Stratagem's own natural 3-digit sort via :class:`~pycsamt.stratagem.io.EDIBatch`), or an already-loaded :class:`~pycsamt.site.base.Sites` / ``EDICollection`` / list of :class:`~pycsamt.seg.edi.EDIFile` — normalised through :func:`~pycsamt.emtools._core.ensure_sites`, the same entry point the rest of ``pycsamt.emtools`` uses. In the latter case :attr:`batch_` stays ``None`` (there's no directory to report) and ordering is whatever the source already has. coord_file : path-like GPS coordinate table (CSV / XLS / XLSX). raw_dir : path-like, optional Directory of raw Stratagem hardware files (``X*.NNN``, …). When supplied, hardware SNR masks are used in QC and frequency filtering. epsg : int, default 32649 EPSG code of the projected CRS of *coord_file*. Use ``32649`` (UTM Zone 49N WGS84) for the standard south-China survey area. utm_zone : str, default ``'49N'`` UTM zone string for ``project_point_utm2ll`` when *epsg* is not sufficient. coordinate_system : str, default ``'utm'`` order : str, default ``'auto'`` Station-to-GPS row ordering for :class:`~pycsamt.stratagem.gis_correct.StationLocator`. drop_stations : list of int, optional 0-based indices into the loaded ``EDIBatch`` to exclude *before* coordinate injection — e.g. a calibration/test shot that isn't a real profile position and has no matching row in *coord_file*. easting_col, northing_col : str, optional Column names in *coord_file* for the projected E-W / N-S coordinates. Forwarded to :meth:`~pycsamt.stratagem.gis_correct.CoordinateInjector.fit`. Required whenever *coord_file* has more than two numeric columns besides *elev_col* — auto-detection raises rather than guessing in that case (see :mod:`pycsamt.stratagem.gis_correct`). elev_col : str, default ``'elev'`` station_col : str, default ``'stations'`` read_kwargs : dict, optional Extra keyword arguments forwarded to the *coord_file* reader. verbose : int, default 0 Attributes ---------- batch_ : EDIBatch or None Loaded EDI collection when *edi_dir* was a directory path; ``None`` when *edi_dir* was already a ``Sites``/list of EDIFile. raw_reader_ : StratagemRawReader or None Hardware file reader (None when *raw_dir* not supplied). injector_ : CoordinateInjector Coordinate-injected EDI wrapper. qc_ : QualityController or None QC report (populated after :meth:`run_qc`). edi_objects_ : list of EDIFile Current working set of EDI objects. Modified in-place by each processing step. Examples -------- Full pipeline, one fluent expression: >>> sv = ( ... StratagemSurvey( ... edi_dir="2/2EDI", ... coord_file="2.csv", ... raw_dir="原始数据/2HX", ... epsg=32649, ... ) ... .fit() ... .run_qc() ... .remove_static_shift() ... .drop_frequencies(fmin=10.0) ... .remove_noises() ... .export("2/2EDIP") ... .rename(basename="T2.", dst_path="2/renamedEDIs") ... ) >>> print(sv.qc_.summary()) """ __repr_fields__ = ( "edi_dir", "epsg", "utm_zone", "n_stations_", ) def __init__( self, edi_dir: EdiSource, coord_file: str | Path, *, raw_dir: str | Path | None = None, epsg: int = 32649, utm_zone: str = "49N", coordinate_system: str = "utm", order: str = "auto", drop_stations: list[int] | None = None, easting_col: str | None = None, northing_col: str | None = None, elev_col: str = "elev", station_col: str = "stations", read_kwargs: dict | None = None, verbose: int = 0, ) -> None: self.edi_dir = edi_dir self.coord_file = coord_file self.raw_dir = raw_dir self.epsg = epsg self.utm_zone = utm_zone self.coordinate_system = coordinate_system self.order = order self.drop_stations = drop_stations self.easting_col = easting_col self.northing_col = northing_col self.elev_col = elev_col self.station_col = station_col self.read_kwargs = read_kwargs self.verbose = verbose # populated by fit() self.batch_: EDIBatch | None = None self.raw_reader_: StratagemRawReader | None = None self.injector_: CoordinateInjector | None = None self.qc_: QualityController | None = None self.edi_objects_: list | None = None # ------------------------------------------------------------------ # mandatory step # ------------------------------------------------------------------
[docs] def fit(self) -> StratagemSurvey: """Load EDIs, optional raw files, and inject GPS coordinates. This is the only mandatory step. All processing methods (:meth:`remove_static_shift`, :meth:`drop_frequencies`, etc.) must be called after :meth:`fit`. Returns ------- self """ # ── 1. load EDIs ───────────────────────────────────────────── if isinstance(self.edi_dir, (str, Path)): self.batch_ = EDIBatch( self.edi_dir, verbose=self.verbose, ).fit() n_loaded = len(self.batch_) edi_list = list(self.batch_.edi_objects_) else: # already a Sites / EDICollection / list of EDIFile — hand it # to the same normaliser the rest of pycsamt.emtools uses. self.batch_ = None sites = ensure_sites(self.edi_dir, verbose=self.verbose) n_loaded = len(sites) edi_list = sites.as_list() # drop non-survey stations (e.g. a calibration/test shot) by # 0-based index *before* coordinate injection, so the coordinate # table only needs one row per real station. if self.drop_stations: drop = set(self.drop_stations) edi_list = [e for i, e in enumerate(edi_list) if i not in drop] self.n_stations_ = len(edi_list) if self.verbose: print( f"[StratagemSurvey] loaded {n_loaded} EDI files" + ( f", using {self.n_stations_} after dropping " f"{n_loaded - self.n_stations_} station(s)" if self.drop_stations else "" ) ) # ── 2. load raw hardware files (optional) ───────────────────── if self.raw_dir is not None: try: self.raw_reader_ = StratagemRawReader( self.raw_dir, verbose=self.verbose, ).fit() if self.verbose: cov = self.raw_reader_.station_coverage() print( f"[StratagemSurvey] raw reader: " f"{self.raw_reader_.n_stations_} stations, " f"coverage={cov:.1%}" ) except Exception as exc: if self.verbose: print(f"[StratagemSurvey] raw reader failed: {exc}") self.raw_reader_ = None # ── 3. inject GPS coordinates ───────────────────────────────── self.injector_ = CoordinateInjector( coordinate_system=self.coordinate_system, epsg=self.epsg, utm_zone=self.utm_zone, order=self.order, verbose=self.verbose, ).fit( edi_list, self.coord_file, easting_col=self.easting_col, northing_col=self.northing_col, elev_col=self.elev_col, station_col=self.station_col, read_kwargs=self.read_kwargs, ) self.edi_objects_ = self.injector_.edi_objects_ if self.verbose: print( f"[StratagemSurvey] coordinates injected, " f"reversed={self.injector_.reversed_}" ) return self
# ------------------------------------------------------------------ # optional processing steps (all chainable) # ------------------------------------------------------------------
[docs] def run_qc( self, *, min_frac_ok: float = 0.6, min_snr_med: float = 2.0, max_skew_med: float = 6.0, include_skew: bool = True, ) -> StratagemSurvey: """Run the station-level QC report. Results stored in :attr:`qc_`. Does not modify ``Z`` data. Returns ------- self """ self._require_fit() self.qc_ = QualityController( min_frac_ok=min_frac_ok, min_snr_med=min_snr_med, max_skew_med=max_skew_med, include_skew=include_skew, verbose=self.verbose, ).fit(self.edi_objects_, raw_reader=self.raw_reader_) if self.verbose: n_flagged = len(self.qc_.flagged_stations()) print( f"[StratagemSurvey] QC: {len(self.qc_.report_)} stations, " f"{n_flagged} flagged" ) return self
[docs] def remove_static_shift( self, *, sort_by: str = "lon", half_window: int = 3, weights: str = "tri", pband: tuple | None = None, max_skew: float | None = 6.0, ) -> StratagemSurvey: """Apply AMA static-shift correction. .. important:: Call this **before** :meth:`drop_frequencies` to ensure the full frequency range is available for spatial averaging. Returns ------- self """ self._require_fit() sc = StaticShiftCorrector( sort_by=sort_by, half_window=half_window, weights=weights, pband=pband, max_skew=max_skew, verbose=self.verbose, ).fit(self.edi_objects_) self._ss_corrector_ = sc if self.verbose: med = sc.factors_["fac_z"].median() print( f"[StratagemSurvey] static shift: " f"{len(sc.factors_)} stations, median fac_z={med:.3f}" ) return self
[docs] def drop_frequencies( self, *, fmin: float | None = None, fmax: float | None = None, snr_thresh: float = 2.5, min_frac: float = 0.4, use_hardware_mask: bool = True, ) -> StratagemSurvey: """Filter frequency bands and mask incoherent bins. Parameters ---------- fmin, fmax : float, optional Frequency band limits in Hz. snr_thresh : float Per-station SNR threshold for incoherence masking. min_frac : float Minimum fraction of stations that must pass *snr_thresh*. use_hardware_mask : bool Apply hardware SNR mask when raw files were loaded. Returns ------- self """ self._require_fit() ff = FrequencyFilter( fmin=fmin, fmax=fmax, snr_thresh=snr_thresh, min_frac=min_frac, use_hardware_mask=use_hardware_mask, verbose=self.verbose, ).fit(self.edi_objects_, raw_reader=self.raw_reader_) self._freq_filter_ = ff if self.verbose: print( f"[StratagemSurvey] freq filter: " f"hw={ff.n_masked_hw_}, band={ff.n_dropped_band_}, " f"incoherent={ff.n_masked_stat_}" ) return self
[docs] def remove_noises( 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, ) -> StratagemSurvey: """Apply powerline notch + Hampel outlier + optional smoothing. Returns ------- self """ self._require_fit() nr = NoiseRemover( mains_hz=mains_hz, n_harm=n_harm, tol_hz=tol_hz, notch_mode=notch_mode, hampel_win=hampel_win, hampel_nsig=hampel_nsig, smooth=smooth, smooth_win=smooth_win, verbose=self.verbose, ).fit(self.edi_objects_) self._noise_remover_ = nr if self.verbose: print( f"[StratagemSurvey] noise removal: " f"{nr.n_stations_} stations processed" ) return self
# ------------------------------------------------------------------ # output steps # ------------------------------------------------------------------
[docs] def export( self, savepath: str | Path, *, dataid_prefix: str | None = None, overwrite: bool = False, ) -> StratagemSurvey: """Write the current ``edi_objects_`` to *savepath*. Parameters ---------- savepath : path-like Output directory (created if absent). dataid_prefix : str, optional When given, ``>HEAD.DATAID`` is standardised to ``{dataid_prefix}{i:03d}`` before writing. overwrite : bool, default False Returns ------- self """ self._require_fit() writer = EDIWriter( dataid_prefix=dataid_prefix, overwrite=overwrite, verbose=self.verbose, ).fit(self.edi_objects_, savepath) self._last_export_dir_ = Path(savepath).expanduser().resolve() self._writer_ = writer if self.verbose: print( f"[StratagemSurvey] export: " f"{writer.n_written_} files → {self._last_export_dir_}" ) return self
[docs] def rename( self, basename: str, dst_path: str | Path, *, zero_pad: int = 3, trailer: str = "", overwrite: bool = False, source: str | Path | None = None, ) -> StratagemSurvey: """Rename EDI files with a standardised basename. Parameters ---------- basename : str Filename prefix, e.g. ``'T2.'`` → ``T2.000.edi``. dst_path : path-like Output directory for renamed files. zero_pad : int, default 3 trailer : str, default ``''`` overwrite : bool, default False source : path-like, optional Source directory or list. Defaults to the directory written by the most recent :meth:`export` call; falls back to the current ``edi_objects_``. Returns ------- self """ self._require_fit() src: str | Path | list if source is not None: src = source elif hasattr(self, "_last_export_dir_"): src = self._last_export_dir_ else: src = self.edi_objects_ renamer = EDIRenamer( basename=basename, zero_pad=zero_pad, trailer=trailer, overwrite=overwrite, verbose=self.verbose, ).fit(src, dst_path) self._renamer_ = renamer if self.verbose: print( f"[StratagemSurvey] renamed {renamer.n_renamed_} files → " f"{Path(dst_path).expanduser().resolve()}" ) return self
# ------------------------------------------------------------------ # convenience helpers # ------------------------------------------------------------------
[docs] def summary(self) -> str: """Return a human-readable pipeline status summary.""" self._require_fit() lines = [ "StratagemSurvey", f" edi_dir : {self.edi_dir}", f" coord_file : {self.coord_file}", f" n_stations : {self.n_stations_}", f" epsg / zone: {self.epsg} / {self.utm_zone}", f" raw_reader : {'yes (' + str(self.raw_reader_.n_stations_) + ' stations)' if self.raw_reader_ else 'not loaded'}", ] if self.injector_ is not None: lines.append( f" coord order: {'reversed' if self.injector_.reversed_ else 'forward'}" ) if self.qc_ is not None: n_flag = len(self.qc_.flagged_stations()) lines.append( f" QC flags : {n_flag} / {len(self.qc_.report_)} stations" ) if hasattr(self, "_ss_corrector_"): med = self._ss_corrector_.factors_["fac_z"].median() lines.append(f" SS fac_z : median={med:.3f}") if hasattr(self, "_freq_filter_"): ff = self._freq_filter_ lines.append( f" freq filter: hw={ff.n_masked_hw_}, " f"band={ff.n_dropped_band_}, incoh={ff.n_masked_stat_}" ) if hasattr(self, "_noise_remover_"): lines.append( f" noise rm : {self._noise_remover_.n_stations_} stations" ) if hasattr(self, "_writer_"): lines.append( f" export : {self._writer_.n_written_} files → {self._last_export_dir_}" ) if hasattr(self, "_renamer_"): lines.append(f" rename : {self._renamer_.n_renamed_} files") return "\n".join(lines)
[docs] @property def coordinate_frame(self): """DataFrame of WGS84 coordinates (requires :meth:`fit`).""" self._require_fit() return self.injector_.coordinate_frame()
[docs] @property def sites_(self) -> Sites: """Current :attr:`edi_objects_` wrapped as a :class:`~pycsamt.site.base.Sites`. A fresh view built on every access, so it always reflects the current pipeline state (post-QC, post-static-shift, etc.). This is the interop point with the conventional ``pycsamt.emtools`` / ``pycsamt.site`` stack — e.g. use ``sv.sites_.write(outdir)`` for the generic ``{station}.edi`` writer instead of Stratagem's own :meth:`export`/:meth:`rename` (which additionally handle DATAID prefixing and Stratagem's zero-padded naming convention). Examples -------- >>> sv.sites_.write("out_dir", exist_ok=True) """ self._require_fit() return Sites(self.edi_objects_)
# ------------------------------------------------------------------ # internal # ------------------------------------------------------------------ def _require_fit(self) -> None: if self.edi_objects_ is None: raise NotFittedError( "Call StratagemSurvey.fit() before running processing steps." )