Source code for pycsamt.emtools.ztem

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0

r"""ZTEM-specific spatial-domain processing, diagnostics, and plotting.

ZTEM (Z-axis Tipper Electromagnetic; Lo and Zang 2008) is an airborne
AFMAG technique: a helicopter-towed vertical-field (:math:`H_z`) coil
is referenced to fixed-ground horizontal-field (:math:`H_x`,
:math:`H_y`) base-station coils, giving exactly the same
:math:`H_z = T_{zx}H_x + T_{zy}H_y` tipper relationship that
:class:`~pycsamt.site.base.Site` already carries as
:attr:`~pycsamt.site.base.Site.tipper` -- the equivalence
:mod:`pycsamt.emtools.afmag` documents and that
:mod:`pycsamt.airborne.ztem` encodes at the file-format/adapter layer.
This module is therefore built, like :mod:`pycsamt.emtools.afmag` and
:mod:`pycsamt.emtools.tf`, entirely on ``Site.tipper``.

It does not repeat what those two modules already do:

* :mod:`pycsamt.emtools.afmag` gives per-station, per-frequency tilt-
  angle diagnostics (:func:`~pycsamt.emtools.afmag.afmag_tilt_angles`)
  and motion-coupling QC -- purely *local* quantities, no derivative
  across stations.
* :mod:`pycsamt.emtools.tf` gives generic tipper visualization
  (induction arrows, polar plots, period sections) that applies
  equally to ground MT tipper and airborne ZTEM tipper alike.

What is missing from both, and what ZTEM survey practice specifically
relies on for a first-pass, pre-inversion read of a flight line
(Sattel and Witherly 2012), is *along-profile spatial-derivative*
processing -- comparing a station's tipper to its neighbours' rather
than looking at one station in isolation:

* :func:`total_divergence_table` -- the horizontal derivative of the
  in-line tipper component along the flight line. Lo and Zang (2008)
  define the (map-grid) "Total Divergence" as
  :math:`DT = \partial T_{zx}/\partial x + \partial T_{zy}/\partial y`
  (the literal equation is reproduced, e.g., as eq. 6 of
  wang2025). Sattel and Witherly (2012) note that "for the 2D case,
  the horizontal derivative is equivalent to the Peaker (Pedersen
  et al., 1994) and the total divergence" -- i.e. along one flight
  line the two published image products, DT and the VLF-style
  "Peaker", coincide with a single along-line derivative
  :math:`\partial T/\partial x`. Reproducing the full map-grid DT
  (needing a true 2-D grid across multiple flight lines, not a
  single :class:`~pycsamt.site.base.Sites` profile) is out of scope
  here; this function computes exactly the along-profile quantity
  the source papers show is equivalent for the 2D/profile case, and
  says so in its own docstring rather than silently overclaiming the
  3-D grid product.
* :func:`phase_rotate_table` -- the "phase-rotated response" image
  product Sattel and Witherly (2012, their Fig. 2) show converts a
  tipper crossover anomaly (over a lateral contact) into a peak
  anomaly, "obtained by reduction-to-pole (RTP) filtering and Hilbert
  transformation", noting "little difference between the RTP-filtered
  and the Hilbert-transformed profiles". This function implements the
  Hilbert-transform half of that pair directly
  (:func:`scipy.signal.hilbert`), a standard, unambiguous spatial
  analytic-signal transform.
* :func:`mask_outside_ztem_band` -- a ``Sites``-in/``Sites``-out QC
  gate (mirroring
  :func:`~pycsamt.emtools.afmag.flag_motion_susceptible_band`'s
  mask/drop contract) that reuses the survey's own published usable
  bandwidth, :attr:`pycsamt.airborne.ztem.ZTEMSystemSpec
  .practical_frequency_range_hz`, rather than inventing a new band
  definition -- the one function in this module meant to sit *inside*
  a processing pipeline rather than only produce a diagnostic table.

Karous-Hjelt (1983) pseudo-depth current-density sections and the
Becken and Pedersen (2003) tipper-gradient apparent-resistivity/phase
transform are both mentioned by Sattel and Witherly (2012) as further
image products, but neither closed-form is reproduced in the papers
available locally (``data/ZTEM/``), so neither is implemented here --
consistent with not silently converting an unverified formula into a
"working" function.

References
----------
.. [Lo2008] Lo, B., and Zang, M. (2008). Numerical modeling of Z-TEM
   (airborne AFMAG) responses to guide exploration strategies. SEG
   Expanded Abstracts, 27, 1098-1101.
.. [Legault2012] Legault, J. M., Zhao, S., and Fitch, R. (2012). ZTEM
   airborne AFMAG survey results over low sulphidation epithermal
   gold-silver vein systems at Gold Springs, south eastern Nevada.
   22nd International Geophysical Conference and Exhibition (ASEG),
   Brisbane.
.. [Sattel2012] Sattel, D., and Witherly, K. (2012). An overview of
   ZTEM data interpretation tools. 2012 NFEM Forum.
.. [Pedersen1994] Pedersen, L. B., Qian, W., Dynesius, L., and Zhang,
   P. (1994). An airborne tensor VLF system. From concept to
   realization. Geophysical Prospecting, 42, 863-883.
.. [wang2025] Wang, Y., Qu, J., Chen, T., Zhou, S., and Li, Y. (2025).
   Studies of three dimensional staggered-grid finite difference for
   Z-axis tipper electromagnetic numerical simulation. Frontiers in
   Earth Science, 13:1496312.
"""

from __future__ import annotations

import os.path
from typing import Any

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

from ..api.station import PYCSAMT_STATION_RENDERING
from ..api.style import PYCSAMT_STYLE
from ._core import (
    _apply_each,
    _axes_list,
    _get_t_block,
    _iter_items,
    _name,
    _station_positions,
    _unwrap,
    ensure_any_sites,
)

__all__ = [
    # raw crossover diagnostics
    "ztem_crossover_diagnostics",
    # spatial-domain processing (tables)
    "total_divergence_table",
    "phase_rotate_table",
    # pipeline QC (Sites in, Sites out)
    "mask_outside_ztem_band",
    # plots
    "plot_ztem_tipper_profile",
    "plot_ztem_divergence_profile",
    "plot_ztem_divergence_psection",
    "plot_ztem_divergence_psection_grid",
    "plot_ztem_phase_rotation_profile",
    "plot_ztem_band_mask_psection",
    "plot_ztem_flight_lines",
    "plot_ztem_map",
]

_DIVERGENCE_COLS = [
    "station_a",
    "station_b",
    "x_m",
    "dx_m",
    "freq_hz",
    "period_s",
    "divergence_real",
    "divergence_imag",
    "divergence_abs",
]

_PHASE_ROTATE_COLS = [
    "x_m",
    "nearest_station",
    "freq_hz",
    "period_s",
    "raw",
    "rotated",
    "envelope",
]


# ─────────────────────────────────────────────────────────────────────────
# Private helpers
# ─────────────────────────────────────────────────────────────────────────


