Source code for pycsamt.emtools.diag

"""
Polar uncertainty diagnostics for CSAMT impedance data.

Adapts the polar-based visualization framework from:
  kouadio2025 : Kouadio K.L. (2025), "k-diagram: Rethinking Forecasting
                Uncertainty via Polar-based Visualization",
                J. Open Source Softw. 10(116), 8661.
                DOI: 10.21105/joss.08661

The three core diagnostics from k-diagram are translated to CSAMT:

Coverage evaluation (kouadio2025 eq. 1):
    c_j = 1(L_j ≤ ρ_a,obs,j ≤ U_j)
    Binary coverage of per-frequency observed apparent resistivity within
    the predicted quantile interval [L_j, U_j].

Frequency-width drift (analogous to Horizon Drift, kouadio2025 Fig 2c):
    w̄_b = mean_{j ∈ band_b} (U_j − L_j) / ρ_a,obs,j × 100   [%]
    Mean relative interval width per frequency band — tracks how
    prediction uncertainty grows with period (a proxy for depth).

Relative-error polar histogram (analogous to polar violin, Fig 2b):
    ε_j = (ρ_a,pred,j − ρ_a,obs,j) / ρ_a,obs,j × 100   [%]
    Rose diagram of residuals binned by frequency decade.
"""

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

__all__ = [
    "COVERAGE_THRESH",
    "coverage_score",
    "rho_coverage",
    "rho_error_stats",
    "coverage_table",
    "plot_polar_coverage",
    "plot_polar_errors",
    "plot_width_drift",
]

COVERAGE_THRESH: float = 0.9  # default nominal probability level


# ─────────────────────────────────────────────────────────────────────────────
# 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_a_from_z(
    z_block: np.ndarray,
    freqs: np.ndarray,
    comp: str,
) -> np.ndarray:
    """Cagniard ρ_a = 0.2 |Z_pq|² / f, 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.
    """
    if comp == "xy":
        Z = z_block[:, 0, 1]
    elif comp == "yx":
        Z = z_block[:, 1, 0]
    else:
        raise ValueError(f"rho_comp must be 'xy' or 'yx', got {comp!r}")
    return 0.2 * np.abs(Z) ** 2 / np.maximum(freqs, 1e-24)


def _fmt_hz(f: float) -> str:
    """Format a frequency compactly, avoiding scientific notation."""
    if f >= 1000.0:
        return f"{f / 1000.0:.3g} kHz"
    return f"{f:.3g} Hz"


def _resolve_bounds(
    bounds: dict | np.ndarray | float,
    station: str,
    n: int,
) -> np.ndarray | None:
    """Return per-frequency bound array for *station*, or None if missing."""
    if isinstance(bounds, dict):
        arr = bounds.get(station)
        if arr is None:
            return None
        return np.asarray(arr, dtype=float)
    arr = np.asarray(bounds, dtype=float)
    return np.full(n, float(arr)) if arr.ndim == 0 else arr


def _rho_dict_from_sites(sites_obj: Any, comp: str) -> dict[str, np.ndarray]:
    """Build {station: rho_a_array} from a sites-like object."""
    result: dict[str, np.ndarray] = {}
    try:
        for i, ed in enumerate(_iter_items(sites_obj)):
            ed = _unwrap(ed)
            _, z_block, freqs = _get_z_block(ed)
            if z_block is None or freqs is None or freqs.size == 0:
                continue
            station = _name(ed, i)
            result[station] = _rho_a_from_z(z_block, freqs, comp)
    except (TypeError, AttributeError):
        pass
    return result


# ─────────────────────────────────────────────────────────────────────────────
# Pure-math helper — no sites dependency
# ─────────────────────────────────────────────────────────────────────────────


