Source code for pycsamt.emtools.anisotropy

"""
3-D axial anisotropy analysis for CSAMT impedance tensor data.

Implements the anisotropy metrics derived in:
  wang2017 : Wang & Tan (2017), "Research on the forward modeling of
             controlled-source audio-frequency magnetotellurics in
             three-dimensional axial anisotropic media",
             J. Appl. Geophys. 146, 27–36.

For axial anisotropy the conductivity tensor is diagonal:
    σ* = diag(σ_xx, σ_yy, σ_zz)

The CSAMT impedance tensor Z = [[Z_xx, Z_xy], [Z_yx, Z_yy]] yields
two independent Cagniard apparent resistivities (eqs 17–18):
    ρ_xy = (1/ωμ₀)|Z_xy|²   (equatorial configuration, sensitive to σ_xx)
    ρ_yx = (1/ωμ₀)|Z_yx|²   (axial configuration, sensitive to σ_yy, σ_zz)

Their ratio Λ = ρ_xy/ρ_yx, expressed in log₁₀ units, is the primary
anisotropy indicator (Λ = 0 for isotropic media).

The Swift (1967) skew S = |Z_xx − Z_yy| / |Z_xy + Z_yx| and the
corresponding rotation angle provide additional dimensionality and
strike information (non-zero for anisotropic / 3-D structures).
"""

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__ = [
    "ANISO_RATIO_THRESH",
    "SWIFT_SKEW_THRESH",
    "analyze_anisotropy",
    "anisotropy_table",
    "plot_anisotropy",
]

# ─────────────────────────────────────────────────────────────────────────────
# Constants
# ─────────────────────────────────────────────────────────────────────────────

ANISO_RATIO_THRESH: float = 0.1  # |log10(Λ)| above which flag is raised
SWIFT_SKEW_THRESH: float = 0.2  # Swift skew above which 3-D is suspected

_DETAIL_COLS = [
    "station",
    "freq_hz",
    "period_s",
    "rho_xy_ohmm",
    "rho_yx_ohmm",
    "phi_xy_deg",
    "phi_yx_deg",
    "ratio_log10",
    "phase_diff_deg",
    "swift_skew",
    "strike_deg",
]
_TABLE_COLS = [
    "station",
    "n_freq",
    "mean_ratio_log10",
    "max_abs_ratio_log10",
    "mean_phase_diff_deg",
    "mean_swift_skew",
    "median_strike_deg",
    "anisotropy_flag",
]


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


def _unwrap(ed: Any) -> Any:
    edi = getattr(ed, "edi", None)
    if edi is not None and hasattr(edi, "Z"):
        return edi
    return ed


def _rho_and_phase(
    z_block: np.ndarray,
    freq: np.ndarray,
) -> tuple:
    """
    Cagniard ρ_xy, ρ_yx, φ_xy, φ_yx from Z tensor (eqs 17–18, wang2017).

    ρ_pq = 0.2|Z_pq|²/f,  φ_pq = arctan(Im(Z_pq)/Re(Z_pq)), for Z in
    practical units (mV/km per nT) — matches the convention used by
    :mod:`pycsamt.emtools.csumt`'s ``_rho_a_det``. This used to divide
    by ``ωμ₀`` instead, which assumes Z is in SI ohms and is wrong by a
    ~10^5-10^6 factor for the practical-unit Z stored in EDI files;
    ``ratio_log10 = log10(ρ_xy/ρ_yx)`` was unaffected since the missing
    factor cancels in the ratio, but the absolute ``rho_xy_ohmm`` /
    ``rho_yx_ohmm`` columns were wrong.
    """
    Zxy = z_block[:, 0, 1]
    Zyx = z_block[:, 1, 0]

    rho_xy = 0.2 * np.abs(Zxy) ** 2 / np.maximum(freq, 1e-24)
    rho_yx = 0.2 * np.abs(Zyx) ** 2 / np.maximum(freq, 1e-24)
    phi_xy = np.angle(Zxy, deg=True)
    phi_yx = np.angle(Zyx, deg=True)
    return rho_xy, rho_yx, phi_xy, phi_yx


def _swift_skew(z_block: np.ndarray) -> np.ndarray:
    """
    Swift (1967) skew: S = |Z_xx − Z_yy| / |Z_xy + Z_yx|.

    S = 0 for a 1-D / 2-D isotropic earth; S > 0 indicates 3-D structure
    or anisotropy.
    """
    Zxx = z_block[:, 0, 0]
    Zxy = z_block[:, 0, 1]
    Zyx = z_block[:, 1, 0]
    Zyy = z_block[:, 1, 1]
    denom = np.abs(Zxy + Zyx)
    with np.errstate(divide="ignore", invalid="ignore"):
        return np.where(denom < 1e-30, 0.0, np.abs(Zxx - Zyy) / denom)


