Source code for pycsamt.emtools.fieldzone

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,
    ensure_sites,
)

__all__ = [
    "classify_field_zones",
    "near_field_factor",
    "plot_field_zones",
]

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

_MU0 = 4.0 * np.pi * 1e-7  # H/m
_FAR = "far"
_TRANS = "transition"
_NEAR = "near"

_ZONE_COLORS = {
    _FAR: "#2ca02c",  # green
    _TRANS: "#ff7f0e",  # orange
    _NEAR: "#d62728",  # red
}

_ZONE_INT = {_FAR: 0, _TRANS: 1, _NEAR: 2}
_INT_ZONE = {0: _FAR, 1: _TRANS, 2: _NEAR}


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


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))


def _bostick_depth(rho: np.ndarray, freq: np.ndarray) -> np.ndarray:
    """δ_B = 356 √(ρ_a / f)  (metres, Bostick approximation)."""
    return 356.0 * np.sqrt(np.maximum(rho, 1e-6) / np.maximum(freq, 1e-12))


def _kr_abs(rho: np.ndarray, freq: np.ndarray, offset: float) -> np.ndarray:
    """
    |k·r| = r / δ_B = r / (356 √(ρ_a / f)).

    This is the dimensionless field-zone parameter for CSAMT.
    The plane-wave approximation is valid when |k·r| >> 1.
    """
    db = _bostick_depth(rho, freq)
    return offset / np.maximum(db, 1e-6)


def _zone_labels(
    kr: np.ndarray,
    far_threshold: float,
    near_threshold: float,
) -> np.ndarray:
    labels = np.where(
        kr >= far_threshold,
        _FAR,
        np.where(kr >= near_threshold, _TRANS, _NEAR),
    )
    return labels


def _nf_correction(
    rho: np.ndarray,
    freq: np.ndarray,
    offset: float,
) -> np.ndarray:
    """
    Near-field correction factor |F(p)| for E_y (equatorial, HED).

    For a horizontal electric dipole over a uniform half-space the ratio
    of the actual E_y to the far-field (plane-wave) approximation is:

        F(p) = 1 − 3/p² + 3/p³   where  p = k·r (complex),
        k = |k|·e^{iπ/4} = (1+i)/√2 · √(ωμ₀/ρ_a)

    F → 1 in the far field (|p| >> 1), deviates strongly in the near
    field.  |F|² gives the bias factor for apparent resistivity.

    References
    ----------
    Chen & Yan (2005) eqs. (8)–(10).
    """
    omega = 2.0 * np.pi * np.maximum(freq, 1e-12)
    k_abs = np.sqrt(omega * _MU0 / np.maximum(rho, 1e-6))
    # complex wave number: k = |k| * exp(i*pi/4)
    p = k_abs * offset * (1.0 + 1j) / np.sqrt(2.0)
    p = np.where(np.abs(p) < 1e-12, 1e-12 * (1.0 + 1j), p)
    F = 1.0 - 3.0 / p**2 + 3.0 / p**3
    return np.abs(F)


def _resolve_offset(
    ed: Any,
    source_offset: float | dict[str, float] | None,
    station: str,
) -> float | None:
    if isinstance(source_offset, (int, float)):
        return float(source_offset)
    if isinstance(source_offset, dict):
        r = source_offset.get(station)
        if r is not None:
            return float(r)
        low = {k.lower(): v for k, v in source_offset.items()}
        r = low.get(station.lower())
        if r is not None:
            return float(r)
    for attr in ("source_offset", "offset", "x", "east"):
        v = getattr(ed, attr, None)
        if isinstance(v, (int, float)) and float(v) > 0:
            return float(v)
    return None


