"""
Gradient-based apparent resistivity pseudo-sections for CSAMT.
Implements the frequency-gradient, spatial-gradient, and joint
frequency-spatial gradient apparent resistivity formulations proposed by:
zhang2021 : Zhang, Farquharson & Liu (2021), "Improved CSAMT apparent
resistivity pseudo sections based on the frequency and
frequency-spatial gradients of electromagnetic fields",
*Geophysical Prospecting*, doi:10.1111/1365-2478.13059.
The three key gradient quantities are:
Transverse (along-line) gradient :math:`\\Delta\\rho_a^x`
Finite difference of :math:`\\rho_a` between adjacent stations at each
frequency.
Vertical (frequency) gradient :math:`\\Delta\\rho_a^z`
Log-frequency finite difference of :math:`\\rho_a` at each station.
Joint vertical-transverse gradient :math:`\\Delta\\rho_a^{zx}`
Frequency difference of the spatial gradient. This is the principal
product of zhang2021: it suppresses background interference caused by
the non-zero spatial gradient over homogeneous half-spaces and
improves boundary delineation in pseudo-section images.
All three quantities are derived from the standard impedance-based
:math:`\\rho_a`, so no source moment or source–receiver geometry is
required.
"""
from __future__ import annotations
from typing import Any
import numpy as np
import pandas as pd
from ._core import (
_get_z_block,
_iter_items,
_name,
_station_positions,
ensure_sites,
)
__all__ = [
"rho_spatial_gradient",
"rho_frequency_gradient",
"rho_joint_gradient",
"plot_gradient_section",
]
# ─────────────────────────────────────────────────────────────────────────────
# Column name constants
# ─────────────────────────────────────────────────────────────────────────────
_SPATIAL_COLS = [
"station_a",
"station_b",
"x_m",
"dx_m",
"freq_hz",
"period_s",
"depth_m",
"rho_a_ohmm",
"delta_rho_x",
]
_FREQ_COLS = [
"station",
"x_m",
"freq_hz",
"period_s",
"depth_m",
"rho_a_ohmm",
"delta_rho_z",
]
_JOINT_COLS = [
"station_a",
"station_b",
"x_m",
"dx_m",
"freq_hz",
"period_s",
"depth_m",
"delta_rho_zx",
]
# ─────────────────────────────────────────────────────────────────────────────
# Private helpers
# ─────────────────────────────────────────────────────────────────────────────
def _unwrap(ed: Any) -> Any:
"""Unwrap a Sites-level Site to its 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_comp(z: np.ndarray, fr: np.ndarray, comp: str) -> np.ndarray:
"""Apparent resistivity [Ω·m] for the requested impedance component."""
if comp == "xy":
return 0.2 * np.abs(z[:, 0, 1]) ** 2 / np.maximum(fr, 1e-24)
if comp == "yx":
return 0.2 * np.abs(z[:, 1, 0]) ** 2 / np.maximum(fr, 1e-24)
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))
def _skin_depth_m(rho: float, freq: float) -> float:
"""Apparent depth δ = 503 √(ρ/f) [m]."""
return 503.0 * float(np.sqrt(max(rho, 1e-6) / max(freq, 1e-12)))
def _build_rho_grid(
sites: Any,
comp: str,
spacing_m: float,
recursive: bool,
on_dup: str,
strict: bool,
verbose: int,
) -> tuple[list[str], np.ndarray, np.ndarray, np.ndarray]:
"""
Build a 2-D apparent resistivity grid from a ``Sites`` object.
Parameters
----------
sites : Sites | list
comp : {"det", "xy", "yx"}
spacing_m : float
Fall-back station spacing when no coordinate metadata is found.
Returns
-------
stations : list of str
Station names sorted by position along the survey line.
x_pos : ndarray, shape (N,)
Station positions [m].
freqs : ndarray, shape (F,)
Sorted unique frequencies [Hz].
rho_grid : ndarray, shape (N, F)
Apparent resistivity [Ω·m]. ``NaN`` where data are missing.
"""
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
items = list(_iter_items(S))
if not items:
return [], np.array([]), np.array([]), np.zeros((0, 0))
raw_list: list[Any] = []
names: list[str] = []
freq_rho: dict[str, tuple[np.ndarray, np.ndarray]] = {}
for i, ed in enumerate(items):
raw = _unwrap(ed)
nm = _name(raw, i)
_, z, fr = _get_z_block(raw)
if z is None or fr is None or fr.size == 0:
continue
raw_list.append(raw)
names.append(nm)
freq_rho[nm] = (fr, _rho_a_comp(z, fr, comp))
if not names:
return [], np.array([]), np.array([]), np.zeros((0, 0))
x_pos = _station_positions(raw_list, spacing_m=spacing_m)
# Sort stations by position along the line
order = np.argsort(x_pos)
names = [names[k] for k in order]
x_pos = x_pos[order]
# Common frequency axis (union of all frequencies)
all_f: set = set()
for fr, _ in freq_rho.values():
all_f.update(fr.tolist())
freqs = np.array(sorted(all_f), dtype=float)
if freqs.size == 0:
return names, x_pos, freqs, np.full((len(names), 0), np.nan)
f_idx = {float(f): k for k, f in enumerate(freqs)}
rho_grid = np.full((len(names), freqs.size), np.nan)
for i, nm in enumerate(names):
fr, rho = freq_rho[nm]
for j in range(fr.size):
k = f_idx.get(float(fr[j]))
if k is not None:
rho_grid[i, k] = rho[j]
return names, x_pos, freqs, rho_grid
# ─────────────────────────────────────────────────────────────────────────────
# Public: rho_spatial_gradient
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def rho_spatial_gradient(
sites: Any,
spacing_m: float = 200.0,
*,
comp: str = "det",
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> pd.DataFrame:
r"""
Transverse (along-line) apparent resistivity gradient.
Computes the first-order finite difference of :math:`\rho_a` between
adjacent stations at each frequency (eq. 11 of zhang2021):
.. math::
\Delta\rho_a^x(j,\,f)
\approx \rho_a(j,\,f) - \rho_a(j-1,\,f)
where station indices are ordered by their position along the survey
line. The result is assigned to the spatial midpoint between the two
stations.
Parameters
----------
sites : Sites | list
EDI-like objects or a ``Sites`` container.
spacing_m : float, default 200
Fall-back inter-station spacing [m] used when no coordinate
metadata is available.
comp : {"det", "xy", "yx"}, default "det"
Impedance component for :math:`\rho_a`.
``"det"`` uses the geometric-mean determinant.
recursive, on_dup, strict, verbose
Forwarded to :func:`~pycsamt.emtools._core.ensure_sites`.
Returns
-------
pandas.DataFrame
One row per (station-pair, frequency). Columns:
``station_a``, ``station_b``
Names of the left and right stations of each pair.
``x_m``
Midpoint position along the survey line [m].
``dx_m``
Spacing between the two stations [m].
``freq_hz``, ``period_s``
Frequency [Hz] and period [s].
``depth_m``
Skin depth :math:`\delta = 503\,\sqrt{\rho_a/f}` [m] at the
midpoint :math:`\rho_a` and the given frequency.
``rho_a_ohmm``
Mean :math:`\rho_a` of the pair at that frequency [Ω·m].
``delta_rho_x``
:math:`\Delta\rho_a^x` [Ω·m].
References
----------
Zhang et al. (2021), eq. (11).
"""
if comp not in ("det", "xy", "yx"):
raise ValueError(f"comp must be 'det', 'xy', or 'yx'; got {comp!r}")
names, x_pos, freqs, rho_grid = _build_rho_grid(
sites, comp, spacing_m, recursive, on_dup, strict, verbose
)
N, F = rho_grid.shape
if N < 2 or F == 0:
return pd.DataFrame(columns=_SPATIAL_COLS)
# Δρ_a^x[pair_idx, freq_idx] = rho_grid[j, f] - rho_grid[j-1, f]
delta_x = rho_grid[1:, :] - rho_grid[:-1, :] # (N-1, F)
x_mid = 0.5 * (x_pos[1:] + x_pos[:-1]) # (N-1,)
dx = x_pos[1:] - x_pos[:-1] # (N-1,)
rho_mid = 0.5 * (rho_grid[1:, :] + rho_grid[:-1, :]) # (N-1, F)
rows: list[dict] = []
for j in range(N - 1):
for k in range(F):
rho_m = float(rho_mid[j, k])
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),
"depth_m": _skin_depth_m(rho_m, float(freqs[k])),
"rho_a_ohmm": rho_m,
"delta_rho_x": float(delta_x[j, k]),
}
)
if not rows:
return pd.DataFrame(columns=_SPATIAL_COLS)
return pd.DataFrame(rows, columns=_SPATIAL_COLS)
# ─────────────────────────────────────────────────────────────────────────────
# Public: rho_frequency_gradient
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def rho_frequency_gradient(
sites: Any,
*,
comp: str = "det",
spacing_m: float = 200.0,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> pd.DataFrame:
r"""
Vertical (log-frequency) apparent resistivity gradient.
Computes the first-order finite difference of :math:`\rho_a` between
adjacent frequencies at each station (eq. 12 of zhang2021):
.. math::
\Delta\rho_a^z(j,\,f_k)
\approx \rho_a(j,\,f_k) - \rho_a(j,\,f_{k-1})
where :math:`f_k > f_{k-1}` (ascending frequency, ascending
skin-depth index). Because different frequencies probe different
depths, :math:`\Delta\rho_a^z` is associated with vertical changes in
the subsurface.
Parameters
----------
sites : Sites | list
EDI-like objects or a ``Sites`` container.
comp : {"det", "xy", "yx"}, default "det"
Impedance component for :math:`\rho_a`.
spacing_m : float, default 200
Fall-back inter-station spacing [m].
recursive, on_dup, strict, verbose
Forwarded to :func:`~pycsamt.emtools._core.ensure_sites`.
Returns
-------
pandas.DataFrame
One row per (station, adjacent-frequency pair). Columns:
``station``
Station name.
``x_m``
Station position along the survey line [m].
``freq_hz``, ``period_s``
Higher frequency :math:`f_k` of the pair [Hz] and its
corresponding period [s].
``depth_m``
Skin depth at the mean :math:`\rho_a` of the pair [m].
``rho_a_ohmm``
Mean :math:`\rho_a` of the two adjacent frequencies [Ω·m].
``delta_rho_z``
:math:`\Delta\rho_a^z` [Ω·m].
References
----------
Zhang et al. (2021), eq. (12).
"""
if comp not in ("det", "xy", "yx"):
raise ValueError(f"comp must be 'det', 'xy', or 'yx'; got {comp!r}")
names, x_pos, freqs, rho_grid = _build_rho_grid(
sites, comp, spacing_m, recursive, on_dup, strict, verbose
)
N, F = rho_grid.shape
if N == 0 or F < 2:
return pd.DataFrame(columns=_FREQ_COLS)
# Δρ_a^z[station_idx, freq_pair_idx] = rho_grid[i, k] - rho_grid[i, k-1]
# freq_pair_idx k corresponds to (freqs[k-1], freqs[k]) pair → assign to freqs[k]
delta_z = rho_grid[:, 1:] - rho_grid[:, :-1] # (N, F-1)
rho_mean = 0.5 * (rho_grid[:, 1:] + rho_grid[:, :-1]) # (N, F-1)
freq_k = freqs[1:] # (F-1,) upper frequencies
rows: list[dict] = []
for i in range(N):
for k in range(F - 1):
rho_m = float(rho_mean[i, k])
rows.append(
{
"station": names[i],
"x_m": float(x_pos[i]),
"freq_hz": float(freq_k[k]),
"period_s": 1.0 / max(float(freq_k[k]), 1e-12),
"depth_m": _skin_depth_m(rho_m, float(freq_k[k])),
"rho_a_ohmm": rho_m,
"delta_rho_z": float(delta_z[i, k]),
}
)
if not rows:
return pd.DataFrame(columns=_FREQ_COLS)
return pd.DataFrame(rows, columns=_FREQ_COLS)
# ─────────────────────────────────────────────────────────────────────────────
# Public: rho_joint_gradient
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def rho_joint_gradient(
sites: Any,
spacing_m: float = 200.0,
*,
comp: str = "det",
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> pd.DataFrame:
r"""
Joint vertical-transverse apparent resistivity gradient.
Computes the frequency difference of the spatial gradient
(eq. 13 of zhang2021), which simultaneously resolves lateral and
vertical boundaries while suppressing the spurious background
interference in the spatial gradient over homogeneous regions:
.. math::
\Delta\rho_a^{zx}(j,\,f_k)
= \Delta\rho_a^x(j,\,f_k) - \Delta\rho_a^x(j,\,f_{k-1})
Expanding in terms of :math:`\rho_a`:
.. math::
\Delta\rho_a^{zx}(j,\,f_k)
= \bigl[\rho_a(j,\,f_k) - \rho_a(j-1,\,f_k)\bigr]
- \bigl[\rho_a(j,\,f_{k-1}) - \rho_a(j-1,\,f_{k-1})\bigr]
where station *j* is to the right of station *j-1* along the survey
line and :math:`f_k > f_{k-1}`.
The result is non-zero only where :math:`\rho_a` changes in **both**
the lateral and vertical directions simultaneously, making it a
sensitive indicator of target boundaries.
Parameters
----------
sites : Sites | list
EDI-like objects or a ``Sites`` container.
spacing_m : float, default 200
Fall-back inter-station spacing [m].
comp : {"det", "xy", "yx"}, default "det"
Impedance component for :math:`\rho_a`.
recursive, on_dup, strict, verbose
Forwarded to :func:`~pycsamt.emtools._core.ensure_sites`.
Returns
-------
pandas.DataFrame
One row per (station-pair, adjacent-frequency pair). Columns:
``station_a``, ``station_b``
Names of the left and right stations of each pair.
``x_m``
Midpoint position along the survey line [m].
``dx_m``
Spacing between the two stations [m].
``freq_hz``, ``period_s``
Upper frequency :math:`f_k` of the pair [Hz] and period [s].
``depth_m``
Skin depth [m] estimated from the median :math:`\rho_a` of
the four surrounding cells and the frequency :math:`f_k`.
``delta_rho_zx``
:math:`\Delta\rho_a^{zx}` [Ω·m].
References
----------
Zhang et al. (2021), eqs. (1), (11), (13).
"""
if comp not in ("det", "xy", "yx"):
raise ValueError(f"comp must be 'det', 'xy', or 'yx'; got {comp!r}")
names, x_pos, freqs, rho_grid = _build_rho_grid(
sites, comp, spacing_m, recursive, on_dup, strict, verbose
)
N, F = rho_grid.shape
if N < 2 or F < 2:
return pd.DataFrame(columns=_JOINT_COLS)
# spatial gradient: delta_x[j, k] = rho_grid[j+1,k] - rho_grid[j,k] (N-1, F)
delta_x = rho_grid[1:, :] - rho_grid[:-1, :]
# joint gradient: delta_zx[j, k] = delta_x[j, k] - delta_x[j, k-1] (N-1, F-1)
delta_zx = delta_x[:, 1:] - delta_x[:, :-1]
x_mid = 0.5 * (x_pos[1:] + x_pos[:-1]) # (N-1,)
dx = x_pos[1:] - x_pos[:-1] # (N-1,)
freq_k = freqs[1:] # (F-1,) upper frequencies
# For depth: use median rho_a of the 4 surrounding corners
# corners: rho_grid[j, k-1], rho_grid[j, k], rho_grid[j+1, k-1], rho_grid[j+1, k]
rho_corners = np.nanmedian(
np.stack(
[
rho_grid[:-1, :-1], # (j, k-1)
rho_grid[:-1, 1:], # (j, k )
rho_grid[1:, :-1], # (j+1, k-1)
rho_grid[1:, 1:], # (j+1, k )
],
axis=0,
),
axis=0,
) # (N-1, F-1)
rows: list[dict] = []
for j in range(N - 1):
for k in range(F - 1):
rho_m = float(rho_corners[j, k])
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(freq_k[k]),
"period_s": 1.0 / max(float(freq_k[k]), 1e-12),
"depth_m": _skin_depth_m(rho_m, float(freq_k[k])),
"delta_rho_zx": float(delta_zx[j, k]),
}
)
if not rows:
return pd.DataFrame(columns=_JOINT_COLS)
return pd.DataFrame(rows, columns=_JOINT_COLS)
# ─────────────────────────────────────────────────────────────────────────────
# Public: plot_gradient_section
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def plot_gradient_section(
sites: Any,
quantity: str = "joint",
spacing_m: float = 200.0,
*,
comp: str = "det",
period_axis: bool = True,
log_y: bool = True,
figsize: tuple[float, float] = (10.0, 5.0),
cmap: str = "RdBu_r",
vlim: tuple[float, float] | None = None,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: Any | None = None,
) -> Any:
r"""
Pseudo-section of a gradient apparent resistivity quantity.
Produces a colour-coded pseudo-section (position × period or
frequency) for one of three gradient quantities from zhang2021:
* ``"spatial"`` (:math:`\Delta\rho_a^x`) — lateral boundaries.
* ``"frequency"`` (:math:`\Delta\rho_a^z`) — vertical boundaries.
* ``"joint"`` (:math:`\Delta\rho_a^{zx}`) — combined; best boundary
delineation and suppressed background interference (default).
A diverging colour-map centred at zero is used so that positive and
negative gradient values (resistivity increase vs. decrease) can be
distinguished at a glance.
Parameters
----------
sites : Sites | list
EDI-like objects or a ``Sites`` container.
quantity : {"joint", "spatial", "frequency"}, default "joint"
Which gradient pseudo-section to plot.
spacing_m : float, default 200
Fall-back inter-station spacing [m].
comp : {"det", "xy", "yx"}, default "det"
Impedance component for :math:`\rho_a`.
period_axis : bool, default True
Show period [s] on the y-axis when *True*; frequency [Hz] when
*False*.
log_y : bool, default True
Use a logarithmic y-axis.
figsize : (float, float), default (10, 5)
cmap : str, default ``"RdBu_r"``
Diverging Matplotlib colour-map.
vlim : (vmin, vmax) or None
Colour-scale limits [Ω·m]. If *None*, a symmetric range centred
at zero is derived from the data.
ax : matplotlib.axes.Axes or None
Draw on existing axes; a new figure is created if *None*.
recursive, on_dup, strict, verbose
Forwarded to :func:`~pycsamt.emtools._core.ensure_sites`.
Returns
-------
matplotlib.axes.Axes
References
----------
Zhang et al. (2021), *Geophysical Prospecting*,
doi:10.1111/1365-2478.13059.
"""
import matplotlib.pyplot as plt
from matplotlib.colors import Normalize, TwoSlopeNorm
_Q = {
"joint": "joint",
"zx": "joint",
"spatial": "spatial",
"x": "spatial",
"frequency": "frequency",
"z": "frequency",
}
q = _Q.get(quantity.lower())
if q is None:
raise ValueError(
f"quantity must be 'joint', 'spatial', or 'frequency'; "
f"got {quantity!r}"
)
if q == "joint":
df = rho_joint_gradient(
sites,
spacing_m,
comp=comp,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
val_col = "delta_rho_zx"
x_col = "x_m"
title = r"$\Delta\rho_a^{zx}$ — joint gradient (zhang2021)"
elif q == "spatial":
df = rho_spatial_gradient(
sites,
spacing_m,
comp=comp,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
val_col = "delta_rho_x"
x_col = "x_m"
title = r"$\Delta\rho_a^x$ — spatial gradient (zhang2021)"
else:
df = rho_frequency_gradient(
sites,
comp=comp,
spacing_m=spacing_m,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
val_col = "delta_rho_z"
x_col = "x_m"
title = r"$\Delta\rho_a^z$ — frequency gradient (zhang2021)"
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,
)
ax.set_title(title)
return ax
y_col = "period_s" if period_axis else "freq_hz"
all_x = np.sort(df[x_col].unique())
all_y = np.sort(df[y_col].unique())
x_idx = {float(v): k for k, v in enumerate(all_x)}
y_idx = {float(v): k for k, v in enumerate(all_y)}
grid = np.full((len(all_y), len(all_x)), np.nan)
for _, row in df.iterrows():
xi = x_idx.get(float(row[x_col]))
yi = y_idx.get(float(row[y_col]))
if xi is not None and yi is not None:
grid[yi, xi] = row[val_col]
valid = grid[np.isfinite(grid)]
if vlim is None:
vabs = float(np.nanmax(np.abs(valid))) if len(valid) else 1.0
vabs = max(vabs, 1e-6)
vlim = (-vabs, vabs)
vmin, vmax = vlim
try:
norm = TwoSlopeNorm(vmin=vmin, vcenter=0.0, vmax=max(vmax, 1e-12))
except Exception:
norm = Normalize(vmin=vmin, vmax=vmax)
X_idx, Y_idx = np.meshgrid(np.arange(len(all_x)), np.arange(len(all_y)))
pcm = ax.pcolormesh(
X_idx, Y_idx, grid, cmap=cmap, norm=norm, shading="nearest"
)
plt.colorbar(pcm, ax=ax, label="Ω·m")
# x-axis: station positions
n_xtick = min(10, len(all_x))
x_step = max(1, len(all_x) // n_xtick)
xt_idx = np.arange(0, len(all_x), x_step)
ax.set_xticks(xt_idx)
ax.set_xticklabels(
[f"{all_x[k]:.0f}" for k in xt_idx],
rotation=45,
ha="right",
fontsize=8,
)
ax.set_xlabel("Position (m)")
# y-axis: period or frequency
n_ytick = min(8, len(all_y))
y_step = max(1, len(all_y) // n_ytick)
yt_idx = np.arange(0, len(all_y), y_step)
ax.set_yticks(yt_idx)
ax.set_yticklabels([f"{all_y[k]:.3g}" for k in yt_idx], fontsize=8)
ax.set_ylabel("Period (s)" if period_axis else "Frequency (Hz)")
ax.set_title(title, fontsize=10)
return ax