def _swift_strike(z_block: np.ndarray) -> np.ndarray:
    """
    Per-frequency Swift strike angle [degrees] from Z tensor rotation.

    Minimises the diagonal elements of the rotated Z tensor:
        tan(2θ) = 2 Re[(Z_xy + Z_yx)(Z_xx − Z_yy)*]
                  / (|Z_xy + Z_yx|² − |Z_xx − Z_yy|²)
    """
    Zxx = z_block[:, 0, 0]
    Zxy = z_block[:, 0, 1]
    Zyx = z_block[:, 1, 0]
    Zyy = z_block[:, 1, 1]

    alpha = Zxy + Zyx  # sum of off-diagonal
    delta = Zxx - Zyy  # difference of diagonal

    num = 2.0 * np.real(alpha * np.conj(delta))
    denom = np.abs(alpha) ** 2 - np.abs(delta) ** 2

    theta = 0.5 * np.arctan2(num, denom)
    return np.rad2deg(theta)


# ─────────────────────────────────────────────────────────────────────────────
# Public: sites-based
# ─────────────────────────────────────────────────────────────────────────────


[docs] def analyze_anisotropy( sites: Any, *, ratio_threshold: float = ANISO_RATIO_THRESH, skew_threshold: float = SWIFT_SKEW_THRESH, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> pd.DataFrame: """ Per-frequency anisotropy metrics for a set of CSAMT sites. Computes the two Cagniard apparent resistivities ρ_xy and ρ_yx (wang2017 eqs 17–18), their log-ratio Λ = log₁₀(ρ_xy/ρ_yx), the phase difference, and the Swift skew from the full Z tensor. Parameters ---------- sites : Sites | list EDI-like objects or a ``Sites`` container. ratio_threshold : float |log₁₀(Λ)| above which the anisotropy flag is raised (default 0.1). skew_threshold : float Swift skew above which 3-D / anisotropy is suspected (default 0.2). recursive, on_dup, strict, verbose Forwarded to :func:`ensure_sites`. Returns ------- pd.DataFrame Columns: station, freq_hz, period_s, rho_xy_ohmm, rho_yx_ohmm, phi_xy_deg, phi_yx_deg, ratio_log10, phase_diff_deg, swift_skew, strike_deg. Notes ----- ``ratio_log10 = 0`` and ``swift_skew = 0`` indicate a perfectly isotropic 1-D earth. Non-zero diagonal Z elements (contributing to swift_skew > 0) suggest 3-D structure or electrical anisotropy (wang2017 §5.3). """ sites = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) rows: list[dict] = [] for i, ed in enumerate(_iter_items(sites)): ed = _unwrap(ed) station = _name(ed, i) _, z_block, freqs = _get_z_block(ed) if z_block is None or freqs is None or freqs.size == 0: continue rho_xy, rho_yx, phi_xy, phi_yx = _rho_and_phase(z_block, freqs) skew = _swift_skew(z_block) strike = _swift_strike(z_block) for j in range(freqs.size): f = float(freqs[j]) rxy = float(rho_xy[j]) ryx = float(rho_yx[j]) if rxy > 0 and ryx > 0: ratio = float(np.log10(rxy / ryx)) else: ratio = np.nan rows.append( { "station": station, "freq_hz": f, "period_s": 1.0 / f if f > 0 else np.nan, "rho_xy_ohmm": rxy, "rho_yx_ohmm": ryx, "phi_xy_deg": float(phi_xy[j]), "phi_yx_deg": float(phi_yx[j]), "ratio_log10": ratio, "phase_diff_deg": float(phi_xy[j] - phi_yx[j]), "swift_skew": float(skew[j]), "strike_deg": float(strike[j]), } ) if not rows: return pd.DataFrame(columns=_DETAIL_COLS) return pd.DataFrame(rows, columns=_DETAIL_COLS)
[docs] def anisotropy_table( sites: Any, *, ratio_threshold: float = ANISO_RATIO_THRESH, skew_threshold: float = SWIFT_SKEW_THRESH, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> pd.DataFrame: """ Per-station summary of anisotropy metrics. Parameters ---------- sites : Sites | list ratio_threshold : float |log₁₀(Λ)| threshold for anisotropy flag (default 0.1). skew_threshold : float Swift skew threshold for anisotropy flag (default 0.2). Returns ------- pd.DataFrame Columns: station, n_freq, mean_ratio_log10, max_abs_ratio_log10, mean_phase_diff_deg, mean_swift_skew, median_strike_deg, anisotropy_flag. ``anisotropy_flag`` is True when ``|mean_ratio_log10| > ratio_threshold`` OR ``mean_swift_skew > skew_threshold``. """ detail = analyze_anisotropy( sites, ratio_threshold=ratio_threshold, skew_threshold=skew_threshold, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) if detail.empty: return pd.DataFrame(columns=_TABLE_COLS) rows: list[dict] = [] for station, grp in detail.groupby("station", sort=False): ratio = grp["ratio_log10"].dropna() pdiff = grp["phase_diff_deg"].dropna() skew = grp["swift_skew"].dropna() strike = grp["strike_deg"].dropna() mean_ratio = float(ratio.mean()) if len(ratio) else np.nan max_abs_r = float(ratio.abs().max()) if len(ratio) else np.nan mean_pdiff = float(pdiff.mean()) if len(pdiff) else np.nan mean_skew = float(skew.mean()) if len(skew) else np.nan med_strike = float(strike.median()) if len(strike) else np.nan flag = bool( (np.isfinite(mean_ratio) and abs(mean_ratio) > ratio_threshold) or (np.isfinite(mean_skew) and mean_skew > skew_threshold) ) rows.append( { "station": station, "n_freq": len(grp), "mean_ratio_log10": mean_ratio, "max_abs_ratio_log10": max_abs_r, "mean_phase_diff_deg": mean_pdiff, "mean_swift_skew": mean_skew, "median_strike_deg": med_strike, "anisotropy_flag": flag, } ) return pd.DataFrame(rows, columns=_TABLE_COLS)
[docs] def plot_anisotropy( sites: Any, *, metric: str = "ratio_log10", ratio_threshold: float = ANISO_RATIO_THRESH, skew_threshold: float = SWIFT_SKEW_THRESH, cmap: str = "RdBu_r", figsize: tuple = (10, 5), period_axis: bool = True, log_y: bool = True, contour_zero: bool = True, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ax=None, ): """ Plot anisotropy metric pseudo-section (station × frequency). Parameters ---------- sites : Sites | list metric : str Column from :func:`analyze_anisotropy` to map to colour: ``"ratio_log10"`` (default), ``"swift_skew"``, ``"phase_diff_deg"``, or ``"strike_deg"``. ratio_threshold : float skew_threshold : float cmap : str Colormap (default ``"RdBu_r"`` — diverging, centred at 0). period_axis : bool Show period on y-axis (default) rather than frequency. log_y : bool Logarithmic y-axis. contour_zero : bool Draw a white contour at value = 0 (relevant for ratio_log10). ax : matplotlib.axes.Axes or None Returns ------- ax : matplotlib.axes.Axes """ import matplotlib.pyplot as plt from matplotlib.colors import Normalize df = analyze_anisotropy( sites, ratio_threshold=ratio_threshold, skew_threshold=skew_threshold, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) if ax is None: _, ax = plt.subplots(figsize=figsize) if df.empty or metric not in df.columns: ax.set_xlabel("Station") ax.set_ylabel("Period (s)" if period_axis else "Frequency (Hz)") ax.set_title(f"Anisotropy — {metric} (no data)") return ax stations = list(dict.fromkeys(df["station"])) s_idx = {s: k for k, s in enumerate(stations)} freqs_all = np.sort(df["freq_hz"].unique()) f_idx = {f: k for k, f in enumerate(freqs_all)} grid = np.full((len(freqs_all), len(stations)), np.nan) for _, row in df.iterrows(): fi = f_idx.get(row["freq_hz"]) si = s_idx.get(row["station"]) if fi is not None and si is not None: grid[fi, si] = row[metric] y_vals = 1.0 / freqs_all if period_axis else freqs_all if period_axis: order = np.argsort(y_vals) y_vals = y_vals[order] grid = grid[order] x_vals = np.arange(len(stations)) X, Y = np.meshgrid(x_vals, y_vals) valid = grid[np.isfinite(grid)] if len(valid) == 0: ax.set_title(f"Anisotropy — {metric} (all NaN)") return ax vmax = max(abs(valid.max()), abs(valid.min()), 1e-6) vmin = -vmax if metric == "ratio_log10" else valid.min() norm = Normalize(vmin=vmin, vmax=vmax) pcm = ax.pcolormesh(X, Y, grid, cmap=cmap, norm=norm, shading="nearest") cb = plt.colorbar(pcm, ax=ax) cb.set_label(_METRIC_LABELS.get(metric, metric)) if contour_zero and metric == "ratio_log10" and np.isfinite(grid).any(): if grid.shape[0] >= 2 and grid.shape[1] >= 2: ax.contour( X, Y, grid, levels=[0.0], colors="white", linewidths=1.2, linestyles="--", ) PYCSAMT_STATION_RENDERING.apply( ax, x_vals, stations, preset="pseudosection", xlim=(-0.5, len(stations) - 0.5), ) if log_y: ax.set_yscale("log") if period_axis: ax.set_ylabel("Period (s)") if not ax.yaxis_inverted(): ax.invert_yaxis() else: ax.set_ylabel("Frequency (Hz)") ax.set_title( f"Anisotropy: {_METRIC_LABELS.get(metric, metric)} (wang2017)" ) return ax
_METRIC_LABELS: dict = { "ratio_log10": "log₁₀(ρ_xy/ρ_yx)", "swift_skew": "Swift skew S", "phase_diff_deg": "Δφ = φ_xy − φ_yx (°)", "strike_deg": "Strike angle θ (°)", }