Source code for pycsamt.pipeline.stratagem

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

Stratagem AMT pipeline — extends the standard :class:`~._pipeline.Pipeline`
with hardware-survey pre-processing and post-processing hooks.

Two levels of API
-----------------

**Level 1 — :class:`StratagemPipeline`** (``Pipeline`` subclass):

    Follows exactly the same ``run(sites, *, outdir=...)`` interface as
    :class:`~._pipeline.Pipeline`.  The *sites* argument accepts every input
    type that :func:`~pycsamt.emtools._core.ensure_sites` handles:

    * :class:`~pycsamt.site.base.Sites` object (used as-is)
    * ``str`` or :class:`pathlib.Path` pointing to a **single EDI file**
    * ``str`` or :class:`pathlib.Path` pointing to an **EDI directory**
    * A single :class:`~pycsamt.seg.edi.EDIFile` object
    * A list of :class:`~pycsamt.seg.edi.EDIFile` objects

    Optional pre-processing before the emtools steps:

    * **Coordinate injection** — when *coord_file* is set, GPS coordinates
      from a CSV / XLS / XLSX file are injected into the EDI ``>HEAD``
      sections.
    * **Hardware SNR masking** — when *raw_dir* is set, zero-stack frequency
      rows from the Stratagem hardware files are masked.

    Optional post-processing after the emtools steps:

    * **Rename** — when *rename_basename* is set, the ``processed/`` EDI
      files are renamed with the given basename (``T2.000.edi``, …).

**Level 2 — :func:`run_stratagem_preset` / :class:`StratagemPreset`**:

    Convenience wrappers around :class:`~pycsamt.stratagem.survey.StratagemSurvey`
    that use the full Stratagem workflow (coord injection + SS + noise + rename).
    Useful for quick one-liner scripts that replace the legacy
    ``stratagem_edi_process_script.py``.

Usage examples
--------------
One-liner (Level 2, backwards-compatible)::

    from pycsamt.pipeline.stratagem import run_stratagem_preset
    sv = run_stratagem_preset(
        "basic",
        edi_dir="2/2EDI",
        coord_file="2.csv",
        outdir="2/processed",
        epsg=32649,
        rename_basename="T2.",
    )

Pipeline API with a directory of EDIs (Level 1)::

    from pycsamt.pipeline.stratagem import StratagemPipeline
    pipe = StratagemPipeline.from_preset(
        "stratagem_mt",
        coord_file="2.csv",
        epsg=32649,
        rename_basename="T2.",
    )
    result = pipe.run("2/2EDI", outdir="2/processed")

Pipeline API with a single EDI file::

    result = pipe.run("2/2EDI/Z2HX002.edi", outdir="tmp/")

Pipeline API with an already-loaded Sites::

    from pycsamt.emtools._core import ensure_sites
    sites = ensure_sites("2/2EDI")
    result = pipe.run(sites, outdir="2/processed")

Custom step list::

    from pycsamt.pipeline import Step
    pipe = StratagemPipeline(
        [
            ("correct_ss",  Step("SS001")),
            ("select_band", Step("FREQ001", band_hz=(10.0, 1e5))),
            ("notch",       Step("NR001")),
            ("hampel",      Step("NR004")),
            ("qc",          Step("QC001")),
        ],
        coord_file="2.csv",
        raw_dir="原始数据/2HX",
        epsg=32649,
        rename_basename="T2.",
    )
    result = pipe.run("2/2EDI", outdir="2/processed")