def _build_tipper_grid(
    sites: Any,
    spacing_m: float,
    recursive: bool,
    on_dup: str,
    strict: bool,
    verbose: int,
) -> tuple[list[str], np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """Return station-ordered ``Tzx``/``Tzy`` grids for a ZTEM profile.

    Returns
    -------
    names : list of str
        Station names, ordered by position along the profile.
    x_pos : ndarray, shape (N,)
        Station chainage [m].
    freqs : ndarray, shape (F,)
        Sorted unique frequencies [Hz] across the profile.
    tzx_grid, tzy_grid : ndarray of complex, shape (N, F)
        ``NaN`` where a station has no value at that frequency.
    """
    S = ensure_any_sites(
        sites,
        recursive=recursive,
        on_dup=on_dup,
        strict=strict,
        verbose=verbose,
    )
    items = list(_iter_items(S))
    if not items:
        empty = np.zeros((0, 0), dtype=complex)
        return [], np.array([]), np.array([]), empty, empty

    names: list[str] = []
    raw_items: list[Any] = []
    freq_t: dict[str, tuple[np.ndarray, np.ndarray]] = {}
    for i, ed in enumerate(items):
        T, t, fr = _get_t_block(ed)
        if T is None or t is None or fr is None or fr.size == 0:
            continue
        nm = _name(ed, i)
        names.append(nm)
        # _station_positions reads east/north (or lat/lon) straight off
        # each object; a Sites-wrapped Site hides those behind
        # ``.edi``, so unwrap here the same way
        # gradient_imaging._build_rho_grid does for the impedance case.
        raw_items.append(_unwrap(ed))
        freq_t[nm] = (fr, t)

    if not names:
        empty = np.zeros((0, 0), dtype=complex)
        return [], np.array([]), np.array([]), empty, empty

    x_pos = _station_positions(raw_items, spacing_m=spacing_m)
    order = np.argsort(x_pos)
    names = [names[k] for k in order]
    x_pos = x_pos[order]

    all_f: set = set()
    for fr, _ in freq_t.values():
        all_f.update(fr.tolist())
    freqs = np.array(sorted(all_f), dtype=float)
    if freqs.size == 0:
        empty = np.full((len(names), 0), np.nan, dtype=complex)
        return names, x_pos, freqs, empty, empty

    f_idx = {float(f): k for k, f in enumerate(freqs)}
    tzx_grid = np.full((len(names), freqs.size), np.nan, dtype=complex)
    tzy_grid = np.full((len(names), freqs.size), np.nan, dtype=complex)
    for i, nm in enumerate(names):
        fr, t = freq_t[nm]
        for j in range(fr.size):
            k = f_idx.get(float(fr[j]))
            if k is not None:
                tzx_grid[i, k] = t[j, 0]
                tzy_grid[i, k] = t[j, 1]
    return names, x_pos, freqs, tzx_grid, tzy_grid


def _resolve_target_frequency(
    freqs: np.ndarray,
    frequency_hz: float | None,
    period_s: float | None,
) -> tuple[int, float]:
    """Return ``(index, value)`` of the frequency nearest a target."""
    if frequency_hz is not None and period_s is not None:
        raise ValueError(
            "give at most one of frequency_hz or period_s, not both"
        )
    if frequency_hz is not None:
        target = float(frequency_hz)
    elif period_s is not None:
        target = 1.0 / max(float(period_s), 1e-24)
    else:
        target = float(np.median(freqs))
    k = int(np.argmin(np.abs(freqs - target)))
    return k, float(freqs[k])


def _apply_station_rendering(
    ax: plt.Axes,
    labels: list[str],
    *,
    station_label_step: int | None = 1,
    station_preset: str = "pseudosection",
    station_style: Any | None = None,
) -> None:
    """Apply the shared top-of-section station rendering convention."""
    import copy

    style = station_style or PYCSAMT_STATION_RENDERING.style_for(
        station_preset
    )
    style = copy.copy(style)
    style.side = "top"
    style.max_labels = max(int(style.max_labels), len(labels))
    style.every = (
        1 if station_label_step is None else int(station_label_step)
    )
    x = np.arange(len(labels), dtype=float)
    style.apply(ax, x, labels, xlim=(-0.5, len(labels) - 0.5))


# ─────────────────────────────────────────────────────────────────────────
# Total divergence / Peaker (Lo and Zang 2008; Sattel and Witherly 2012)
# ─────────────────────────────────────────────────────────────────────────


[docs] def total_divergence_table( sites: Any, spacing_m: float = 200.0, *, component: str = "tzx", recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> pd.DataFrame: r"""Along-profile ZTEM total-divergence / Peaker table. Computes the along-line horizontal derivative of the selected tipper component by first-order central-in-space finite differences between adjacent stations, at every frequency: .. math:: DT(j,\,f) \approx \frac{T(j+1,\,f) - T(j,\,f)}{x(j+1) - x(j)} where stations are ordered by chainage along the profile (see :func:`~pycsamt.emtools._core._station_positions`). Per Sattel and Witherly (2012), this single along-line derivative *is* both the "Total Divergence" (Lo and Zang 2008) and the VLF-style "Peaker" (Pedersen et al. 1994) in the 2D/profile case -- the full 3-D map-grid divergence (:math:`\partial T_{zx}/\partial x + \partial T_{zy}/\partial y`) would additionally require a genuine cross-line (``y``) sampling that a single :class:`~pycsamt.site.base.Sites` profile does not carry, and is not attempted here. .. warning:: *sites* is assumed to be **one flight line**. Chainage comes from :func:`~pycsamt.emtools._core._station_positions`, which projects every station onto a single bearing; passing a multi-line survey directly differentiates across line boundaries too, producing a physically meaningless value at every line-to-line join. Pre-filter to one line first (e.g. :meth:`~pycsamt.airborne.site.AirborneSites.select` on a per-line predicate) before calling this function on a multi-line dataset -- see :func:`~pycsamt.emtools.ztem.plot_ztem_map`'s own ``quantity="divergence"`` branch for a worked example that does this per-line grouping automatically. Parameters ---------- sites : Sites-like or AirborneSites-like Anything accepted by :func:`~pycsamt.emtools._core.ensure_any_sites`: ground :class:`~pycsamt.site.base.Sites` when the data carries an impedance channel too, or :class:`~pycsamt.airborne.site.AirborneSites` for genuine tipper-only ZTEM/AFMAG data (a path/directory of EMTF-XML is routed automatically based on what it contains). spacing_m : float, default 200.0 Fall-back inter-station spacing [m] used only when no station coordinates are available; see :func:`~pycsamt.emtools._core._station_positions`. component : {"tzx", "tzy"}, default "tzx" Tipper component to differentiate. ``"tzx"`` is the classical in-line (flight-direction) choice (Legault et al. 2012); ``"tzy"`` highlights structures striking across the line (Sattel and Witherly 2012). recursive, on_dup, strict, verbose Forwarded to :func:`~pycsamt.emtools._core.ensure_any_sites`. Returns ------- pandas.DataFrame One row per (adjacent-station pair, frequency). Columns: ``station_a``, ``station_b`` (left/right station of the pair), ``x_m`` (pair midpoint chainage), ``dx_m`` (station spacing), ``freq_hz``, ``period_s``, ``divergence_real``, ``divergence_imag`` [each in units of tipper per metre], ``divergence_abs``. Pairs/frequencies with a missing tipper value on either side are omitted, not filled with zero. Raises ------ ValueError If *component* is not ``"tzx"`` or ``"tzy"``. """ component = str(component).strip().lower() if component not in ("tzx", "tzy"): raise ValueError( f"component must be 'tzx' or 'tzy'; got {component!r}" ) names, x_pos, freqs, tzx_grid, tzy_grid = _build_tipper_grid( sites, spacing_m, recursive, on_dup, strict, verbose ) grid = tzx_grid if component == "tzx" else tzy_grid n_st, n_f = grid.shape if n_st < 2 or n_f == 0: return pd.DataFrame(columns=_DIVERGENCE_COLS) dx = x_pos[1:] - x_pos[:-1] x_mid = 0.5 * (x_pos[1:] + x_pos[:-1]) with np.errstate(invalid="ignore", divide="ignore"): ddx = (grid[1:, :] - grid[:-1, :]) / dx[:, None] rows: list[dict[str, Any]] = [] for j in range(n_st - 1): for k in range(n_f): val = ddx[j, k] if not (np.isfinite(val.real) and np.isfinite(val.imag)): continue rows.append( { "station_a": names[j], "station_b": names[j + 1], "x_m": float(x_mid[j]), "dx_m": float(dx[j]), "freq_hz": float(freqs[k]), "period_s": 1.0 / max(float(freqs[k]), 1e-12), "divergence_real": float(val.real), "divergence_imag": float(val.imag), "divergence_abs": float(np.abs(val)), } ) if not rows: return pd.DataFrame(columns=_DIVERGENCE_COLS) return pd.DataFrame(rows, columns=_DIVERGENCE_COLS)
# ───────────────────────────────────────────────────────────────────────── # Phase rotation (Sattel and Witherly 2012, Fig. 2: crossover -> peak) # ─────────────────────────────────────────────────────────────────────────
[docs] def phase_rotate_table( sites: Any, *, frequency_hz: float | None = None, period_s: float | None = None, component: str = "tzx", part: str = "real", spacing_m: float = 200.0, n_resample: int | None = None, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> pd.DataFrame: r"""Hilbert-transform "phase-rotated" ZTEM profile at one frequency. Reproduces the Hilbert-transform half of the phase-rotation image product described by Sattel and Witherly (2012, Fig. 2): the tipper component's along-profile crossover anomaly (odd about the causative contact) is converted into a peak anomaly (even about it) by taking the imaginary part of its spatial analytic signal (:func:`scipy.signal.hilbert`). Because the Hilbert transform assumes uniform sampling, the selected component is first linearly interpolated onto a uniform grid along chainage; the returned table is indexed by that uniform grid (with the nearest real station attached for reference), not by the original, generally unevenly spaced, station positions. .. warning:: Like :func:`total_divergence_table`, *sites* is assumed to be one flight line: chainage is a single-bearing projection of every station, so a multi-line survey passed directly gets interpolated across line boundaries too. Pre-filter to one line first for a multi-line dataset. Parameters ---------- sites : Sites-like or AirborneSites-like Anything accepted by :func:`~pycsamt.emtools._core.ensure_any_sites`: ground :class:`~pycsamt.site.base.Sites` when the data carries an impedance channel too, or :class:`~pycsamt.airborne.site.AirborneSites` for genuine tipper-only ZTEM/AFMAG data (a path/directory of EMTF-XML is routed automatically based on what it contains). frequency_hz, period_s : float, optional Target frequency/period; the nearest available frequency is used. At most one may be given; the median frequency across the profile is used when neither is given. component : {"tzx", "tzy"}, default "tzx" part : {"real", "imag"}, default "real" Which part of the complex tipper to phase-rotate. ``"real"`` (in-phase) matches the classical VLF/ZTEM crossover-to-peak image product. spacing_m : float, default 200.0 Fall-back inter-station spacing [m]; see :func:`~pycsamt.emtools._core._station_positions`. n_resample : int, optional Number of points on the uniform resampling grid. Defaults to the number of stations with a valid value (minimum 64). recursive, on_dup, strict, verbose Forwarded to :func:`~pycsamt.emtools._core.ensure_any_sites`. Returns ------- pandas.DataFrame Columns: ``x_m`` (uniform-grid chainage), ``nearest_station``, ``freq_hz``, ``period_s``, ``raw`` (the interpolated, un- rotated component), ``rotated`` (its Hilbert transform), ``envelope`` (the analytic-signal magnitude :math:`\sqrt{raw^2 + rotated^2}`). Raises ------ ValueError If *component* is not ``"tzx"``/``"tzy"``, if *part* is not ``"real"``/``"imag"``, or if both *frequency_hz* and *period_s* are given. """ from scipy.signal import hilbert component = str(component).strip().lower() if component not in ("tzx", "tzy"): raise ValueError( f"component must be 'tzx' or 'tzy'; got {component!r}" ) part = str(part).strip().lower() if part not in ("real", "imag"): raise ValueError(f"part must be 'real' or 'imag'; got {part!r}") names, x_pos, freqs, tzx_grid, tzy_grid = _build_tipper_grid( sites, spacing_m, recursive, on_dup, strict, verbose ) grid = tzx_grid if component == "tzx" else tzy_grid if len(names) < 4 or freqs.size == 0: return pd.DataFrame(columns=_PHASE_ROTATE_COLS) k, f0 = _resolve_target_frequency(freqs, frequency_hz, period_s) col = grid[:, k] valid = np.isfinite(col.real) & np.isfinite(col.imag) if int(valid.sum()) < 4: return pd.DataFrame(columns=_PHASE_ROTATE_COLS) names_arr = np.array(names) xv = x_pos[valid] order = np.argsort(xv) xv = xv[order] yv = (col[valid].real if part == "real" else col[valid].imag)[order] nv = names_arr[valid][order] n = int(n_resample) if n_resample else max(len(xv), 64) x_uniform = np.linspace(float(xv[0]), float(xv[-1]), n) y_uniform = np.interp(x_uniform, xv, yv) analytic = hilbert(y_uniform) rotated = np.imag(analytic) envelope = np.abs(analytic) near = np.clip(np.searchsorted(xv, x_uniform), 1, xv.size - 1) prev = near - 1 use_prev = np.abs(x_uniform - xv[prev]) <= np.abs(x_uniform - xv[near]) near = np.where(use_prev, prev, near) nearest_station = nv[near] period_val = 1.0 / max(f0, 1e-12) return pd.DataFrame( { "x_m": x_uniform, "nearest_station": nearest_station, "freq_hz": np.full(n, f0), "period_s": np.full(n, period_val), "raw": y_uniform, "rotated": rotated, "envelope": envelope, }, columns=_PHASE_ROTATE_COLS, )
# ───────────────────────────────────────────────────────────────────────── # Pipeline QC: mask/drop frequencies outside the published ZTEM band # ─────────────────────────────────────────────────────────────────────────
[docs] def mask_outside_ztem_band( sites: Any, *, band_hz: tuple[float, float] | None = None, system_spec: Any | None = None, action: str = "mask", inplace: bool = False, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> Any: r"""Mask/drop tipper frequencies outside the usable ZTEM band. Reuses the published usable bandwidth already carried by :class:`pycsamt.airborne.ztem.ZTEMSystemSpec` (default ``practical_frequency_range_hz`` of 22-720 Hz) rather than inventing a new band definition. For ground ``Sites`` input, this mirrors the same ``ensure_sites`` -> ``_apply_each`` mutation contract used by :func:`~pycsamt.emtools.afmag.flag_motion_susceptible_band` and :func:`~pycsamt.emtools.remove_noise.notch_powerline`: this is the one function in this module meant to sit inside a processing pipeline (container in, container out) rather than only produce a diagnostic table. For :class:`~pycsamt.airborne.site.AirborneSites` input, ``action="drop"`` is refused (see Raises) because it would leave the ``EMTF`` document's shared period axis inconsistent with the tipper transfer function's own periods; only ``action="mask"`` is offered there, matching :func:`~pycsamt.emtools.mobilemt.mask_outside_mobilemt_band`'s identical restriction for the identical reason. Parameters ---------- sites : Sites-like or AirborneSites-like Anything accepted by :func:`~pycsamt.emtools._core.ensure_any_sites`: ground :class:`~pycsamt.site.base.Sites` when the data carries an impedance channel too, or :class:`~pycsamt.airborne.site.AirborneSites` for genuine tipper-only ZTEM/AFMAG data (a path/directory of EMTF-XML is routed automatically based on what it contains). band_hz : (float, float), optional Explicit ``(low, high)`` band in Hz. Mutually exclusive with *system_spec*; when neither is given, a default :class:`~pycsamt.airborne.ztem.ZTEMSystemSpec`'s ``practical_frequency_range_hz`` is used. system_spec : pycsamt.airborne.ztem.ZTEMSystemSpec, optional Survey-specific system metadata to read the band from. action : {"mask", "drop"}, default "mask" ``"mask"`` sets out-of-band tipper values to ``nan`` in place; ``"drop"`` removes the corresponding frequency rows entirely. inplace, recursive, on_dup, strict, verbose Standard ``emtools`` processing-function tail; see :func:`~pycsamt.emtools.afmag.flag_motion_susceptible_band` for the established convention this mirrors. Returns ------- Sites The (optionally new) sites collection with out-of-band tipper frequencies masked or dropped. Raises ------ ValueError If *action* is not ``"mask"`` or ``"drop"``; if both *band_hz* and *system_spec* are given; or if *action* is ``"drop"`` and *sites* resolves to :class:`~pycsamt.airborne.site.AirborneSites`. TypeError If *system_spec* is given and is not a :class:`~pycsamt.airborne.ztem.ZTEMSystemSpec`. """ from ..airborne.site import AirborneSites from ..airborne.ztem import ZTEMSystemSpec action = str(action).strip().lower() if action not in {"mask", "drop"}: raise ValueError("action must be 'mask' or 'drop'") if band_hz is not None and system_spec is not None: raise ValueError( "give at most one of band_hz or system_spec, not both" ) if system_spec is not None and not isinstance( system_spec, ZTEMSystemSpec ): raise TypeError("system_spec must be a ZTEMSystemSpec or None") if band_hz is not None: lo, hi = float(band_hz[0]), float(band_hz[1]) else: spec = system_spec or ZTEMSystemSpec() lo, hi = spec.practical_frequency_range_hz def _one(Si: Any) -> Any: for ed in _iter_items(Si): T, t, fr = _get_t_block(ed) if T is None or t is None: continue keep = (fr >= lo) & (fr <= hi) if keep.all(): continue if action == "mask": t[~keep, :] = np.nan + 1j * np.nan if hasattr(T, "tipper"): T.tipper = t elif hasattr(T, "T"): T.T = t else: new_t = t[keep, :] new_fr = fr[keep] for name in ("tipper", "T"): if hasattr(T, name): setattr(T, name, new_t) break if hasattr(T, "freq"): T.freq = new_fr elif hasattr(ed, "freq"): ed.freq = new_fr return Si S = ensure_any_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) if isinstance(S, AirborneSites): if action == "drop": raise ValueError( "action='drop' is not supported for AirborneSites " "(it would leave the EMTF document's shared period " "axis inconsistent with the tipper transfer " "function's own periods); use action='mask' instead" ) # AirborneSite/EMTF are plain deepcopy-able objects with no # EDICollection-style container to rebuild, so the ground # _apply_each machinery (which does exactly that rebuilding) # is neither needed nor applicable here. if not inplace: import copy S = copy.deepcopy(S) _one(S) return S return _apply_each(S, _one, inplace=inplace, verbose=verbose)
# ───────────────────────────────────────────────────────────────────────── # Raw crossover diagnostics (Legault et al. 2012, Fig. 6) # ─────────────────────────────────────────────────────────────────────────
[docs] def ztem_crossover_diagnostics( sites: Any, *, frequency_hz: float | None = None, period_s: float | None = None, component: str = "tzx", spacing_m: float = 200.0, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> dict[str, Any]: r"""Legault et al. (2012, Fig. 6)-style raw in-phase/quadrature crossover. Their own synthetic forward-model example over a mushroom-shaped epithermal target reads a negative-to-positive **in-phase** crossover directly above the target, generally accompanied by a (noisier) negative-to-positive **quadrature** crossover at every frequency -- the qualitative, single-frequency, pre-processing read of a flight line before any derivative or transform is applied (contrast :func:`total_divergence_table`/ :func:`phase_rotate_table`, both of which operate on this same raw tipper but convert the crossover into a peak). This function finds those two crossovers and reports the peak-to-peak swing of each, the same crossover/amplitude measurements :func:`~pycsamt.emtools.afmag.original_afmag_conductor_diagnostics` reports for the AFMAG comparator, applied here to the real/ imaginary parts of one tipper component instead of two hardware frequencies. .. warning:: Like :func:`total_divergence_table`, *sites* is assumed to be one flight line; pre-filter a multi-line survey to one line first (see that function's own warning for why). Parameters ---------- sites : Sites-like or AirborneSites-like Anything accepted by :func:`~pycsamt.emtools._core.ensure_any_sites`. frequency_hz, period_s : float, optional Reference frequency/period; nearest available value is used. At most one may be given; the median frequency across the profile is used when neither is given. component : {"tzx", "tzy"}, default "tzx" ``"tzx"`` is the classical in-line choice (Legault et al. 2012); ``"tzy"`` highlights cross-line structure (Sattel and Witherly 2012). spacing_m : float, default 200.0 Forwarded to :func:`~pycsamt.emtools._core._station_positions`. recursive, on_dup, strict, verbose Forwarded to :func:`~pycsamt.emtools._core.ensure_any_sites`. Returns ------- dict Keys: ``freq_hz``, ``crossover_real_m``, ``crossover_imag_m`` (along-profile position, ``nan`` if that part never changes sign between its own max and min), ``peak_to_peak_real``, ``peak_to_peak_imag``, and ``profile`` -- a :class:`pandas.DataFrame` with columns ``station``, ``position_m``, ``real``, ``imag`` (the raw, dimensionless tipper values; multiply by 100 for the percent convention Legault et al. 2012 and Sattel and Witherly 2012 both plot). Raises ------ ValueError If *component* is not ``"tzx"``/``"tzy"``, or fewer than 2 stations have a usable value at the resolved frequency. """ component = str(component).strip().lower() if component not in ("tzx", "tzy"): raise ValueError( f"component must be 'tzx' or 'tzy'; got {component!r}" ) names, x_pos, freqs, tzx_grid, tzy_grid = _build_tipper_grid( sites, spacing_m, recursive, on_dup, strict, verbose ) grid = tzx_grid if component == "tzx" else tzy_grid if len(names) < 2 or freqs.size == 0: raise ValueError( "ztem_crossover_diagnostics requires at least 2 stations " "with tipper data" ) k, f0 = _resolve_target_frequency(freqs, frequency_hz, period_s) col = grid[:, k] valid = np.isfinite(col.real) & np.isfinite(col.imag) if int(valid.sum()) < 2: raise ValueError( "ztem_crossover_diagnostics: fewer than 2 stations have a " f"usable value at {f0:.4g} Hz" ) names_arr = np.array(names)[valid] xv = x_pos[valid] order = np.argsort(xv) xv = xv[order] names_arr = names_arr[order] re = col[valid].real[order] im = col[valid].imag[order] profile = pd.DataFrame( { "station": names_arr, "position_m": xv, "real": re, "imag": im, } ) def _crossover(x: np.ndarray, y: np.ndarray) -> float: if x.size < 2: return float("nan") i_max, i_min = int(np.argmax(y)), int(np.argmin(y)) lo, hi = (i_max, i_min) if i_max < i_min else (i_min, i_max) for i in range(lo, hi): y0, y1 = y[i], y[i + 1] if y0 == 0.0: return float(x[i]) if np.sign(y0) != np.sign(y1): t = y0 / (y0 - y1) return float(x[i] + t * (x[i + 1] - x[i])) return float("nan") return { "freq_hz": f0, "crossover_real_m": _crossover(xv, re), "crossover_imag_m": _crossover(xv, im), "peak_to_peak_real": float(np.max(re) - np.min(re)), "peak_to_peak_imag": float(np.max(im) - np.min(im)), "profile": profile, }
# ───────────────────────────────────────────────────────────────────────── # Plots # ─────────────────────────────────────────────────────────────────────────
[docs] def plot_ztem_tipper_profile( sites: Any, *, frequency_hz: float | None = None, period_s: float | None = None, component: str = "tzx", as_percent: bool = True, figsize: tuple[float, float] = (9.5, 4.2), recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ax: plt.Axes | None = None, ) -> plt.Axes: r"""Legault et al. (2012, Fig. 6)-style raw in-phase/quadrature profile. The classic ZTEM field-presentation figure: real (in-phase) and imaginary (quadrature) tipper plotted together at one frequency, in percent, along real flight-line chainage -- Fig. 6's own "METERS" x-axis, not a discrete station index -- with each part's crossover marked; see :func:`ztem_crossover_diagnostics`. Parameters ---------- sites : Sites-like or AirborneSites-like Anything accepted by :func:`~pycsamt.emtools._core.ensure_any_sites`. frequency_hz, period_s, component Forwarded to :func:`ztem_crossover_diagnostics`. as_percent : bool, default True Multiply the plotted tipper values by 100, matching the percent convention both cited papers use. Set ``False`` to plot the raw dimensionless tipper instead. figsize : (float, float), default (9.5, 4.2) Used only when *ax* is not supplied. recursive, on_dup, strict, verbose Forwarded to :func:`ztem_crossover_diagnostics`. ax : matplotlib.axes.Axes, optional Existing axes to draw on. Returns ------- matplotlib.axes.Axes """ if ax is None: _, ax = plt.subplots(figsize=figsize) try: diag = ztem_crossover_diagnostics( sites, frequency_hz=frequency_hz, period_s=period_s, component=component, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) except ValueError: ax.text(0.5, 0.5, "no tipper data", ha="center", va="center") return ax profile = diag["profile"] scale = 100.0 if as_percent else 1.0 x = profile["position_m"].to_numpy() ax.axhline(0.0, color="0.8", lw=0.8) ax.plot( x, profile["real"] * scale, "-", color="tab:blue", marker="o", ms=4, label="in-phase", ) ax.plot( x, profile["imag"] * scale, "--", color="tab:red", marker="x", ms=5, label="quadrature", ) for cross, color in ( (diag["crossover_real_m"], "tab:blue"), (diag["crossover_imag_m"], "tab:red"), ): if np.isfinite(cross): ax.axvline(cross, color=color, lw=1.0, ls=":", alpha=0.7) ax.set_xlabel("Position along flight line (m)") tlabel = r"$T_{zx}$" if component == "tzx" else r"$T_{zy}$" ax.set_ylabel(f"{tlabel} ({'%' if as_percent else 'dimensionless'})") ax.set_title( f"ZTEM raw in-phase/quadrature profile at {diag['freq_hz']:.4g} Hz", fontsize=10, ) ax.legend(fontsize=8, loc="best") ax.grid(True, ls=":", alpha=0.35) return ax
[docs] def plot_ztem_divergence_profile( sites: Any, *, component: str = "tzx", part: str = "real", frequency_hz: float | None = None, period_s: float | None = None, spacing_m: float = 200.0, figsize: tuple[float, float] = (9.5, 4.0), station_label_step: int | None = 1, station_preset: str = "pseudosection", station_style: Any | None = None, ax: plt.Axes | None = None, ) -> plt.Axes: r"""Plot the ZTEM total-divergence / Peaker flight-line profile. One value per adjacent-station pair at a single reference frequency/period -- the along-line, pre-gridding form of the "Total Divergence" / "Peaker" image product (see :func:`total_divergence_table`). Parameters ---------- sites : Sites-like or AirborneSites-like Anything accepted by :func:`~pycsamt.emtools._core.ensure_any_sites`: ground :class:`~pycsamt.site.base.Sites` when the data carries an impedance channel too, or :class:`~pycsamt.airborne.site.AirborneSites` for genuine tipper-only ZTEM/AFMAG data (a path/directory of EMTF-XML is routed automatically based on what it contains). component : {"tzx", "tzy"}, default "tzx" part : {"real", "imag"}, default "real" frequency_hz, period_s : float, optional Reference frequency/period; nearest available value is used per station pair. At most one may be given; the median frequency across all pairs is used when neither is given. spacing_m : float, default 200.0 Forwarded to :func:`total_divergence_table`. figsize : (float, float), default (9.5, 4.0) Used only when *ax* is not supplied. station_label_step, station_preset, station_style Forwarded to :meth:`~pycsamt.api.station.PyCSAMTStationRendering.style_for` via the shared top-of-section station convention. ax : matplotlib.axes.Axes, optional Existing axes to draw on. Returns ------- matplotlib.axes.Axes """ df = total_divergence_table(sites, spacing_m, component=component) if ax is None: _, ax = plt.subplots(figsize=figsize) if df.empty: ax.text(0.5, 0.5, "no tipper data", ha="center", va="center") return ax col = "divergence_real" if part == "real" else "divergence_imag" if frequency_hz is None and period_s is not None: frequency_hz = 1.0 / max(float(period_s), 1e-24) if frequency_hz is None: frequency_hz = float(df["freq_hz"].median()) pairs = list(dict.fromkeys(zip(df["station_a"], df["station_b"]))) values: list[float] = [] used_freqs: list[float] = [] for a, b in pairs: sub = df[(df["station_a"] == a) & (df["station_b"] == b)] idx = (sub["freq_hz"] - frequency_hz).abs().idxmin() values.append(float(sub.loc[idx, col])) used_freqs.append(float(sub.loc[idx, "freq_hz"])) labels = [a for a, _ in pairs] x = np.arange(len(pairs)) _ml = PYCSAMT_STYLE.multiline ax.axhline(0.0, color="0.8", lw=0.8) ax.plot( x, values, lw=_ml.lw, alpha=_ml.alpha, marker="o", color="tab:blue" ) _apply_station_rendering( ax, labels, station_label_step=station_label_step, station_preset=station_preset, station_style=station_style, ) ref = float(np.nanmedian(used_freqs)) tlabel = r"$T_{zx}$" if component == "tzx" else r"$T_{zy}$" ax.set_ylabel(rf"$\partial${tlabel}$/\partial x$ [{part}] (m$^{{-1}}$)") ax.set_title( f"ZTEM total divergence profile at {ref:.4g} Hz", fontsize=10 ) ax.grid(True, ls=":", alpha=0.35) return ax
def _divergence_grid( line_items: Any, component: str, part: str, spacing_m: float, ) -> tuple[np.ndarray, list[str], np.ndarray]: """Return ``(grid, pair_labels, freqs)`` for one flight line's total-divergence pseudosection, shared by :func:`plot_ztem_divergence_psection` and :func:`plot_ztem_divergence_psection_grid`.""" df = total_divergence_table(line_items, spacing_m, component=component) if df.empty: return np.zeros((0, 0)), [], np.zeros(0) col = "divergence_real" if part == "real" else "divergence_imag" pairs = list(dict.fromkeys(zip(df["station_a"], df["station_b"]))) labels = [a for a, _ in pairs] freqs = np.sort(df["freq_hz"].unique())[::-1] grid = np.full((freqs.size, len(pairs)), np.nan, dtype=float) pair_idx = {p: j for j, p in enumerate(pairs)} freq_idx = {float(f): i for i, f in enumerate(freqs)} for _, row in df.iterrows(): j = pair_idx.get((row["station_a"], row["station_b"])) i = freq_idx.get(float(row["freq_hz"])) if i is not None and j is not None: grid[i, j] = row[col] return grid, labels, freqs def _draw_divergence_psection( ax: plt.Axes, grid: np.ndarray, labels: list[str], freqs: np.ndarray, *, cmap: str, vmin: float, vmax: float, show_grid: bool, show_contour: bool, n_contour_levels: int, station_label_step: int | None, station_preset: str, station_style: Any | None, ) -> Any: """Draw one divergence pseudosection panel (no colorbar of its own); returns the ``imshow`` mappable so callers can attach a shared or per-panel colorbar. Factored out of :func:`plot_ztem_divergence_psection` so :func:`plot_ztem_divergence_psection_grid` can reuse it verbatim for every panel with one shared colour scale.""" from ..api.labels import LOG10_PERIOD_LABEL extent = ( -0.5, len(labels) - 0.5, np.log10(1.0 / freqs[0]), np.log10(1.0 / freqs[-1]), ) im = ax.imshow( grid, aspect="auto", origin="upper", interpolation="nearest", cmap=cmap, vmin=vmin, vmax=vmax, extent=extent, ) ax.set_ylabel(LOG10_PERIOD_LABEL) nf, ns = grid.shape y_top, y_bottom = extent[3], extent[2] dy = (y_top - y_bottom) / nf y_centers = y_top - (np.arange(nf) + 0.5) * dy if show_grid: ax.set_xticks(np.arange(-0.5, ns, 1.0), minor=True) ax.set_yticks(y_top - np.arange(nf + 1) * dy, minor=True) ax.grid(which="minor", color="0.25", linewidth=0.6, alpha=0.5) ax.tick_params(which="minor", length=0) if show_contour and n_contour_levels >= 3 and nf >= 2 and ns >= 2: finite = grid[np.isfinite(grid)] if finite.size: levels = np.linspace(vmin, vmax, n_contour_levels)[1:-1] if levels.size: xx, yy = np.meshgrid(np.arange(ns), y_centers) masked = np.ma.masked_invalid(grid) cs = ax.contour( xx, yy, masked, levels=levels, colors="black", linewidths=0.8, ) ax.clabel(cs, fmt="%.1g", fontsize=6, inline=True) _apply_station_rendering( ax, labels, station_label_step=station_label_step, station_preset=station_preset, station_style=station_style, ) return im
[docs] def plot_ztem_divergence_psection( sites: Any, *, component: str = "tzx", part: str = "real", spacing_m: float = 200.0, cmap: str = "RdBu_r", clim: tuple[float, float] | None = None, clim_pct: float = 95.0, show_grid: bool = True, show_contour: bool = True, n_contour_levels: int = 3, figsize: tuple[float, float] = (9.0, 5.0), station_label_step: int | None = 1, station_preset: str = "pseudosection", station_style: Any | None = None, ax: plt.Axes | None = None, ) -> plt.Axes: r"""Plot a ZTEM total-divergence pseudosection (station x log-period). A diverging, zero-centred colour scale is used, matching the physical sign convention of a spatial derivative (positive on one side of an anomaly, negative on the other -- see :func:`total_divergence_table`). Optional cell-boundary gridlines and a contour overlay (default ``n_contour_levels=3``, one interior level -- here the physically meaningful zero-divergence line itself) match the same ``imshow``/``contour`` convention used by :func:`~pycsamt.emtools.afmag.plot_airmt_tilt_psection` and :mod:`pycsamt.emtools.fieldzone`'s own pseudosections. For several flight lines compared side by side on one shared colour scale, see :func:`plot_ztem_divergence_psection_grid`. Parameters ---------- sites : Sites-like or AirborneSites-like Anything accepted by :func:`~pycsamt.emtools._core.ensure_any_sites`: ground :class:`~pycsamt.site.base.Sites` when the data carries an impedance channel too, or :class:`~pycsamt.airborne.site.AirborneSites` for genuine tipper-only ZTEM/AFMAG data (a path/directory of EMTF-XML is routed automatically based on what it contains). component : {"tzx", "tzy"}, default "tzx" part : {"real", "imag"}, default "real" spacing_m : float, default 200.0 Forwarded to :func:`total_divergence_table`. cmap : str, default "RdBu_r" Diverging colormap name. clim : (float, float), optional Explicit, zero-centred color limits; overrides *clim_pct*. clim_pct : float, default 95.0 Percentile of ``|divergence|`` used to size a symmetric colour range when *clim* is not given. show_grid : bool, default True Draw thin gridlines at every station-pair/period cell boundary. show_contour : bool, default True Overlay *n_contour_levels* - 2 evenly-spaced contour lines with inline labels; with the default zero-centred colour scale and 3 levels, the single interior level drawn is the zero-divergence contour itself, i.e. the crossover/conductor axis at every period simultaneously. n_contour_levels : int, default 3 Number of evenly-spaced levels spanning *clim* before dropping the two outermost; must be at least 3 for any line to be drawn. Kept low deliberately -- a coarse station/period grid does not support many contour levels without the lines tangling into visual noise (see :func:`~pycsamt.emtools.afmag.plot_airmt_tilt_psection`'s docstring for the same reasoning). figsize : (float, float), default (9.0, 5.0) Used only when *ax* is not supplied. station_label_step, station_preset, station_style See :func:`plot_ztem_divergence_profile`. ax : matplotlib.axes.Axes, optional Existing axes to draw on. Returns ------- matplotlib.axes.Axes """ from ..api.plot import add_colorbar grid, labels, freqs = _divergence_grid(sites, component, part, spacing_m) if ax is None: _, ax = plt.subplots(figsize=figsize) if grid.size == 0: ax.text(0.5, 0.5, "no tipper data", ha="center", va="center") return ax if clim is None: finite = np.abs(grid[np.isfinite(grid)]) vabs = float(np.percentile(finite, clim_pct)) if finite.size else 1.0 vabs = max(vabs, 1e-12) clim = (-vabs, vabs) vmin, vmax = clim im = _draw_divergence_psection( ax, grid, labels, freqs, cmap=cmap, vmin=vmin, vmax=vmax, show_grid=show_grid, show_contour=show_contour, n_contour_levels=n_contour_levels, station_label_step=station_label_step, station_preset=station_preset, station_style=station_style, ) tlabel = "Tzx" if component == "tzx" else "Tzy" ax.set_title( f"ZTEM total divergence pseudosection [{tlabel}, {part}]", fontsize=10, ) add_colorbar(im, ax, label=r"$\partial T/\partial x$ (m$^{-1}$)") return ax
[docs] def plot_ztem_divergence_psection_grid( sites: Any, *, component: str = "tzx", part: str = "real", spacing_m: float = 200.0, max_lines: int = 6, n_cols: int = 3, cmap: str = "seismic", clim: tuple[float, float] | None = None, clim_pct: float = 95.0, show_grid: bool = True, show_contour: bool = True, n_contour_levels: int = 3, panel_size: tuple[float, float] = (4.3, 3.4), station_label_step: int | None = 2, station_preset: str = "pseudosection", station_style: Any | None = None, axes: Any | None = None, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> plt.Figure: r"""Compare several flight lines' divergence pseudosections at once. Every panel shares one colour scale (unlike calling :func:`plot_ztem_divergence_psection` once per line, where each panel would size its own), so colour differences between lines are directly comparable -- the multi-line counterpart of a single :func:`plot_ztem_divergence_psection` call, laid out on a grid with *n_cols* columns. Flight lines are detected from station geometry (see :func:`_detect_line_groups`); when more than *max_lines* are found, a spatially even subset is kept rather than just the first *max_lines* encountered. Parameters ---------- sites : Sites-like or AirborneSites-like Anything accepted by :func:`~pycsamt.emtools._core.ensure_any_sites`, spanning several flight lines. component, part, spacing_m Forwarded to :func:`total_divergence_table` for every line. max_lines : int, default 6 Maximum number of lines to draw. n_cols : int, default 3 Number of grid columns; rows are added as needed. cmap : str, default "seismic" Diverging colormap name. clim : (float, float), optional Explicit, zero-centred color limits shared by every panel; overrides *clim_pct*. clim_pct : float, default 95.0 Percentile of ``|divergence|``, pooled across every drawn line, used to size the shared symmetric colour range when *clim* is not given. show_grid, show_contour, n_contour_levels See :func:`plot_ztem_divergence_psection`. panel_size : (float, float), default (4.3, 3.4) Per-panel figure size in inches; the full figure scales with the number of rows/columns actually used. Ignored when *axes* is supplied. station_label_step, station_preset, station_style See :func:`plot_ztem_divergence_profile`. axes : sequence of Axes, optional Existing axes to draw the (up to *max_lines*) panels on, flattened in the same row-major order the auto-created grid would use; must provide at least as many axes as lines are actually drawn. When not given, a new figure and grid of axes is created. recursive, on_dup, strict, verbose Forwarded to :func:`~pycsamt.emtools._core.ensure_any_sites`. Returns ------- matplotlib.Figure """ from matplotlib.ticker import ScalarFormatter S = ensure_any_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) items, lon, lat = _station_lonlat(S) if not items: fig, ax = plt.subplots(figsize=panel_size) ax.text(0.5, 0.5, "no station coordinates", ha="center", va="center") return fig groups = _detect_line_groups(lon, lat) group_ids = sorted(set(groups.tolist())) if len(group_ids) > max_lines: pick = np.linspace(0, len(group_ids) - 1, max_lines) pick = sorted(set(int(round(p)) for p in pick)) group_ids = [group_ids[i] for i in pick] panels = [] for g in group_ids: idx = np.where(groups == g)[0] if idx.size < 2: continue line_items = [items[i] for i in idx] grid, labels, freqs = _divergence_grid( line_items, component, part, spacing_m, ) if grid.size == 0: continue doc = getattr(line_items[0], "emtf", None) line_name = None if doc is not None: line_name = ( doc.metadata.get("notes", {}) .get("ZTEM", {}) .get("LineId") ) panels.append((line_name or f"Line {g}", grid, labels, freqs)) if not panels: fig, ax = plt.subplots(figsize=panel_size) ax.text(0.5, 0.5, "no tipper data", ha="center", va="center") return fig if clim is None: pooled = np.concatenate( [np.abs(g[np.isfinite(g)]).ravel() for _, g, _, _ in panels] ) vabs = float(np.percentile(pooled, clim_pct)) if pooled.size else 1.0 vabs = max(vabs, 1e-12) clim = (-vabs, vabs) vmin, vmax = clim n_panels = len(panels) n_cols_eff = max(1, min(n_cols, n_panels)) n_rows = int(np.ceil(n_panels / n_cols_eff)) axes_given = _axes_list(axes, n_panels) if axes is not None else None if axes_given is None: fig, axes_arr = plt.subplots( n_rows, n_cols_eff, figsize=(panel_size[0] * n_cols_eff, panel_size[1] * n_rows), sharey=True, squeeze=False, gridspec_kw={"hspace": 1.05, "wspace": 0.25}, ) axes_flat = axes_arr.ravel() else: axes_flat = np.asarray(axes_given, dtype=object) fig = axes_flat[0].figure im = None for k, (name, grid, labels, freqs) in enumerate(panels): ax = axes_flat[k] im = _draw_divergence_psection( ax, grid, labels, freqs, cmap=cmap, vmin=vmin, vmax=vmax, show_grid=show_grid, show_contour=show_contour, n_contour_levels=n_contour_levels, station_label_step=station_label_step, station_preset=station_preset, station_style=station_style, ) # extra pad pushes the title clear of the top-side station # ticks/labels _apply_station_rendering already draws there. ax.set_title(str(name), fontsize=9, pad=26) if k % n_cols_eff != 0: ax.set_ylabel("") for k in range(n_panels, axes_flat.size): axes_flat[k].axis("off") tlabel = "Tzx" if component == "tzx" else "Tzy" fig.text( 0.5, 1.0 + 0.10 / n_rows, f"ZTEM total divergence pseudosections [{tlabel}, {part}]" f" -- {n_panels} lines", fontsize=11, ha="center", ) fmt = ScalarFormatter(useMathText=True) fmt.set_powerlimits((-1, 1)) cbar = fig.colorbar( im, ax=axes_flat[:n_panels].tolist(), format=fmt, shrink=0.85, pad=0.02, ) cbar.set_label(r"$\partial T/\partial x$ (m$^{-1}$)") return fig
[docs] def plot_ztem_phase_rotation_profile( sites: Any, *, component: str = "tzx", part: str = "real", frequency_hz: float | None = None, period_s: float | None = None, spacing_m: float = 200.0, n_resample: int | None = None, figsize: tuple[float, float] = (9.5, 4.2), recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ax: plt.Axes | None = None, ) -> plt.Axes: r"""Plot raw vs. Hilbert-phase-rotated ZTEM response at one frequency. Direct reproduction of the crossover-to-peak comparison in Sattel and Witherly (2012, Fig. 2); see :func:`phase_rotate_table`. Parameters ---------- sites : Sites-like or AirborneSites-like Anything accepted by :func:`~pycsamt.emtools._core.ensure_any_sites`: ground :class:`~pycsamt.site.base.Sites` when the data carries an impedance channel too, or :class:`~pycsamt.airborne.site.AirborneSites` for genuine tipper-only ZTEM/AFMAG data (a path/directory of EMTF-XML is routed automatically based on what it contains). component, part, frequency_hz, period_s, spacing_m, n_resample Forwarded to :func:`phase_rotate_table`. figsize : (float, float), default (9.5, 4.2) Used only when *ax* is not supplied. recursive, on_dup, strict, verbose Forwarded to :func:`phase_rotate_table`. ax : matplotlib.axes.Axes, optional Existing axes to draw on. Returns ------- matplotlib.axes.Axes """ df = phase_rotate_table( sites, frequency_hz=frequency_hz, period_s=period_s, component=component, part=part, spacing_m=spacing_m, n_resample=n_resample, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) if ax is None: _, ax = plt.subplots(figsize=figsize) if df.empty: ax.text(0.5, 0.5, "no tipper data", ha="center", va="center") return ax ax.axhline(0.0, color="0.8", lw=0.8) ax.plot( df["x_m"], df["raw"], lw=1.6, color="tab:blue", label="raw (crossover)", ) ax.plot( df["x_m"], df["rotated"], lw=1.6, color="tab:red", ls="--", label="Hilbert-rotated (peak)", ) ax.plot( df["x_m"], df["envelope"], lw=1.2, color="0.35", ls=":", label="envelope", ) ax.set_xlabel("Position along profile (m)") tlabel = r"$T_{zx}$" if component == "tzx" else r"$T_{zy}$" ax.set_ylabel(f"{tlabel} [{part}]") f0 = float(df["freq_hz"].iloc[0]) ax.set_title( f"ZTEM phase-rotated response at {f0:.4g} Hz", fontsize=10 ) ax.legend(fontsize=8, framealpha=0.85) ax.grid(True, ls=":", alpha=0.3) return ax
[docs] def plot_ztem_band_mask_psection( sites: Any, *, band_hz: tuple[float, float] | None = None, system_spec: Any | None = None, component: str = "abs", cmap: str = "RdBu_r", figsize: tuple[float, float] = (9.5, 8.0), axes: Any | None = None, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> plt.Figure: r"""Plot before/after :math:`|T|` pseudosections around the ZTEM usable band. Reuses :func:`~pycsamt.emtools.tf.plot_induction_section` for both panels rather than re-implementing pseudosection gridding, and :func:`mask_outside_ztem_band` to compute the "after" sites. Parameters ---------- sites : Sites-like Anything accepted by :func:`~pycsamt.emtools._core.ensure_any_sites`: ground :class:`~pycsamt.site.base.Sites` or tipper-only :class:`~pycsamt.airborne.site.AirborneSites` transparently, since :func:`~pycsamt.emtools.tf.plot_induction_section` now accepts both. band_hz, system_spec Forwarded to :func:`mask_outside_ztem_band`. component : {"real", "imag", "abs"}, default "abs" Forwarded to :func:`~pycsamt.emtools.tf.plot_induction_section`. cmap : str, default "RdBu_r" figsize : (float, float), default (9.5, 8.0) Used only when *axes* is not supplied. axes : sequence of 2 Axes, optional Existing axes (before, after) to draw on. recursive, on_dup, strict, verbose Forwarded to :func:`~pycsamt.emtools._core.ensure_any_sites` and :func:`mask_outside_ztem_band`. Returns ------- matplotlib.Figure """ from .tf import plot_induction_section before = ensure_any_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) after = mask_outside_ztem_band( before, band_hz=band_hz, system_spec=system_spec, action="mask", inplace=False, ) axes_given = _axes_list(axes, 2) if axes is not None else None if axes_given is None: fig, axes_arr = plt.subplots( 2, 1, figsize=figsize, sharex=True, gridspec_kw={"hspace": 0.3}, ) else: axes_arr = np.asarray(axes_given, dtype=object) fig = axes_arr[0].figure plot_induction_section( before, component=component, cmap=cmap, ax=axes_arr[0], title="Before ZTEM band mask", ) plot_induction_section( after, component=component, cmap=cmap, ax=axes_arr[1], title="After ZTEM band mask", ) return fig
# ───────────────────────────────────────────────────────────────────────── # Map view (Legault et al. 2012, Fig. 7; Sattel and Witherly 2012, Fig. 7-11) # ───────────────────────────────────────────────────────────────────────── def _station_lonlat(sites: Any) -> tuple[list[Any], np.ndarray, np.ndarray]: """Return ``(items, lon, lat)`` for every site with finite coords.""" items = [] lon: list[float] = [] lat: list[float] = [] for ed in _iter_items(ensure_any_sites(sites)): coords = getattr(ed, "coords", None) if coords is None: continue try: la, lo = float(coords[0]), float(coords[1]) except (TypeError, ValueError, IndexError): continue if np.isfinite(la) and np.isfinite(lo): items.append(ed) lon.append(lo) lat.append(la) return items, np.asarray(lon), np.asarray(lat) def _detect_line_groups( lon: np.ndarray, lat: np.ndarray, n_bins: int = 50 ) -> np.ndarray: r"""Group stations into flight lines from their (lon, lat) alone. A parallel, axis-aligned block survey has one coordinate that stays nearly constant *within* a line and jumps *between* lines (the cross-line axis) and another that varies continuously along each line (the along-line axis). This picks whichever of longitude/latitude clusters into fewer, coarser groups after binning at roughly ``span / n_bins`` and uses that as the cross-line axis. It is a real, general heuristic for this common survey geometry, not a full arbitrary-azimuth line detector. Returns ------- numpy.ndarray of int One line-group index per input station, in encounter order within each detected group. """ if lon.size == 0: return np.zeros(0, dtype=int) def _groups(coord: np.ndarray) -> np.ndarray: span = float(np.ptp(coord)) if coord.size > 1 else 0.0 tol = max(span / max(n_bins, 1), 1e-9) rounded = np.round(coord / tol) * tol uniq = sorted(set(rounded.tolist())) idx = {v: i for i, v in enumerate(uniq)} return np.array([idx[v] for v in rounded.tolist()]) g_lat = _groups(lat) g_lon = _groups(lon) return g_lat if g_lat.max(initial=0) <= g_lon.max(initial=0) else g_lon
[docs] def plot_ztem_flight_lines( sites: Any, *, figsize: tuple[float, float] = (7.0, 6.0), recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ax: plt.Axes | None = None, ) -> plt.Axes: r"""Plot a Sattel and Witherly (2012, Fig. 7)-style flight-line map. Every detected flight line (see :func:`_detect_line_groups`) is drawn as its own connected navigation trace, coloured distinctly (a ``viridis`` sample per line) and labelled near its first station -- with the real flight-line identifier when a technology note carries one (e.g. ZTEM's own ``metadata["notes"]["ZTEM"]["LineId"]``), or else a generic ``L1``, ``L2``, ... in detected-group order, which need not match any real line numbering -- with station markers. The plan-view counterpart of every other function in this module, which reads a single profile at a time. Longitude tick labels are rotated 45 degrees to avoid overlapping. Parameters ---------- sites : Sites-like or AirborneSites-like Anything accepted by :func:`~pycsamt.emtools._core.ensure_any_sites`. figsize : (float, float), default (7.0, 6.0) Used only when *ax* is not supplied. recursive, on_dup, strict, verbose Forwarded to :func:`~pycsamt.emtools._core.ensure_any_sites`. ax : matplotlib.axes.Axes, optional Existing axes to draw on. Returns ------- matplotlib.axes.Axes """ S = ensure_any_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) items, lon, lat = _station_lonlat(S) if ax is None: _, ax = plt.subplots(figsize=figsize) if lon.size == 0: ax.text(0.5, 0.5, "no station coordinates", ha="center", va="center") return ax groups = _detect_line_groups(lon, lat) group_ids = sorted(set(groups.tolist())) line_cmap = plt.get_cmap("viridis", max(len(group_ids), 1)) # Resolve every line's display label first, in a separate pass, # so a common prefix shared by every real flight-line identifier # (e.g. "gold_springs_nv_L" in "gold_springs_nv_L1" .. # "gold_springs_nv_L7") can be stripped before drawing -- the # full identifier is unique but far too wide to label a compact # map with, and every line sharing the same survey prefix carries # no distinguishing information anyway. orders: list[np.ndarray] = [] labels: list[str] = [] for g in group_ids: idx = np.where(groups == g)[0] order = ( idx[np.argsort(lon[idx])] if np.ptp(lon[idx]) >= np.ptp(lat[idx]) else idx[np.argsort(lat[idx])] ) orders.append(order) doc = getattr(items[idx[0]], "emtf", None) line_name = ( doc.metadata.get("notes", {}).get("ZTEM", {}).get("LineId") if doc is not None else None ) labels.append(line_name or f"L{len(labels) + 1}") if len(labels) > 1: prefix = os.path.commonprefix(labels) # only strip up to the last safe boundary, and only if doing # so still leaves every label non-empty and distinguishable cut = max(prefix.rfind("_"), prefix.rfind("-")) + 1 if cut > 0 and all(len(label) > cut for label in labels): labels = [label[cut:] for label in labels] for k, order in enumerate(orders): gx, gy = lon[order], lat[order] color = line_cmap(k) ax.plot(gx, gy, "-", color=color, lw=1.4, zorder=1) ax.scatter( gx, gy, s=26, color=color, edgecolors="white", linewidths=0.4, zorder=2, ) # label each line once, just past its first station, alternating # sides so consecutive close-together lines don't collide dx = (float(np.ptp(lon)) or 1.0) * 0.015 ha = "left" if k % 2 == 0 else "right" label_x = gx[0] + dx if ha == "left" else gx[0] - dx ax.text( label_x, gy[0], labels[k], ha=ha, va="center", fontsize=8, fontweight="bold", color=color, ) ax.margins(x=0.10) ax.set_xlabel("Longitude") ax.set_ylabel("Latitude") ax.set_title( f"ZTEM flight lines ({len(group_ids)} lines, {len(items)} stations)", fontsize=10, ) ax.ticklabel_format(useOffset=False, style="plain") plt.setp(ax.get_xticklabels(), rotation=45, ha="right") ax.grid(True, ls=":", alpha=0.3) return ax
[docs] def plot_ztem_map( sites: Any, *, quantity: str = "tipper", part: str = "real", component: str = "tzx", frequency_hz: float | None = None, period_s: float | None = None, n_grid: int = 120, cmap: str = "RdBu_r", clim: tuple[float, float] | None = None, clim_pct: float = 95.0, show_stations: bool = True, figsize: tuple[float, float] = (8.0, 6.5), recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ax: plt.Axes | None = None, ) -> plt.Axes: r"""Legault et al. (2012, Fig. 7) and Sattel and Witherly (2012, Fig. 8-11)-style map-view grid. Interpolates one scalar field at one frequency across every flight line in *sites* onto a regular map grid (:func:`scipy.interpolate.griddata`, linear inside the convex hull of the stations, unfilled -- left ``nan`` -- outside it rather than extrapolated) and images it with a diverging, zero-centred colour scale -- the genuine multi-line map product both papers show (their "DT map"/"XIP grid"/"phase-rotated grid"), as opposed to every other function in this module, which reads one flight line as a profile or pseudosection. Parameters ---------- sites : Sites-like or AirborneSites-like Anything accepted by :func:`~pycsamt.emtools._core.ensure_any_sites`. A genuine map needs several roughly-parallel flight lines; a single line still renders, as a thin interpolated strip along it. quantity : {"tipper", "divergence"}, default "tipper" ``"tipper"`` images the raw, un-processed tipper component (Legault et al. 2012, Fig. 7's own "In-Phase" map); ``"divergence"`` images the along-line total-divergence / Peaker value (:func:`total_divergence_table`) at each station's own flight line, matching Sattel and Witherly (2012)'s "DT" grid. part : {"real", "imag"}, default "real" component : {"tzx", "tzy"}, default "tzx" frequency_hz, period_s : float, optional Reference frequency/period; nearest available value is used per station. At most one may be given; the median frequency is used when neither is given. n_grid : int, default 120 Number of grid points along the longer map axis; the shorter axis is scaled to preserve the survey's aspect ratio. cmap : str, default "RdBu_r" clim : (float, float), optional Explicit, zero-centred color limits; overrides *clim_pct*. clim_pct : float, default 95.0 Percentile of ``|value|`` used to size a symmetric colour range when *clim* is not given. show_stations : bool, default True Overlay the actual station positions as small markers. figsize : (float, float), default (8.0, 6.5) Used only when *ax* is not supplied. recursive, on_dup, strict, verbose Forwarded to :func:`~pycsamt.emtools._core.ensure_any_sites`. ax : matplotlib.axes.Axes, optional Existing axes to draw on. Returns ------- matplotlib.axes.Axes Raises ------ ValueError If *quantity* is not ``"tipper"``/``"divergence"``, or *part*/*component* is invalid. """ from scipy.interpolate import griddata quantity = str(quantity).strip().lower() if quantity not in ("tipper", "divergence"): raise ValueError( f"quantity must be 'tipper' or 'divergence'; got {quantity!r}" ) component = str(component).strip().lower() if component not in ("tzx", "tzy"): raise ValueError( f"component must be 'tzx' or 'tzy'; got {component!r}" ) part = str(part).strip().lower() if part not in ("real", "imag"): raise ValueError(f"part must be 'real' or 'imag'; got {part!r}") S = ensure_any_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) if ax is None: _, ax = plt.subplots(figsize=figsize) items, lon, lat = _station_lonlat(S) if items: groups = _detect_line_groups(lon, lat) else: groups = np.zeros(0, dtype=int) if quantity == "tipper": values_l: list[float] = [] keep: list[int] = [] for i, ed in enumerate(items): T, t, fr = _get_t_block(ed) if T is None or t is None or fr is None or fr.size == 0: continue k, _ = _resolve_target_frequency(fr, frequency_hz, period_s) v = t[k, 0] if component == "tzx" else t[k, 1] values_l.append(v.real if part == "real" else v.imag) keep.append(i) lon, lat = lon[keep], lat[keep] values = np.asarray(values_l, dtype=float) tlabel = r"$T_{zx}$" if component == "tzx" else r"$T_{zy}$" label = f"{tlabel} [{part}]" else: # total_divergence_table's along-profile chainage assumes one # flight line: differentiating across a line boundary would # produce a physically meaningless value. Each detected line # is therefore differentiated separately, then the resulting # (lon, lat, divergence) triples from every line are pooled # for one combined map. col = "divergence_real" if part == "real" else "divergence_imag" lon_l: list[float] = [] lat_l: list[float] = [] values_l = [] for g in sorted(set(groups.tolist())): idx = np.where(groups == g)[0] if idx.size < 2: continue line_items = [items[i] for i in idx] df = total_divergence_table(line_items, component=component) if df.empty: continue freqs_here = np.sort(df["freq_hz"].unique()) _, f0 = _resolve_target_frequency( freqs_here, frequency_hz, period_s, ) sub = df[np.isclose(df["freq_hz"], f0)] name_to_ll = { _name(ed, j): (lon[idx[j]], lat[idx[j]]) for j, ed in enumerate(line_items) } for _, row in sub.iterrows(): ll = name_to_ll.get(row["station_a"]) if ll is None: continue lon_l.append(ll[0]) lat_l.append(ll[1]) values_l.append(row[col]) lon = np.asarray(lon_l) lat = np.asarray(lat_l) values = np.asarray(values_l, dtype=float) label = rf"$\partial T/\partial x$ [{part}] (m$^{{-1}}$)" if lon.size < 3 or values.size < 3: ax.text( 0.5, 0.5, "not enough stations to map", ha="center", va="center", ) return ax lon_span = max(float(np.ptp(lon)), 1e-9) lat_span = max(float(np.ptp(lat)), 1e-9) if lon_span >= lat_span: nx = n_grid ny = max(int(round(n_grid * lat_span / lon_span)), 8) else: ny = n_grid nx = max(int(round(n_grid * lon_span / lat_span)), 8) grid_lon = np.linspace(lon.min(), lon.max(), nx) grid_lat = np.linspace(lat.min(), lat.max(), ny) gx, gy = np.meshgrid(grid_lon, grid_lat) grid_z = griddata( (lon, lat), values, (gx, gy), method="linear", ) if clim is None: finite = np.abs(values[np.isfinite(values)]) vabs = float(np.percentile(finite, clim_pct)) if finite.size else 1.0 vabs = max(vabs, 1e-12) clim = (-vabs, vabs) vmin, vmax = clim im = ax.pcolormesh( gx, gy, grid_z, cmap=cmap, vmin=vmin, vmax=vmax, shading="auto", ) if show_stations: ax.scatter( lon, lat, s=10, color="black", alpha=0.5, zorder=3, ) ax.set_xlabel("Longitude") ax.set_ylabel("Latitude") ax.ticklabel_format(useOffset=False, style="plain") plt.setp(ax.get_xticklabels(), rotation=45, ha="right") q_label = "In-Phase" if part == "real" else "Quadrature" title_q = "raw tipper" if quantity == "tipper" else "total divergence" ax.set_title(f"ZTEM {q_label} {title_q} map", fontsize=10) from ..api.plot import add_colorbar add_colorbar(im, ax, label=label) return ax