def _unwrap(ed: Any) -> Any:
    """Unwrap a Sites-level Site object 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 _sorted_stations(stations: list[str], S: Any, sort_by: str) -> list[str]:
    if sort_by not in ("lon", "lat"):
        return sorted(stations)
    coords: dict[str, float] = {}
    for i, ed in enumerate(_iter_items(S)):
        ed = _unwrap(ed)
        nm = _name(ed, i)
        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 np.inf
    return sorted(stations, key=lambda s: coords.get(s, np.inf))


# ----------------------------- public API --------------------------------- #


[docs] def classify_field_zones( sites: Any, source_offset: float | dict[str, float] | None = None, *, far_threshold: float = 3.0, near_threshold: float = 0.3, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> pd.DataFrame: """ Classify CSAMT measurement zones per station per frequency. For each (station, frequency) pair the dimensionless field-zone parameter is computed as: |k·r| = r / δ_B where δ_B = 356 √(ρ_a / f) (metres) and the zone is assigned as: ========= ========================= ===================== Zone Condition Meaning ========= ========================= ===================== ``"far"`` |k·r| ≥ far_threshold Plane-wave approx. valid ``"transition"`` near ≤ |k·r| < far Mixed zone ``"near"`` |k·r| < near_threshold Geometric near-field ========= ========================= ===================== Parameters ---------- sites : path, EDI-like, Sites, or iterable Any input accepted by :func:`~pycsamt.emtools._core.ensure_sites`. source_offset : float, dict {station: float}, or None Source–receiver separation **r** in metres. If a dict is given, missing stations are skipped (or read from ``ed.offset`` / ``ed.source_offset``). far_threshold : float, default=3.0 |k·r| threshold for the far-field (plane-wave) zone. near_threshold : float, default=0.3 |k·r| threshold below which the near-field zone is declared. recursive, on_dup, strict, verbose Forwarded to :func:`~pycsamt.emtools._core.ensure_sites`. Returns ------- pandas.DataFrame Tidy table, one row per (station, frequency): ``station``, ``freq_hz``, ``period_s``, ``offset_m``, ``rho_a_ohmm``, ``delta_bostick_m``, ``kr``, ``zone`` References ---------- Chen & Yan (2005), *J. Geophysics and Engineering* 2, 105–120. Yan & Fu (2004), analytical shadow/overprint estimation. """ S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) 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 offset = _resolve_offset(ed, source_offset, station) if offset is None: if verbose > 0: import warnings warnings.warn( f"classify_field_zones: no source_offset for " f"'{station}' — station skipped.", RuntimeWarning, stacklevel=2, ) continue rho_a = _rho_a_det(z, fr) kr = _kr_abs(rho_a, fr, offset) db = _bostick_depth(rho_a, fr) zones = _zone_labels(kr, far_threshold, near_threshold) for j in range(fr.size): rows.append( { "station": station, "freq_hz": float(fr[j]), "period_s": 1.0 / max(float(fr[j]), 1e-12), "offset_m": offset, "rho_a_ohmm": float(rho_a[j]), "delta_bostick_m": float(db[j]), "kr": float(kr[j]), "zone": str(zones[j]), } ) _COLS = [ "station", "freq_hz", "period_s", "offset_m", "rho_a_ohmm", "delta_bostick_m", "kr", "zone", ] if not rows: return pd.DataFrame(columns=_COLS) return pd.DataFrame(rows, columns=_COLS)
[docs] def near_field_factor( sites: Any, source_offset: float | dict[str, float] | None = None, *, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> pd.DataFrame: """ Near-field correction factor for apparent resistivity (equatorial HED). For the equatorial E_y component from a horizontal electric dipole over a homogeneous half-space, the ratio of the measured E_y to the plane-wave (far-field) E_y is: F(p) = 1 − 3/p² + 3/p³ p = k·r (complex) so the apparent resistivity is biased by factor |F(p)|². When |F(p)| ≈ 1 the data are in the plane-wave zone; strong departures indicate near-field contamination. Parameters ---------- sites : path, EDI-like, Sites, or iterable source_offset : float, dict {station: float}, or None Source–receiver separation **r** in metres. recursive, on_dup, strict, verbose Forwarded to :func:`~pycsamt.emtools._core.ensure_sites`. Returns ------- pandas.DataFrame Columns: ``station``, ``freq_hz``, ``period_s``, ``offset_m``, ``rho_a_ohmm``, ``kr``, ``nf_factor``. * ``nf_factor`` = |F(p)|; close to 1.0 → far-field (safe). * ``nf_factor`` far from 1.0 → near-field bias present. References ---------- Chen & Yan (2005), eqs. (8)–(10). """ S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) 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 offset = _resolve_offset(ed, source_offset, station) if offset is None: continue rho_a = _rho_a_det(z, fr) kr = _kr_abs(rho_a, fr, offset) nff = _nf_correction(rho_a, fr, offset) for j in range(fr.size): rows.append( { "station": station, "freq_hz": float(fr[j]), "period_s": 1.0 / max(float(fr[j]), 1e-12), "offset_m": offset, "rho_a_ohmm": float(rho_a[j]), "kr": float(kr[j]), "nf_factor": float(nff[j]), } ) _COLS = [ "station", "freq_hz", "period_s", "offset_m", "rho_a_ohmm", "kr", "nf_factor", ] if not rows: return pd.DataFrame(columns=_COLS) return pd.DataFrame(rows, columns=_COLS)
[docs] def plot_field_zones( sites: Any, source_offset: float | dict[str, float] | None = None, *, far_threshold: float = 3.0, near_threshold: float = 0.3, contour_kr: bool = True, kr_levels: tuple[float, ...] = (0.1, 0.3, 1.0, 3.0, 10.0), sort_by: str = "name", period_axis: bool = True, log_y: bool = True, figsize: tuple[float, float] = (10.0, 5.0), recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ax: Any | None = None, ) -> Any: """ Pseudosection of CSAMT field zones across stations and frequencies. Each cell (station × period/frequency) is filled by zone colour: * **green** → far field (|k·r| ≥ *far_threshold*) * **orange** → transition * **red** → near field (|k·r| < *near_threshold*) Dashed white contours of constant |k·r| can be overlaid. Parameters ---------- sites : path, EDI-like, Sites, or iterable source_offset : float, dict {station: float}, or None Source–receiver separation **r** in metres. far_threshold, near_threshold : float Zone boundaries in |k·r|. contour_kr : bool, default=True Draw |k·r| contours. kr_levels : tuple of float |k·r| values to contour. sort_by : {"name", "lon", "lat"} Station ordering along the x-axis. period_axis : bool, default=True If True y-axis shows period (s), else frequency (Hz). log_y : bool, default=True Use a quasi-log y-axis (log-spaced tick labels). figsize : (float, float), default=(10, 5) recursive, on_dup, strict, verbose Forwarded to :func:`~pycsamt.emtools._core.ensure_sites`. ax : matplotlib.axes.Axes, optional Draw on existing axes. Returns ------- matplotlib.axes.Axes """ import matplotlib.colors as mcolors import matplotlib.patches as mpatches import matplotlib.pyplot as plt df = classify_field_zones( sites, source_offset, far_threshold=far_threshold, near_threshold=near_threshold, 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, ) stations = _sorted_stations(stations, S, sort_by) else: stations = sorted(stations) y_key = "period_s" if period_axis else "freq_hz" all_y = np.sort(df[y_key].unique()) # fill grid grid_zone = np.full((len(all_y), len(stations)), np.nan) grid_kr = 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_zone[yi, xi] = _ZONE_INT[row.zone] grid_kr[yi, xi] = row.kr cmap = mcolors.ListedColormap( [_ZONE_COLORS[_FAR], _ZONE_COLORS[_TRANS], _ZONE_COLORS[_NEAR]] ) xs = np.arange(len(stations) + 1) - 0.5 ys = np.arange(len(all_y) + 1) - 0.5 ax.pcolormesh( xs, ys, grid_zone, cmap=cmap, vmin=-0.5, vmax=2.5, shading="auto" ) if ( contour_kr and not np.all(np.isnan(grid_kr)) and grid_kr.shape[0] >= 2 and grid_kr.shape[1] >= 2 ): XX, YY = np.meshgrid(np.arange(len(stations)), np.arange(len(all_y))) valid_levels = [ lv for lv in sorted(kr_levels) if np.nanmin(grid_kr) < lv < np.nanmax(grid_kr) ] if valid_levels: cs = ax.contour( XX, YY, grid_kr, levels=valid_levels, colors="white", linewidths=0.8, linestyles="--", ) ax.clabel(cs, fmt="|kr|=%.2g", fontsize=7, inline=True) ax.set_xticks(range(len(stations))) ax.set_xticklabels(stations, rotation=45, ha="right", fontsize=8) # y-axis ticks 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)") ax.set_xlabel("Station") ax.set_title("CSAMT Field Zone Classification (|k·r|)") patches = [ mpatches.Patch( color=_ZONE_COLORS[_FAR], label=f"Far field |kr|≥{far_threshold}", ), mpatches.Patch( color=_ZONE_COLORS[_TRANS], label=f"Transition {near_threshold}≤|kr|<{far_threshold}", ), mpatches.Patch( color=_ZONE_COLORS[_NEAR], label=f"Near field |kr|<{near_threshold}", ), ] ax.legend(handles=patches, loc="upper right", fontsize=8, framealpha=0.85) return ax