"""

from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from ._pipeline import Pipeline, PipelineResult
from ._steps import Step

__all__ = [
    "StratagemPreset",
    "STRATAGEM_PRESETS",
    "StratagemPipeline",
    "get_stratagem_preset",
    "list_stratagem_presets",
    "run_stratagem_preset",
    "stratagem_preset_catalogue",
]


# ---------------------------------------------------------------------------
# StratagemPreset  (Level 2 — StratagemSurvey-based presets)
# ---------------------------------------------------------------------------


[docs] @dataclass class StratagemPreset: """Named configuration bundle for :class:`~pycsamt.stratagem.survey.StratagemSurvey`. Used by :func:`run_stratagem_preset`. Each preset describes an ordered sequence of :class:`~pycsamt.stratagem.survey.StratagemSurvey` method calls with default keyword arguments. Attributes ---------- name : str description : str survey_defaults : dict Default keyword arguments for :class:`~pycsamt.stratagem.survey.StratagemSurvey.__init__`. steps : list of (str, dict) Ordered ``(method_name, kwargs)`` pairs executed on the survey. """ name: str description: str survey_defaults: dict = field(default_factory=dict) steps: list[tuple[str, dict]] = field(default_factory=list) def __repr__(self) -> str: step_names = " → ".join(n for n, _ in self.steps) return f"StratagemPreset({self.name!r} [{step_names}])"
STRATAGEM_PRESETS: dict[str, StratagemPreset] = { "basic": StratagemPreset( name="basic", description=( "Direct equivalent of the legacy stratagem_edi_process_script.py: " "inject GPS coords → AMA static-shift → frequency trim → " "noise removal → export → rename." ), survey_defaults={"epsg": 32649, "utm_zone": "49N"}, steps=[ ( "remove_static_shift", {"sort_by": "lon", "half_window": 3, "weights": "tri"}, ), ( "drop_frequencies", { "fmin": 10.0, "snr_thresh": 2.5, "min_frac": 0.4, "use_hardware_mask": True, }, ), ( "remove_noises", { "mains_hz": 50.0, "n_harm": 30, "hampel_win": 3, "smooth": False, }, ), ("export", {}), ("rename", {}), ], ), "full_processing": StratagemPreset( name="full_processing", description=( "QC report → AMA static-shift → hardware-aware frequency filter " "→ noise removal with smoothing → export → rename." ), survey_defaults={"epsg": 32649, "utm_zone": "49N"}, steps=[ ( "run_qc", { "include_skew": True, "min_frac_ok": 0.6, "min_snr_med": 2.0, "max_skew_med": 6.0, }, ), ( "remove_static_shift", { "sort_by": "lon", "half_window": 3, "weights": "tri", "max_skew": 6.0, }, ), ( "drop_frequencies", { "fmin": 10.0, "fmax": 1e5, "snr_thresh": 2.5, "min_frac": 0.4, "use_hardware_mask": True, }, ), ( "remove_noises", { "mains_hz": 50.0, "n_harm": 30, "hampel_win": 3, "smooth": True, "smooth_win": 3, }, ), ("export", {}), ("rename", {}), ], ), "publication_ready": StratagemPreset( name="publication_ready", description=( "C&G standard: full QC, hardware SNR masking, tight AMT band " "(15 Hz – 100 kHz), aggressive smoothing → export → rename." ), survey_defaults={"epsg": 32649, "utm_zone": "49N"}, steps=[ ( "run_qc", { "include_skew": True, "min_frac_ok": 0.5, "min_snr_med": 3.0, "max_skew_med": 5.0, }, ), ( "remove_static_shift", { "sort_by": "lon", "half_window": 5, "weights": "tri", "max_skew": 5.0, }, ), ( "drop_frequencies", { "fmin": 15.0, "fmax": 1e5, "snr_thresh": 3.0, "min_frac": 0.5, "use_hardware_mask": True, }, ), ( "remove_noises", { "mains_hz": 50.0, "n_harm": 30, "tol_hz": 0.05, "hampel_win": 3, "hampel_nsig": 3.0, "smooth": True, "smooth_win": 3, }, ), ("export", {}), ("rename", {}), ], ), }
[docs] def get_stratagem_preset(name: str) -> StratagemPreset: """Return the :class:`StratagemPreset` for *name*.""" if name not in STRATAGEM_PRESETS: raise KeyError( f"Unknown Stratagem preset {name!r}. " f"Available: {sorted(STRATAGEM_PRESETS)}" ) return STRATAGEM_PRESETS[name]
[docs] def list_stratagem_presets() -> list[StratagemPreset]: """Return all :class:`StratagemPreset` objects.""" return list(STRATAGEM_PRESETS.values())
[docs] def stratagem_preset_catalogue() -> str: """Return a formatted catalogue of all Stratagem presets.""" lines = ["Stratagem pipeline presets", "─" * 64] for p in STRATAGEM_PRESETS.values(): steps_str = " → ".join(n for n, _ in p.steps) lines.append(f" {p.name:<22s} {p.description}") lines.append(f" {'':22s} {steps_str}") lines.append("") return "\n".join(lines)
# --------------------------------------------------------------------------- # helpers # --------------------------------------------------------------------------- def _coerce_to_sites(sites: Any) -> Any: """Normalise any Stratagem-compatible input to a :class:`~pycsamt.site.base.Sites`. Accepted inputs: * :class:`~pycsamt.site.base.Sites` — returned unchanged * ``str`` / :class:`pathlib.Path` (dir) — loads all EDIs * ``str`` / :class:`pathlib.Path` (single .edi) — loads one EDI * :class:`~pycsamt.seg.edi.EDIFile` — wrapped in Sites * list of :class:`~pycsamt.seg.edi.EDIFile` — wrapped in Sites """ from ..emtools._core import ensure_sites # noqa: PLC0415 from ..seg.edi import EDIFile # noqa: PLC0415 # Wrap a bare EDIFile so ensure_sites doesn't confuse it with a path-like if isinstance(sites, EDIFile): sites = [sites] return ensure_sites(sites) def _inject_coordinates( sites: Any, coord_file: str | Path, epsg: int, utm_zone: str, order: str, ) -> Any: """Apply coordinate injection and return the updated Sites. When the number of EDIs does not match the number of rows in *coord_file* (e.g. the caller passed a single EDI but the CSV has 83 rows), injection is skipped with a warning and the original Sites is returned unchanged. """ import warnings # noqa: PLC0415 from ..emtools._core import ( # noqa: PLC0415 _iter_items, ensure_sites, ) from ..exceptions import ValidationError # noqa: PLC0415 from ..stratagem.gis_correct import ( CoordinateInjector, # noqa: PLC0415 ) edis = [getattr(ed, "edi", ed) for ed in _iter_items(sites)] try: inj = CoordinateInjector( epsg=epsg, utm_zone=utm_zone, order=order, ).fit(edis, coord_file) return ensure_sites(inj.edi_objects_) except ValidationError as exc: warnings.warn( f"[StratagemPipeline] coordinate injection skipped: {exc} " "Proceeding without injecting coordinates.", UserWarning, stacklevel=3, ) return sites def _apply_hardware_mask(sites: Any, raw_dir: str | Path) -> Any: """Apply hardware SNR mask and return the updated Sites.""" from ..emtools._core import ( # noqa: PLC0415 _iter_items, ensure_sites, ) from ..stratagem.io import ( StratagemRawReader, # noqa: PLC0415 ) from ..stratagem.qc import ( FrequencyFilter, # noqa: PLC0415 ) rdr = StratagemRawReader(raw_dir).fit() edis = [getattr(ed, "edi", ed) for ed in _iter_items(sites)] ff = FrequencyFilter(use_hardware_mask=True, snr_thresh=0.0, min_frac=0.0) ff.fit(edis, raw_reader=rdr) return ensure_sites(ff.edi_objects_) def _rename_processed( processed_dir: Path, rename_dir: Path, basename: str, overwrite: bool = False, ) -> list[Path]: """Rename EDI files in *processed_dir* to *rename_dir* with *basename*.""" from ..stratagem.rename import EDIRenamer # noqa: PLC0415 if not processed_dir.is_dir(): return [] rename_dir.mkdir(parents=True, exist_ok=True) rn = EDIRenamer(basename=basename, overwrite=overwrite) rn.fit(processed_dir, rename_dir) return rn.dst_paths() # --------------------------------------------------------------------------- # StratagemPipeline (Level 1 — proper Pipeline subclass) # ---------------------------------------------------------------------------
[docs] class StratagemPipeline(Pipeline): """A :class:`~._pipeline.Pipeline` extended with Stratagem pre/post-processing. Follows exactly the same ``run(sites, *, outdir=...)`` interface as the standard pipeline. The *sites* argument is normalised via :func:`~pycsamt.emtools._core.ensure_sites`, so it accepts: * :class:`~pycsamt.site.base.Sites` * ``str`` / :class:`pathlib.Path` pointing to a **directory** of EDIs * ``str`` / :class:`pathlib.Path` pointing to a **single EDI** file * A single :class:`~pycsamt.seg.edi.EDIFile` object * A list of :class:`~pycsamt.seg.edi.EDIFile` objects Optional pre-processing (applied before emtools steps) ------------------------------------------------------- * **Coordinate injection** — when *coord_file* is given, GPS coordinates from a CSV / XLS / XLSX table are written into each EDI ``>HEAD`` section via :class:`~pycsamt.stratagem.gis_correct.CoordinateInjector`. * **Hardware SNR mask** — when *raw_dir* is given, zero-stack frequency rows in the Stratagem raw files are masked in the impedance tensor via :class:`~pycsamt.stratagem.qc.FrequencyFilter`. Optional post-processing (applied after emtools steps) ------------------------------------------------------- * **Rename** — when *rename_basename* is given, the output EDI files in ``<outdir>/processed/`` are copied to ``<outdir>/renamed/`` with a standardised naming convention using :class:`~pycsamt.stratagem.rename.EDIRenamer`. Parameters ---------- steps : list of (str, Step) or list of Step emtools processing steps (same format as :class:`~._pipeline.Pipeline`). coord_file : path-like, optional GPS coordinate table (CSV / XLS / XLSX). raw_dir : path-like, optional Directory of raw Stratagem hardware files for hardware SNR masking. epsg : int, default 32649 EPSG code of the projected CRS in *coord_file*. utm_zone : str, default ``'49N'`` order : str, default ``'auto'`` Station ordering for :class:`~pycsamt.stratagem.gis_correct.StationLocator`. rename_basename : str, optional When given, output EDIs are renamed ``{basename}000.edi``, … rename_dir : path-like, optional Destination for renamed files. Defaults to ``<outdir>/renamed/``. name : str, default ``'stratagem_pipeline'`` Examples -------- Load a directory and inject coordinates:: from pycsamt.pipeline.stratagem import StratagemPipeline pipe = StratagemPipeline.from_preset( "stratagem_mt", coord_file="2.csv", epsg=32649, rename_basename="T2.", ) result = pipe.run("2/2EDI", outdir="2/processed") print(result.summary()) Single EDI file:: result = pipe.run("2/2EDI/Z2HX002.edi", outdir="tmp/single/") Already-loaded Sites:: from pycsamt.emtools._core import ensure_sites S = ensure_sites("2/2EDI") result = pipe.run(S, outdir="2/processed") Custom steps with hardware mask:: from pycsamt.pipeline import Step pipe = StratagemPipeline( [("ss", Step("SS001")), ("band", Step("FREQ001", band_hz=(10, 1e5)))], coord_file="2.csv", raw_dir="原始数据/2HX", epsg=32649, ) result = pipe.run("2/2EDI", outdir="out/") """ def __init__( self, steps: list[tuple[str, Step]] | list[Step], *, coord_file: str | Path | None = None, raw_dir: str | Path | None = None, epsg: int = 32649, utm_zone: str = "49N", order: str = "auto", rename_basename: str | None = None, rename_dir: str | Path | None = None, name: str = "stratagem_pipeline", ) -> None: super().__init__(steps, name=name) self.coord_file = coord_file self.raw_dir = raw_dir self.epsg = epsg self.utm_zone = utm_zone self.order = order self.rename_basename = rename_basename self.rename_dir = rename_dir # ------------------------------------------------------------------
[docs] def run( self, sites: Any, *, outdir: Any = None, save_plots: bool = True, save_edis: bool = True, save_report: bool = True, api: Any = None, rename_basename: str | None = None, rename_dir: str | Path | None = None, overwrite: bool = False, ) -> PipelineResult: """Run the pipeline on *sites*. Parameters ---------- sites : Sites | str | Path | EDIFile | list[EDIFile] Input data. Any form accepted by :func:`~pycsamt.emtools._core.ensure_sites`. outdir : path-like, optional Root output directory (same as :meth:`Pipeline.run`). save_plots : bool, default True save_edis : bool, default True save_report : bool, default True api : PipelineAPIConfig, optional rename_basename : str, optional Override the *rename_basename* set in ``__init__``. rename_dir : path-like, optional Override the *rename_dir* set in ``__init__``. overwrite : bool, default False Overwrite existing renamed files. Returns ------- :class:`~._pipeline.PipelineResult` """ # ── 1. normalise input ──────────────────────────────────────── S = _coerce_to_sites(sites) # ── 2. optional coord injection ─────────────────────────────── if self.coord_file is not None: S = _inject_coordinates( S, self.coord_file, self.epsg, self.utm_zone, self.order, ) # ── 3. optional hardware SNR mask ───────────────────────────── if self.raw_dir is not None: S = _apply_hardware_mask(S, self.raw_dir) # ── 4. standard emtools pipeline ────────────────────────────── result = super().run( S, outdir=outdir, save_plots=save_plots, save_edis=save_edis, save_report=save_report, api=api, ) # ── 5. optional rename ──────────────────────────────────────── _basename = rename_basename or self.rename_basename if _basename and result.outdir is not None: from ..api.pipe import ( PYCSAMT_PIPE, # noqa: PLC0415 ) cfg = api or PYCSAMT_PIPE processed_sub = getattr(cfg, "processed_subdir", "processed") processed_dir = result.outdir / processed_sub _rdir = rename_dir or self.rename_dir dst_dir = ( Path(_rdir).expanduser().resolve() if _rdir is not None else result.outdir / "renamed" ) renamed = _rename_processed( processed_dir, dst_dir, _basename, overwrite=overwrite ) result.processed_paths.extend(renamed) return result
# ------------------------------------------------------------------
[docs] @classmethod def from_preset( cls, name: str = "stratagem_mt", *, coord_file: str | Path | None = None, raw_dir: str | Path | None = None, epsg: int = 32649, utm_zone: str = "49N", order: str = "auto", rename_basename: str | None = None, rename_dir: str | Path | None = None, pipeline_name: str | None = None, ) -> StratagemPipeline: """Build a :class:`StratagemPipeline` from a named emtools preset. Parameters ---------- name : str, default ``'stratagem_mt'`` Any preset in :data:`~._presets.PRESETS` (e.g. ``'stratagem_mt'``, ``'full_processing'``, ``'publication_ready'``). coord_file, raw_dir, epsg, utm_zone, order Forwarded to the constructor. rename_basename, rename_dir Forwarded to the constructor. pipeline_name : str, optional Override the pipeline label. Returns ------- StratagemPipeline Examples -------- >>> pipe = StratagemPipeline.from_preset( ... "stratagem_mt", ... coord_file="2.csv", ... epsg=32649, ... rename_basename="T2.", ... ) >>> result = pipe.run("2/2EDI", outdir="2/processed") """ from ._presets import get_preset # noqa: PLC0415 preset = get_preset(name) return cls( list(preset.steps), coord_file=coord_file, raw_dir=raw_dir, epsg=epsg, utm_zone=utm_zone, order=order, rename_basename=rename_basename, rename_dir=rename_dir, name=pipeline_name or preset.name, )
# --------------------------------------------------------------------------- # run_stratagem_preset (Level 2 — StratagemSurvey-based convenience fn) # ---------------------------------------------------------------------------
[docs] def run_stratagem_preset( preset: str, edi_dir: str | Path, coord_file: str | Path, outdir: str | Path, *, raw_dir: str | Path | None = None, epsg: int = 32649, utm_zone: str = "49N", rename_basename: str = "S", rename_dir: str | Path | None = None, step_overrides: dict[str, dict] | None = None, overwrite: bool = False, verbose: int = 0, ) -> Any: """Execute a named Stratagem preset in one call (convenience wrapper). Uses :class:`~pycsamt.stratagem.survey.StratagemSurvey` internally for the full EDI-directory + GPS-CSV workflow. For the pipeline-style API (``run(sites, outdir=...)``) use :class:`StratagemPipeline` directly. Parameters ---------- preset : {'basic', 'full_processing', 'publication_ready'} edi_dir : path-like coord_file : path-like outdir : path-like raw_dir : path-like, optional epsg : int, default 32649 utm_zone : str, default ``'49N'`` rename_basename : str, default ``'S'`` rename_dir : path-like, optional step_overrides : dict, optional Per-step parameter overrides (see :class:`StratagemPipeline`). overwrite : bool, default False verbose : int, default 0 Returns ------- :class:`~pycsamt.stratagem.survey.StratagemSurvey` The completed survey object. Examples -------- Replicate the legacy script:: sv = run_stratagem_preset( "basic", edi_dir="2/2EDI", coord_file="2.csv", outdir="2/processed", epsg=32649, rename_basename="T2.", ) With hardware files:: sv = run_stratagem_preset( "full_processing", edi_dir="2/2EDI", coord_file="2.csv", outdir="2/processed", raw_dir="原始数据/2HX", epsg=32649, rename_basename="T2.", ) """ from ..stratagem.survey import ( StratagemSurvey, # noqa: PLC0415 ) preset_cfg = get_stratagem_preset(preset) defaults = preset_cfg.survey_defaults.copy() defaults.update(epsg=epsg, utm_zone=utm_zone) sv = StratagemSurvey( edi_dir=edi_dir, coord_file=coord_file, raw_dir=raw_dir, verbose=verbose, epsg=defaults.get("epsg", epsg), utm_zone=defaults.get("utm_zone", utm_zone), ).fit() out_base = Path(outdir).expanduser().resolve() export_dir = out_base / "corrected" ren_dir = ( Path(rename_dir).expanduser().resolve() if rename_dir is not None else out_base / "renamed" ) for method_name, preset_kw in preset_cfg.steps: kw = {**preset_kw, **(step_overrides or {}).get(method_name, {})} if method_name == "export": sv.export(export_dir, overwrite=overwrite) elif method_name == "rename": sv.rename( basename=rename_basename, dst_path=ren_dir, overwrite=overwrite, ) else: method = getattr(sv, method_name, None) if method is not None: method(**kw) elif verbose: print( f"[run_stratagem_preset] unknown step {method_name!r}, skip" ) return sv