Source code for pycsamt.emtools.csumt

"""Controlled-source ultra-audio MT depth and survey-planning tools.

The module converts apparent resistivity and transmitter frequency into
Bostick depth estimates, evaluates vertical resolution and depth coverage,
designs CSUMT frequency schedules, and plots depth sections for survey sites.
"""

from __future__ import annotations

from typing import Any

import numpy as np
import pandas as pd

from ..api.station import PYCSAMT_STATION_RENDERING
from ._core import (
    _get_z_block,
    _iter_items,
    _name,
    ensure_sites,
)

__all__ = [
    # pure survey-planning functions (no sites required)
    "bostick_depth_from_rho",
    "vertical_resolution_pair",
    "frequency_for_depth",
    "frequency_schedule",
    # sites-based analysis
    "bostick_depth",
    "vertical_resolution",
    "depth_coverage_table",
    "plot_depth_section",
]

# ----------------------------- constants ---------------------------------- #

BOSTICK_CONST: float = 356.0
"""
Bostick depth constant in metres:
    D(f) = 356 × √(ρ_a / f)

Derived from the skin depth δ = 503√(ρ/f) as D_B = δ / √2 ≈ 356√(ρ/f).
"""

F_MIN_CSUMT: float = 9.6e3
"""Lower bound of the CSUMT frequency range: 9.6 kHz (zhang2025)."""

F_MAX_CSUMT: float = 614.4e3
"""Upper bound of the CSUMT frequency range: 614.4 kHz (zhang2025)."""


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


def _unwrap(ed: Any) -> Any:
    """Unwrap a Sites-level Site wrapper to the underlying EDI-like object."""
    edi = getattr(ed, "edi", None)
    if edi is not None and hasattr(edi, "Z"):
        return edi
    return ed


def _rho_a_det(z: np.ndarray, fr: np.ndarray) -> np.ndarray:
    """Geometric-mean apparent resistivity from off-diagonal Z (Ω·m)."""
    rxy = 0.2 * np.abs(z[:, 0, 1]) ** 2 / np.maximum(fr, 1e-24)
    ryx = 0.2 * np.abs(z[:, 1, 0]) ** 2 / np.maximum(fr, 1e-24)
    return np.sqrt(np.maximum(rxy * ryx, 1e-12))


# ====================== pure survey-planning API ========================== #