[docs] def coverage_score( y_true: np.ndarray | float, y_lo: np.ndarray | float, y_hi: np.ndarray | float, ) -> float: """ Empirical coverage fraction of a prediction interval (kouadio2025 eq. 1). Parameters ---------- y_true : array-like Observed values. y_lo, y_hi : array-like Lower and upper bounds of the predicted interval. Returns ------- cov : float Fraction of observations that fall inside [y_lo, y_hi] ∈ [0, 1]. """ y = np.asarray(y_true, dtype=float).ravel() lo = np.asarray(y_lo, dtype=float).ravel() hi = np.asarray(y_hi, dtype=float).ravel() return float(np.mean((y >= lo) & (y <= hi)))
# ───────────────────────────────────────────────────────────────────────────── # Sites-based: per-frequency DataFrames # ─────────────────────────────────────────────────────────────────────────────
[docs] def rho_coverage( sites: Any, q_lo: dict[str, np.ndarray] | np.ndarray | float, q_hi: dict[str, np.ndarray] | np.ndarray | float, *, rho_comp: str = "xy", recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> pd.DataFrame: """ Per-frequency coverage of observed ρ_a within predicted quantile bounds. For each site and frequency, checks whether the Cagniard apparent resistivity extracted from the observed Z tensor falls inside the predicted interval [L_j, U_j] (kouadio2025 eq. 1): c_j = 1(L_j ≤ ρ_a,obs,j ≤ U_j) Parameters ---------- sites : Sites | list Observed CSAMT sites. q_lo, q_hi : dict {station: array} or array or scalar Lower / upper quantile bounds aligned with each site's frequency array. When a dict, keys must match station names; sites without a key are skipped. A scalar broadcasts to every frequency of every site. rho_comp : {"xy", "yx"} Impedance component used to derive ρ_a (default ``"xy"``). recursive, on_dup, strict, verbose Forwarded to :func:`ensure_sites`. Returns ------- pd.DataFrame Columns: station, freq_hz, period_s, rho_obs, q_lo, q_hi, covered, width_pct. ``covered`` is bool; ``width_pct`` = 100 × (q_hi−q_lo) / ρ_obs. """ _COLS = [ "station", "freq_hz", "period_s", "rho_obs", "q_lo", "q_hi", "covered", "width_pct", ] 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_obs = _rho_a_from_z(z_block, freqs, rho_comp) lo = _resolve_bounds(q_lo, station, freqs.size) hi = _resolve_bounds(q_hi, station, freqs.size) if lo is None or hi is None: continue for j in range(freqs.size): f = float(freqs[j]) r_o = float(rho_obs[j]) l_j = float(lo[j]) h_j = float(hi[j]) w = 100.0 * (h_j - l_j) / r_o if r_o > 0 else np.nan rows.append( { "station": station, "freq_hz": f, "period_s": 1.0 / f if f > 0 else np.nan, "rho_obs": r_o, "q_lo": l_j, "q_hi": h_j, "covered": bool(l_j <= r_o <= h_j), "width_pct": w, } ) if not rows: return pd.DataFrame(columns=_COLS) return pd.DataFrame(rows, columns=_COLS)
[docs] def rho_error_stats( sites: Any, model_rho: dict[str, np.ndarray] | Any, *, rho_comp: str = "xy", recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> pd.DataFrame: """ Per-frequency relative error between observed and predicted ρ_a. Computes ε_j = (ρ_a,pred,j − ρ_a,obs,j) / ρ_a,obs,j × 100 % for each station and frequency, analogous to the error distribution visualised in the k-diagram polar violin (kouadio2025 Fig 2b). Parameters ---------- sites : Sites | list Observed CSAMT sites. model_rho : dict {station: array} or Sites-like Predicted apparent resistivity. Either a dict mapping station names to 1-D arrays (same length as the corresponding site's frequency array), or a Sites-like container from which ρ_a is extracted with the same ``rho_comp`` setting. rho_comp : {"xy", "yx"} Impedance component. recursive, on_dup, strict, verbose Forwarded to :func:`ensure_sites`. Returns ------- pd.DataFrame Columns: station, freq_hz, period_s, rho_obs, rho_pred, rel_err_pct, abs_err_pct. """ _COLS = [ "station", "freq_hz", "period_s", "rho_obs", "rho_pred", "rel_err_pct", "abs_err_pct", ] sites = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) if isinstance(model_rho, dict): pred_dict = { k: np.asarray(v, dtype=float) for k, v in model_rho.items() } else: pred_dict = _rho_dict_from_sites(model_rho, rho_comp) 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_obs = _rho_a_from_z(z_block, freqs, rho_comp) rho_pred = pred_dict.get(station) if rho_pred is None: continue for j in range(freqs.size): f = float(freqs[j]) r_o = float(rho_obs[j]) r_p = float(rho_pred[j]) if j < len(rho_pred) else np.nan if r_o > 0 and np.isfinite(r_p): rel_err = 100.0 * (r_p - r_o) / r_o abs_err = abs(rel_err) else: rel_err = abs_err = np.nan rows.append( { "station": station, "freq_hz": f, "period_s": 1.0 / f if f > 0 else np.nan, "rho_obs": r_o, "rho_pred": r_p, "rel_err_pct": rel_err, "abs_err_pct": abs_err, } ) if not rows: return pd.DataFrame(columns=_COLS) return pd.DataFrame(rows, columns=_COLS)
[docs] def coverage_table( sites: Any, q_lo: dict[str, np.ndarray] | np.ndarray | float, q_hi: dict[str, np.ndarray] | np.ndarray | float, *, rho_comp: str = "xy", nominal: float = COVERAGE_THRESH, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> pd.DataFrame: """ Per-station coverage summary. Parameters ---------- nominal : float Target coverage probability (e.g. 0.9 for a 90 % interval). ``calibrated_flag`` is True when the empirical coverage ≥ nominal. Returns ------- pd.DataFrame Columns: station, n_freq, empirical_cov, mean_width_pct, calibrated_flag. """ _COLS = [ "station", "n_freq", "empirical_cov", "mean_width_pct", "calibrated_flag", ] detail = rho_coverage( sites, q_lo, q_hi, rho_comp=rho_comp, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) if detail.empty: return pd.DataFrame(columns=_COLS) rows: list[dict] = [] for station, grp in detail.groupby("station", sort=False): emp = float(grp["covered"].mean()) w = ( float(grp["width_pct"].dropna().mean()) if grp["width_pct"].notna().any() else np.nan ) rows.append( { "station": station, "n_freq": len(grp), "empirical_cov": emp, "mean_width_pct": w, "calibrated_flag": bool(emp >= nominal), } ) return pd.DataFrame(rows, columns=_COLS)
# ───────────────────────────────────────────────────────────────────────────── # Plots # ─────────────────────────────────────────────────────────────────────────────
[docs] def plot_polar_coverage( sites: Any, q_lo: dict | np.ndarray | float, q_hi: dict | np.ndarray | float, *, rho_comp: str = "xy", log_radius: bool = True, n_freq_ticks: int = 8, figsize: tuple = (7, 7), title: str = "Coverage evaluation", recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ax=None, ): """ Polar coverage plot: angle ∝ log₁₀(f), radius ∝ ρ_a,obs. Green markers = observed ρ_a within predicted interval (covered); red = outside. Thin radial segments show each [q_lo, q_hi] range. Every station shares (almost) the same frequency grid, so each angular position is really one frequency shared by every station — the angle axis is labelled with that frequency directly (rather than the otherwise-meaningless default degree ticks) so a reader can tell which part of the band a cluster of misses falls in. Parameters ---------- n_freq_ticks : int, default 8 Number of evenly (log-)spaced frequency labels drawn around the ring. Set to 0 to fall back to the default degree ticks. Returns ------- ax : matplotlib.axes.Axes (polar projection) """ import matplotlib.pyplot as plt df = rho_coverage( sites, q_lo, q_hi, rho_comp=rho_comp, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) if ax is None: _, ax = plt.subplots( subplot_kw={"projection": "polar"}, figsize=figsize ) hide_polar_radius_labels(ax) if df.empty: ax.set_title(title + " (no data)") return ax f_vals = df["freq_hz"].values f_min, f_max = f_vals.min(), f_vals.max() if f_max <= f_min: f_max = f_min + 1.0 log_f_min, log_f_max = np.log10(f_min), np.log10(f_max) log_f = np.log10(f_vals) theta = 2.0 * np.pi * (log_f - log_f_min) / (log_f_max - log_f_min) def _r(v): return np.log10(np.maximum(v, 1e-30)) if log_radius else v r_obs = _r(df["rho_obs"].values) r_lo = _r(df["q_lo"].values) r_hi = _r(df["q_hi"].values) covered = df["covered"].values ax.scatter( theta[covered], r_obs[covered], c="green", s=18, alpha=0.8, zorder=3, label="covered", ) ax.scatter( theta[~covered], r_obs[~covered], c="red", s=18, alpha=0.8, zorder=3, label="not covered", ) for t, lo, hi in zip(theta, r_lo, r_hi): ax.plot([t, t], [lo, hi], color="gray", lw=0.5, alpha=0.35, zorder=1) if n_freq_ticks > 0: tick_theta = np.linspace( 0.0, 2.0 * np.pi, n_freq_ticks, endpoint=False ) tick_freq = 10.0 ** ( log_f_min + (tick_theta / (2.0 * np.pi)) * (log_f_max - log_f_min) ) ax.set_xticks(tick_theta) ax.set_xticklabels([_fmt_hz(f) for f in tick_freq], fontsize=8) emp_cov = float(covered.mean()) hide_polar_radius_labels(ax) ax.set_title(f"{title}\ncoverage = {emp_cov:.3f}", pad=15) ax.legend(loc="lower right", fontsize=7) return ax
[docs] def plot_polar_errors( sites: Any, model_rho: dict[str, np.ndarray] | Any, *, rho_comp: str = "xy", n_bins: int = 18, figsize: tuple = (7, 7), title: str = "Error distribution", recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ax=None, ): """ Polar rose diagram of relative residuals (ε = (ρ_pred − ρ_obs)/ρ_obs × 100 %). Each angular sector spans one frequency decade. Bar length = mean |ε| within that sector; red = over-prediction (mean ε > 0), blue = under. Analogous to the polar violin in kouadio2025 Fig 2b. Returns ------- ax : matplotlib.axes.Axes (polar projection) """ import matplotlib.pyplot as plt df = rho_error_stats( sites, model_rho, rho_comp=rho_comp, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) if ax is None: _, ax = plt.subplots( subplot_kw={"projection": "polar"}, figsize=figsize ) hide_polar_radius_labels(ax) if df.empty or df["rel_err_pct"].isna().all(): ax.set_title(title + " (no data)") return ax f_vals = df["freq_hz"].values f_min, f_max = f_vals.min(), f_vals.max() if f_max <= f_min: f_max = f_min + 1.0 log_f = np.log10(f_vals) bins = np.linspace(np.log10(f_min), np.log10(f_max), n_bins + 1) theta = np.deg2rad(np.linspace(0, 360, n_bins, endpoint=False)) bw = 2.0 * np.pi / n_bins errs = df["rel_err_pct"].values heights = np.zeros(n_bins) signs = np.zeros(n_bins) for b in range(n_bins): mask = (log_f >= bins[b]) & (log_f < bins[b + 1]) if mask.any(): signs[b] = float(np.sign(np.nanmean(errs[mask]))) heights[b] = float(np.nanmean(np.abs(errs[mask]))) colors = ["#e74c3c" if s >= 0 else "#2980b9" for s in signs] ax.bar( theta, heights, width=bw * 0.85, color=colors, alpha=0.72, bottom=0 ) hide_polar_radius_labels(ax) ax.set_title(f"{title}\nred=over-pred, blue=under-pred", pad=15) return ax
[docs] def plot_width_drift( sites: Any, q_lo: dict | np.ndarray | float, q_hi: dict | np.ndarray | float, *, rho_comp: str = "xy", n_bands: int = 8, polar: bool = False, figsize: tuple = (8, 4), title: str = "Frequency-width drift", recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ax=None, ): """ Mean relative interval width per frequency band (horizon-drift analogue). Visualises how the predicted interval width — relative to observed ρ_a — changes across the frequency spectrum, a proxy for how model uncertainty grows with probing depth (kouadio2025 § Forecast Horizon Drift). Parameters ---------- n_bands : int Number of frequency bands. polar : bool Radial bar chart (k-diagram style) when True; Cartesian otherwise. Returns ------- ax : matplotlib.axes.Axes """ import matplotlib.pyplot as plt df = rho_coverage( sites, q_lo, q_hi, rho_comp=rho_comp, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) if ax is None: if polar: _, ax = plt.subplots( subplot_kw={"projection": "polar"}, figsize=figsize ) else: _, ax = plt.subplots(figsize=figsize) if polar: hide_polar_radius_labels(ax) if df.empty: ax.set_title(title + " (no data)") return ax log_f = np.log10(df["freq_hz"].values) f_min, f_max = log_f.min(), log_f.max() if f_max <= f_min: f_max = f_min + 1.0 bins = np.linspace(f_min, f_max, n_bands + 1) centers = 10.0 ** (0.5 * (bins[:-1] + bins[1:])) widths_pct = df["width_pct"].values mean_w = np.zeros(n_bands) for b in range(n_bands): mask = (log_f >= bins[b]) & (log_f < bins[b + 1]) if mask.any(): mean_w[b] = float(np.nanmean(widths_pct[mask])) labels = [f"{c:.2g}" for c in centers] if polar: theta = np.linspace(0, 2.0 * np.pi, n_bands, endpoint=False) bw = 2.0 * np.pi / n_bands ax.bar(theta, mean_w, width=bw * 0.85, alpha=0.78) ax.set_xticks(theta) ax.set_xticklabels(labels, fontsize=7) hide_polar_radius_labels(ax) else: x = np.arange(n_bands) ax.bar(x, mean_w, color="#3498db", alpha=0.8) ax.set_xticks(x) ax.set_xticklabels(labels, rotation=45, ha="right", fontsize=8) ax.set_xlabel("Frequency band centre [Hz]") ax.set_ylabel("Mean interval width [% of ρ_a]") ax.grid(True, linestyle=":", alpha=0.5) ax.set_title(title) return ax