[docs] def bostick_depth_from_rho( rho: float | np.ndarray, freq: float | np.ndarray, ) -> np.ndarray: """ Bostick depth estimate D(f) from apparent resistivity. D(f) = 356 × √(ρ_a / f) [metres] Parameters ---------- rho : float or array Apparent resistivity ρ_a in Ω·m. Broadcastable with *freq*. freq : float or array Frequency in Hz. Returns ------- numpy.ndarray Depth in metres, same shape as broadcast of *rho* and *freq*. References ---------- Zhang et al. (2025), Eq. (1), *Measurement*. """ rho = np.asarray(rho, dtype=float) freq = np.asarray(freq, dtype=float) return BOSTICK_CONST * np.sqrt( np.maximum(rho, 0.0) / np.maximum(freq, 1e-24) )
[docs] def vertical_resolution_pair( rho: float, f_lo: float, f_hi: float, ) -> float: """ Vertical resolution ΔD between two adjacent frequencies. ΔD = 356 × √ρ_c × (1/√f_lo − 1/√f_hi) [metres; f_lo < f_hi] Parameters ---------- rho : float Characteristic (apparent) resistivity ρ_c in Ω·m. f_lo : float Lower frequency in Hz (deeper penetration). f_hi : float Higher frequency in Hz (shallower penetration). Returns ------- float Vertical resolution in metres. Positive when f_lo < f_hi. References ---------- Zhang et al. (2025), Eq. (2), *Measurement*. """ rho = max(float(rho), 0.0) f_lo = max(float(f_lo), 1e-24) f_hi = max(float(f_hi), 1e-24) return ( BOSTICK_CONST * np.sqrt(rho) * (1.0 / np.sqrt(f_lo) - 1.0 / np.sqrt(f_hi)) )
[docs] def frequency_for_depth( depth_m: float | np.ndarray, rho: float, ) -> np.ndarray: """ Invert the Bostick formula: return the frequency (Hz) that maps to a given depth for a background resistivity *rho*. f = ρ × (356 / D)² [Hz] Parameters ---------- depth_m : float or array Target depth(s) in metres. rho : float Background apparent resistivity in Ω·m. Returns ------- numpy.ndarray Frequency in Hz, same shape as *depth_m*. """ d = np.asarray(depth_m, dtype=float) return float(rho) * (BOSTICK_CONST / np.maximum(d, 1e-6)) ** 2
[docs] def frequency_schedule( target_depths: float | np.ndarray, rho_estimate: float, *, f_min: float = F_MIN_CSUMT, f_max: float = F_MAX_CSUMT, min_resolution_m: float | None = None, fill_decades: bool = False, per_decade: int = 3, as_khz: bool = False, ) -> np.ndarray: """ Design a CSUMT frequency schedule that samples a set of target depths. Each target depth is converted to a frequency via :func:`frequency_for_depth`, then clipped to [*f_min*, *f_max*]. Optionally, intermediate frequencies can be inserted to guarantee a minimum vertical resolution between consecutive depth levels. Parameters ---------- target_depths : float or array Target depths in metres (deepest first or any order — sorted internally). rho_estimate : float Background apparent resistivity ρ (Ω·m) used for the conversion. f_min : float, default=9.6e3 Minimum transmitter frequency in Hz (lower bound of CSUMT range). f_max : float, default=614.4e3 Maximum transmitter frequency in Hz (upper bound of CSUMT range). min_resolution_m : float or None If given, insert additional frequencies between adjacent target depths whenever their vertical resolution would exceed this value. fill_decades : bool, default=False If True, add *per_decade* log-spaced frequencies within each decade of the schedule to smooth coverage. per_decade : int, default=3 Number of extra frequencies to insert per decade when *fill_decades* is True. as_khz : bool, default=False If True, return frequencies in kHz instead of Hz. Returns ------- numpy.ndarray Sorted frequencies in Hz (or kHz if *as_khz* is True). References ---------- Zhang et al. (2025), "Controlled source ultra-audio frequency magnetotellurics (CSUMT) transmitter", *Measurement*. """ depths = np.unique(np.asarray(target_depths, dtype=float).ravel()) depths = depths[depths > 0] if depths.size == 0: return np.array([], dtype=float) freqs = frequency_for_depth(depths, rho_estimate) freqs = freqs[(freqs >= f_min) & (freqs <= f_max)] freqs = np.unique(freqs) if min_resolution_m is not None and freqs.size >= 2: extra: list[float] = [] fs = np.sort(freqs) for i in range(len(fs) - 1): f_lo, f_hi = fs[i], fs[i + 1] rho_c = rho_estimate delta = vertical_resolution_pair(rho_c, f_lo, f_hi) if delta > min_resolution_m: n_insert = int(np.ceil(delta / min_resolution_m)) - 1 extra.extend( np.logspace( np.log10(f_lo), np.log10(f_hi), n_insert + 2, )[1:-1].tolist() ) if extra: freqs = np.unique(np.concatenate([freqs, extra])) freqs = freqs[(freqs >= f_min) & (freqs <= f_max)] if fill_decades and freqs.size >= 2: lo_d = np.log10(freqs.min()) hi_d = np.log10(freqs.max()) n = max(2, int(np.ceil((hi_d - lo_d) * per_decade))) fill = np.logspace(lo_d, hi_d, n) fill = fill[(fill >= f_min) & (fill <= f_max)] freqs = np.unique(np.concatenate([freqs, fill])) freqs = np.sort(freqs) if as_khz: return freqs / 1e3 return freqs
# ======================= sites-based analysis API ========================= #
[docs] def bostick_depth( sites: Any, *, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> pd.DataFrame: """ Bostick depth estimate per station per frequency from measured data. Uses the apparent resistivity derived from the off-diagonal impedance tensor components (geometric mean): D(f) = 356 × √(ρ_a(f) / f) [metres] Parameters ---------- sites : path, EDI-like, Sites, or iterable Any input accepted by :func:`~pycsamt.emtools._core.ensure_sites`. recursive, on_dup, strict, verbose Forwarded to :func:`~pycsamt.emtools._core.ensure_sites`. Returns ------- pandas.DataFrame One row per (station, frequency) with columns: ``station``, ``freq_hz``, ``period_s``, ``rho_a_ohmm``, ``depth_m``. References ---------- Zhang et al. (2025), Eq. (1). """ S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) _COLS = ["station", "freq_hz", "period_s", "rho_a_ohmm", "depth_m"] rows = [] for i, ed in enumerate(_iter_items(S)): ed = _unwrap(ed) station = _name(ed, i) _, z, fr = _get_z_block(ed) if z is None or fr is None: continue rho_a = _rho_a_det(z, fr) depth = bostick_depth_from_rho(rho_a, fr) for j in range(fr.size): rows.append( { "station": station, "freq_hz": float(fr[j]), "period_s": 1.0 / max(float(fr[j]), 1e-24), "rho_a_ohmm": float(rho_a[j]), "depth_m": float(depth[j]), } ) if not rows: return pd.DataFrame(columns=_COLS) return pd.DataFrame(rows, columns=_COLS)
[docs] def vertical_resolution( sites: Any, *, rho_override: float | None = None, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> pd.DataFrame: """ Vertical resolution ΔD between adjacent frequencies per station. For each consecutive pair (f_lo, f_hi) in the station's frequency list (sorted ascending), computes: ΔD = D(f_lo) − D(f_hi) [metres] using the Bostick depths derived from the measured ρ_a. Alternatively, supply *rho_override* to use a fixed background resistivity with the analytical formula ``356 × √ρ × (1/√f_lo − 1/√f_hi)``. Parameters ---------- sites : path, EDI-like, Sites, or iterable rho_override : float or None If given, use this constant resistivity for all ΔD calculations (analytical formula) instead of the per-frequency ρ_a. recursive, on_dup, strict, verbose Forwarded to :func:`~pycsamt.emtools._core.ensure_sites`. Returns ------- pandas.DataFrame Columns: ``station``, ``freq_lo_hz``, ``freq_hi_hz``, ``depth_lo_m``, ``depth_hi_m``, ``delta_depth_m``, ``rho_a_ohmm``. """ S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) _COLS = [ "station", "freq_lo_hz", "freq_hi_hz", "depth_lo_m", "depth_hi_m", "delta_depth_m", "rho_a_ohmm", ] rows = [] for i, ed in enumerate(_iter_items(S)): ed = _unwrap(ed) station = _name(ed, i) _, z, fr = _get_z_block(ed) if z is None or fr is None or fr.size < 2: continue # sort ascending by frequency order = np.argsort(fr) fr_s = fr[order] z_s = z[order] rho_a = _rho_a_det(z_s, fr_s) for j in range(fr_s.size - 1): f_lo = float(fr_s[j]) f_hi = float(fr_s[j + 1]) if rho_override is not None: rho_c = float(rho_override) d_lo = bostick_depth_from_rho(rho_c, f_lo) d_hi = bostick_depth_from_rho(rho_c, f_hi) else: rho_c = float(np.sqrt(rho_a[j] * rho_a[j + 1])) d_lo = bostick_depth_from_rho(float(rho_a[j]), f_lo) d_hi = bostick_depth_from_rho(float(rho_a[j + 1]), f_hi) rows.append( { "station": station, "freq_lo_hz": f_lo, "freq_hi_hz": f_hi, "depth_lo_m": float(d_lo), "depth_hi_m": float(d_hi), "delta_depth_m": float(d_lo - d_hi), "rho_a_ohmm": rho_c, } ) if not rows: return pd.DataFrame(columns=_COLS) return pd.DataFrame(rows, columns=_COLS)
[docs] def depth_coverage_table( sites: Any, *, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> pd.DataFrame: """ Summary depth-coverage statistics per station. Parameters ---------- sites : path, EDI-like, Sites, or iterable recursive, on_dup, strict, verbose Forwarded to :func:`~pycsamt.emtools._core.ensure_sites`. Returns ------- pandas.DataFrame One row per station with columns: ``station``, ``n_freq``, ``freq_min_hz``, ``freq_max_hz``, ``depth_min_m``, ``depth_max_m``, ``mean_resolution_m``, ``median_resolution_m``. """ bd = bostick_depth( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) vr = vertical_resolution( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) _COLS = [ "station", "n_freq", "freq_min_hz", "freq_max_hz", "depth_min_m", "depth_max_m", "mean_resolution_m", "median_resolution_m", ] if bd.empty: return pd.DataFrame(columns=_COLS) rows = [] for st in bd["station"].unique(): sub_bd = bd[bd["station"] == st] sub_vr = vr[vr["station"] == st] if not vr.empty else pd.DataFrame() dres = ( sub_vr["delta_depth_m"].dropna().values if not sub_vr.empty else np.array([]) ) rows.append( { "station": st, "n_freq": len(sub_bd), "freq_min_hz": float(sub_bd["freq_hz"].min()), "freq_max_hz": float(sub_bd["freq_hz"].max()), "depth_min_m": float(sub_bd["depth_m"].min()), "depth_max_m": float(sub_bd["depth_m"].max()), "mean_resolution_m": float(np.nanmean(dres)) if dres.size else float("nan"), "median_resolution_m": float(np.nanmedian(dres)) if dres.size else float("nan"), } ) return pd.DataFrame(rows, columns=_COLS)
[docs] def plot_depth_section( sites: Any, *, log_color: bool = True, sort_by: str = "name", cmap: str = "viridis_r", figsize: tuple[float, float] = (10.0, 5.0), period_axis: bool = True, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ax: Any | None = None, ) -> Any: """ Pseudosection of Bostick depth across stations and periods/frequencies. Each cell (station × period) is coloured by the Bostick depth D(f) = 356 √(ρ_a / f). Parameters ---------- sites : path, EDI-like, Sites, or iterable log_color : bool, default=True Color by log10(depth) instead of depth. sort_by : {"name", "lon", "lat"} Station ordering along the x-axis. cmap : str, default="viridis_r" Matplotlib colormap name. figsize : (float, float), default=(10, 5) period_axis : bool, default=True If True y-axis is period (s); otherwise frequency (Hz). recursive, on_dup, strict, verbose Forwarded to :func:`~pycsamt.emtools._core.ensure_sites`. ax : matplotlib.axes.Axes, optional Returns ------- matplotlib.axes.Axes """ import matplotlib.pyplot as plt df = bostick_depth( sites, 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 data", ha="center", va="center", transform=ax.transAxes, ) return ax # station order stations = df["station"].unique().tolist() if sort_by in ("lon", "lat"): S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) coords: dict[str, float] = {} for ii, ed in enumerate(_iter_items(S)): ed = _unwrap(ed) nm = _name(ed, ii) v = getattr(ed, sort_by, None) or getattr( ed, "longitude" if sort_by == "lon" else "latitude", None ) coords[nm] = float(v) if v is not None else float("inf") stations = sorted(stations, key=lambda s: coords.get(s, float("inf"))) else: stations = sorted(stations) y_key = "period_s" if period_axis else "freq_hz" all_y = np.sort(df[y_key].unique()) grid = np.full((len(all_y), len(stations)), np.nan) y_idx = {v: k for k, v in enumerate(all_y)} x_idx = {s: k for k, s in enumerate(stations)} for row in df.itertuples(index=False): yi = y_idx.get(getattr(row, y_key)) xi = x_idx.get(row.station) if yi is not None and xi is not None: grid[yi, xi] = row.depth_m plot_data = np.log10(np.maximum(grid, 1e-3)) if log_color else grid xs = np.arange(len(stations) + 1) - 0.5 ys = np.arange(len(all_y) + 1) - 0.5 im = ax.pcolormesh(xs, ys, plot_data, cmap=cmap, shading="auto") PYCSAMT_STATION_RENDERING.apply( ax, np.arange(len(stations), dtype=float), stations, preset="pseudosection", xlim=(-0.5, len(stations) - 0.5), ) n_ytick = min(8, len(all_y)) step = max(1, len(all_y) // n_ytick) tick_idx = np.arange(0, len(all_y), step) ax.set_yticks(tick_idx) ax.set_yticklabels([f"{all_y[k]:.3g}" for k in tick_idx], fontsize=8) ax.set_ylabel("Period (s)" if period_axis else "Frequency (Hz)") if period_axis and not ax.yaxis_inverted(): ax.invert_yaxis() ax.set_title("Bostick Depth Section D = 356√(ρ_a / f)") cb = plt.colorbar(im, ax=ax) cb.set_label("log10 depth (m)" if log_color else "depth (m)") return ax