"""Quality-control confidence ratios for EM transfer functions.
The composite confidence ratio (CR) used by this module is a bounded,
weighted score:
CR = sum_k w_k s_k / sum_k w_k, for finite component scores s_k.
The default components are data coverage, tensor uncertainty,
off-diagonal consistency, diagonal leakage, phase smoothness, and spatial
coherence. Each score is clipped to [0, 1], where 1 is most trustworthy.
The default manuscript classes are CR >= 0.95 (safe), 0.85 <= CR < 0.95
(recoverable/marginal), and CR < 0.85 (reject/review).
"""
from __future__ import annotations
import copy
from typing import Any
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib.patches import Rectangle as _Rect
from ..api.labels import LOG10_PERIOD_LABEL
from ..api.section import PYCSAMT_SECTION, SectionStyle
from ..api.station import (
PYCSAMT_STATION_RENDERING,
StationAxisStyle,
)
from ..api.view import maybe_wrap_frame
from ._core import (
_axes_list,
_get_t_block,
_get_z_block,
_iter_items,
_name,
_station_positions,
ensure_sites,
)
from .tensor import build_phase_tensor_table
__all__ = [
"build_qc_table",
"confidence_ratio",
"frequency_confidence_table",
"plot_confidence_band_summary",
"plot_confidence_profile",
"plot_frequency_confidence_psection",
"plot_station_confidence_dashboard",
"plot_station_confidence_spectrum",
"qc_flags",
"station_confidence_table",
]
DEFAULT_CONFIDENCE_WEIGHTS: dict[str, float] = {
"coverage": 0.35,
"uncertainty": 0.20,
"offdiag": 0.15,
"diagonal": 0.10,
"phase": 0.10,
"spatial": 0.10,
}
DEFAULT_CI_HI = 0.95
DEFAULT_CI_LO = 0.85
# ------------------------------ helpers --------------------------------- #
def _resolve_section_style(section: str | SectionStyle) -> SectionStyle:
"""Return a copied section style for EMTools pseudo-sections."""
if isinstance(section, SectionStyle):
return section.copy()
return PYCSAMT_SECTION.style_for(str(section)).copy()
def _row_ok_z(z: np.ndarray) -> np.ndarray:
y = z.reshape(z.shape[0], -1)
return np.isfinite(y).all(axis=1)
def _row_ok_t(t: np.ndarray) -> np.ndarray:
y = t.reshape(t.shape[0], -1)
return np.isfinite(y).all(axis=1)
def _row_nanmedian(values: np.ndarray) -> np.ndarray:
"""Return row medians without warning for all-NaN rows."""
out = np.full(values.shape[0], np.nan, dtype=float)
valid_rows = np.isfinite(values).any(axis=1)
if valid_rows.any():
out[valid_rows] = np.nanmedian(values[valid_rows], axis=1)
return out
def _snr_rows(z: np.ndarray, ze: np.ndarray | None) -> np.ndarray:
if ze is None:
return np.full(z.shape[0], np.nan, dtype=float)
a = np.sqrt(np.nanmean(np.abs(z) ** 2, axis=(1, 2)))
e = np.sqrt(np.nanmean(np.abs(ze) ** 2, axis=(1, 2)))
return a / (e + 1e-12)
def _offdiag_logmag(z: np.ndarray) -> np.ndarray:
m = _row_nanmedian(
np.stack([np.abs(z[:, 0, 1]), np.abs(z[:, 1, 0])], axis=1),
)
return np.log10(np.maximum(m, 1e-24))
def _clip01(x: Any) -> float:
"""Return finite scalar clipped to the confidence interval."""
try:
value = float(x)
except (TypeError, ValueError):
return np.nan
if not np.isfinite(value):
return np.nan
return float(np.clip(value, 0.0, 1.0))
def _weighted_nanmean(
values: dict[str, float], weights: dict[str, float]
) -> float:
"""Return weighted mean ignoring unavailable metrics."""
total = 0.0
weight = 0.0
for key, value in values.items():
value = _clip01(value)
w = float(weights.get(key, 0.0))
if np.isfinite(value) and w > 0.0:
total += w * value
weight += w
return float(total / weight) if weight > 0.0 else np.nan
def _confidence_error(
values: dict[str, float], n_freq: int, confidence: float
) -> float:
"""Estimate a compact station-level confidence uncertainty."""
vals = np.asarray(
[_clip01(value) for value in values.values()],
dtype=float,
)
vals = vals[np.isfinite(vals)]
if vals.size > 1:
return float(np.nanstd(vals, ddof=0))
confidence = _clip01(confidence)
if not np.isfinite(confidence):
return np.nan
n_freq = max(1, int(n_freq))
return float(np.sqrt(confidence * (1.0 - confidence) / n_freq))
[docs]
def confidence_ratio(
scores: dict[str, float],
*,
weights: dict[str, float] | None = None,
n_freq: int = 1,
return_error: bool = False,
) -> float | tuple[float, float]:
r"""Compute the composite confidence ratio from diagnostic scores.
The confidence ratio is a weighted finite-score mean:
.. math::
\mathrm{CR} =
\frac{\sum_k w_k s_k \mathbf{1}_{s_k\ finite}}
{\sum_k w_k \mathbf{1}_{s_k\ finite}},
\qquad 0 \leq s_k \leq 1.
The default score vector is
``coverage, uncertainty, offdiag, diagonal, phase, spatial`` with
weights ``0.35, 0.20, 0.15, 0.10, 0.10, 0.10``. Missing scores are
ignored and all finite scores are clipped to ``[0, 1]``.
The optional error is the population spread of available component
scores; when only one score is available it falls back to the binomial
standard error ``sqrt(CR * (1 - CR) / n_freq)``.
"""
use_weights = {**DEFAULT_CONFIDENCE_WEIGHTS, **(weights or {})}
cr = _weighted_nanmean(scores, use_weights)
if return_error:
return cr, _confidence_error(scores, n_freq, cr)
return cr
def _relerr_score(
z: np.ndarray, ze: np.ndarray | None, threshold: float
) -> float:
"""Score tensor uncertainty from median relative error."""
if ze is None:
return np.nan
rel = np.abs(ze) / (np.abs(z) + 1e-24)
if not np.isfinite(rel).any():
return np.nan
med = float(np.nanmedian(rel))
return _clip01(1.0 - med / max(float(threshold), 1e-12))
def _offdiag_consistency_score(
z: np.ndarray, tolerance_log10: float
) -> float:
"""Score similarity of ``Zxy`` and ``Zyx`` amplitudes."""
zxy = np.abs(z[:, 0, 1])
zyx = np.abs(z[:, 1, 0])
ratio = np.log10((zxy + 1e-24) / (zyx + 1e-24))
if not np.isfinite(ratio).any():
return np.nan
med = float(np.nanmedian(np.abs(ratio)))
return _clip01(1.0 - med / max(float(tolerance_log10), 1e-12))
def _diagonal_leakage_score(z: np.ndarray, max_fraction: float) -> float:
"""Score how much diagonal impedance leaks into off-diagonal terms."""
diag = _row_nanmedian(
np.stack([np.abs(z[:, 0, 0]), np.abs(z[:, 1, 1])], axis=1),
)
off = _row_nanmedian(
np.stack([np.abs(z[:, 0, 1]), np.abs(z[:, 1, 0])], axis=1),
)
frac = diag / (off + diag + 1e-24)
if not np.isfinite(frac).any():
return np.nan
med = float(np.nanmedian(frac))
return _clip01(1.0 - med / max(float(max_fraction), 1e-12))
def _phase_smoothness_score(
z: np.ndarray, jump_tolerance_deg: float
) -> float:
"""Score abrupt phase jumps in the off-diagonal components."""
phases = []
for comp in (z[:, 0, 1], z[:, 1, 0]):
if not np.isfinite(comp).any():
continue
ph = np.unwrap(np.angle(comp))
if ph.size > 1:
jumps = np.rad2deg(np.abs(np.diff(ph)))
if np.isfinite(jumps).any():
phases.append(jumps)
if not phases:
return np.nan
phase_jumps = np.concatenate(phases)
if not np.isfinite(phase_jumps).any():
return np.nan
med_jump = float(np.nanmedian(phase_jumps))
return _clip01(1.0 - med_jump / max(float(jump_tolerance_deg), 1e-12))
def _station_spatial_scores(
med_logrho: np.ndarray,
tolerance_log10: float,
) -> np.ndarray:
"""Score station coherence against immediate neighboring stations."""
scores = np.full(med_logrho.size, np.nan, dtype=float)
for i, value in enumerate(med_logrho):
neighbors = []
if i > 0 and np.isfinite(med_logrho[i - 1]):
neighbors.append(med_logrho[i - 1])
if i + 1 < med_logrho.size and np.isfinite(med_logrho[i + 1]):
neighbors.append(med_logrho[i + 1])
if not neighbors or not np.isfinite(value):
continue
ref = float(np.nanmedian(neighbors))
diff = abs(float(value) - ref)
scores[i] = _clip01(1.0 - diff / max(float(tolerance_log10), 1e-12))
return scores
def _frequency_spatial_scores(
table: pd.DataFrame,
tolerance_log10: float,
) -> np.ndarray:
"""Score frequency samples against same-frequency neighbor stations."""
scores = np.full(len(table), np.nan, dtype=float)
if table.empty:
return scores
for _, group in table.groupby("frequency_hz", sort=False):
order = group.sort_values("distance_m")
idx = order.index.to_numpy(dtype=int)
values = order["logrho_proxy"].to_numpy(dtype=float)
for j, row_index in enumerate(idx):
neighbors = []
if j > 0 and np.isfinite(values[j - 1]):
neighbors.append(values[j - 1])
if j + 1 < values.size and np.isfinite(values[j + 1]):
neighbors.append(values[j + 1])
if not neighbors or not np.isfinite(values[j]):
continue
ref = float(np.nanmedian(neighbors))
diff = abs(float(values[j]) - ref)
scores[row_index] = _clip01(
1.0 - diff / max(float(tolerance_log10), 1e-12),
)
return scores
def _frequency_phase_jump_score(
z: np.ndarray,
jump_tolerance_deg: float,
) -> np.ndarray:
"""Return per-frequency smoothness scores for off-diagonal phase."""
scores = np.full(z.shape[0], np.nan, dtype=float)
jumps = []
for comp in (z[:, 0, 1], z[:, 1, 0]):
if not np.isfinite(comp).any():
continue
phase = np.unwrap(np.angle(comp))
if phase.size < 2:
continue
local = np.full(phase.size, np.nan, dtype=float)
dphase = np.rad2deg(np.abs(np.diff(phase)))
local[:-1] = dphase
prior = local[1:].copy()
current = dphase
both = np.isfinite(prior) & np.isfinite(current)
only_current = ~np.isfinite(prior) & np.isfinite(current)
prior[both] = np.maximum(prior[both], current[both])
prior[only_current] = current[only_current]
local[1:] = prior
jumps.append(local)
if not jumps:
return scores
jump = _row_nanmedian(np.stack(jumps, axis=1))
valid = np.isfinite(jump)
scores[valid] = [
_clip01(1.0 - value / max(float(jump_tolerance_deg), 1e-12))
for value in jump[valid]
]
return scores
def _frequency_flags(row: pd.Series, ci_hi: float, ci_lo: float) -> str:
"""Return readable quality flags for one frequency-confidence row."""
flags = []
if row["confidence"] < ci_lo:
flags.append("reject")
elif row["confidence"] < ci_hi:
flags.append("recoverable")
if row["coverage"] < 1.0:
flags.append("missing")
if np.isfinite(row["uncertainty"]) and row["uncertainty"] < ci_lo:
flags.append("high_error")
if np.isfinite(row["offdiag"]) and row["offdiag"] < ci_lo:
flags.append("offdiag_mismatch")
if np.isfinite(row["diagonal"]) and row["diagonal"] < ci_lo:
flags.append("diagonal_leakage")
if np.isfinite(row["phase"]) and row["phase"] < ci_lo:
flags.append("phase_jump")
if np.isfinite(row["spatial"]) and row["spatial"] < ci_lo:
flags.append("spatial_outlier")
return ",".join(flags)
def _y_ticks(yall: np.ndarray, ny: int) -> tuple[np.ndarray, list[str]]:
yt = np.linspace(0, yall.size - 1, num=min(ny, yall.size))
yv = np.linspace(yall.min(), yall.max(), num=yt.size)
lab = [f"{v:.2g}" for v in yv]
return yt, lab
# ------------------------------ tables ---------------------------------- #
[docs]
def build_qc_table(
sites: Any,
*,
include_skew: bool = True,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
api: bool | None = None,
) -> Any:
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
pt = None
if include_skew:
pt = build_phase_tensor_table(
S,
recursive=False,
on_dup=on_dup,
strict=False,
verbose=verbose,
)
rows: list[dict[str, Any]] = []
for i, ed in enumerate(_iter_items(S)):
st = _name(ed, i)
Z, z, fr = _get_z_block(ed)
T, t, ft = _get_t_block(ed)
if Z is None:
continue
n = z.shape[0]
ko = _row_ok_z(z)
ze = getattr(Z, "z_err", None)
snr = _snr_rows(z, ze)
med_snr = float(np.nanmedian(snr))
n_ok = int(np.nansum(ko))
n_t = 0
n_t_ok = 0
if T is not None and t is not None:
n_t = t.shape[0]
n_t_ok = int(np.nansum(_row_ok_t(t)))
per = 1.0 / fr
pmin = float(np.nanmin(per)) if per.size else np.nan
pmax = float(np.nanmax(per)) if per.size else np.nan
rec = dict(
station=st,
n_freq=int(n),
n_ok=int(n_ok),
frac_ok=float(n_ok / max(1, n)),
n_tip=int(n_t),
n_tip_ok=int(n_t_ok),
snr_med=med_snr,
pmin=pmin,
pmax=pmax,
)
if include_skew and pt is not None:
sdf = pt[pt["station"] == st]
if not sdf.empty:
sb = np.abs(sdf["beta"].to_numpy(dtype=float))
rec["skew_med"] = float(np.nanmedian(sb))
rec["skew_iqr"] = float(
np.nanpercentile(sb, 75) - np.nanpercentile(sb, 25)
)
else:
rec["skew_med"] = np.nan
rec["skew_iqr"] = np.nan
rows.append(rec)
cols = [
"station",
"n_freq",
"n_ok",
"frac_ok",
"n_tip",
"n_tip_ok",
"snr_med",
"pmin",
"pmax",
]
if include_skew:
cols += ["skew_med", "skew_iqr"]
df = pd.DataFrame.from_records(rows, columns=cols)
return maybe_wrap_frame(
df,
api=api,
name="qc_table",
kind="emtools.qc",
source=sites,
description="Station-level transfer-function quality summary.",
)
[docs]
def qc_flags(
sites: Any,
*,
min_frac_ok: float = 0.6,
min_snr_med: float = 2.0,
max_skew_med: float = 6.0,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> pd.DataFrame:
tb = build_qc_table(
sites,
include_skew=True,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
if tb.empty:
return tb
flags = []
for _, r in tb.iterrows():
f = []
if float(r["frac_ok"]) < float(min_frac_ok):
f.append("low_coverage")
if np.isfinite(r["snr_med"]) and r["snr_med"] < min_snr_med:
f.append("low_snr")
if (
np.isfinite(r.get("skew_med", np.nan))
and r["skew_med"] > max_skew_med
):
f.append("high_skew")
flags.append(",".join(f))
out = tb.copy()
out["flags"] = flags
return out
[docs]
def station_confidence_table(
sites: Any,
*,
method: str = "composite",
weights: dict[str, float] | None = None,
relerr_threshold: float = 0.20,
offdiag_tolerance_log10: float = 0.35,
diagonal_leakage_max: float = 0.35,
phase_jump_tolerance_deg: float = 90.0,
spatial_tolerance_log10: float = 0.60,
spacing_m: float = 200.0,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
api: bool | None = None,
) -> Any:
"""Return station-level confidence scores for EM transfer functions.
``method="presence"`` reproduces the legacy criterion based only on
finite tensor rows. ``method="composite"`` combines several station
trust indicators: finite data coverage, tensor uncertainty when error
tensors exist, off-diagonal consistency, diagonal leakage, phase
smoothness, and spatial coherence with neighboring stations.
"""
method = str(method).lower()
if method not in {"presence", "composite"}:
msg = "method must be 'presence' or 'composite'."
raise ValueError(msg)
weights = {**DEFAULT_CONFIDENCE_WEIGHTS, **(weights or {})}
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
items = list(_iter_items(S))
positions = _station_positions(items, spacing_m)
rows: list[dict[str, Any]] = []
med_logrho = []
for i, ed in enumerate(items):
st = _name(ed, i)
Z, z, fr = _get_z_block(ed)
if Z is None or z is None or fr is None:
continue
ze = getattr(Z, "z_err", None)
ok = _row_ok_z(z)
coverage = float(np.nansum(ok) / max(1, z.shape[0]))
zxy = z[:, 0, 1]
zyx = z[:, 1, 0]
rho_proxy = 0.5 * (
np.log10(np.abs(zxy) ** 2 / np.maximum(fr, 1e-24) + 1e-24)
+ np.log10(np.abs(zyx) ** 2 / np.maximum(fr, 1e-24) + 1e-24)
)
med_logrho.append(float(np.nanmedian(rho_proxy)))
score_parts = {
"coverage": coverage,
"uncertainty": _relerr_score(z, ze, relerr_threshold),
"offdiag": _offdiag_consistency_score(
z,
offdiag_tolerance_log10,
),
"diagonal": _diagonal_leakage_score(
z,
diagonal_leakage_max,
),
"phase": _phase_smoothness_score(z, phase_jump_tolerance_deg),
}
confidence = coverage
if method == "composite":
confidence = confidence_ratio(score_parts, weights=weights)
error_parts = score_parts
else:
error_parts = {"coverage": coverage}
confidence_err = _confidence_error(
error_parts,
z.shape[0],
confidence,
)
rows.append(
dict(
station=st,
distance_m=float(positions[i])
if i < positions.size
else np.nan,
confidence=float(confidence),
confidence_err=float(confidence_err),
method=method,
n_freq=int(z.shape[0]),
n_ok=int(np.nansum(ok)),
coverage=score_parts["coverage"],
uncertainty=score_parts["uncertainty"],
offdiag=score_parts["offdiag"],
diagonal=score_parts["diagonal"],
phase=score_parts["phase"],
spatial=np.nan,
)
)
if not rows:
df = pd.DataFrame(
columns=[
"station",
"distance_m",
"confidence",
"method",
"confidence_err",
"n_freq",
"n_ok",
"coverage",
"uncertainty",
"offdiag",
"diagonal",
"phase",
"spatial",
]
)
return maybe_wrap_frame(
df,
api=api,
name="station_confidence_table",
kind="emtools.qc.station_confidence",
source=sites,
)
spatial_scores = _station_spatial_scores(
np.asarray(med_logrho, dtype=float),
spatial_tolerance_log10,
)
if method == "composite":
for i, row in enumerate(rows):
row["spatial"] = (
float(spatial_scores[i])
if i < spatial_scores.size
else np.nan
)
parts = {
key: row[key]
for key in (
"coverage",
"uncertainty",
"offdiag",
"diagonal",
"phase",
"spatial",
)
}
row["confidence"] = confidence_ratio(parts, weights=weights)
row["confidence_err"] = _confidence_error(
parts,
row["n_freq"],
row["confidence"],
)
else:
for i, row in enumerate(rows):
row["spatial"] = (
float(spatial_scores[i])
if i < spatial_scores.size
else np.nan
)
df = pd.DataFrame.from_records(rows)
return maybe_wrap_frame(
df,
api=api,
name="station_confidence_table",
kind="emtools.qc.station_confidence",
source=sites,
description="Station-level composite confidence scores.",
)
[docs]
def frequency_confidence_table(
sites: Any,
*,
method: str = "composite",
weights: dict[str, float] | None = None,
ci_hi: float = DEFAULT_CI_HI,
ci_lo: float = DEFAULT_CI_LO,
relerr_threshold: float = 0.20,
offdiag_tolerance_log10: float = 0.35,
diagonal_leakage_max: float = 0.35,
phase_jump_tolerance_deg: float = 90.0,
spatial_tolerance_log10: float = 0.60,
spacing_m: float = 200.0,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
api: bool | None = None,
) -> Any:
"""Return frequency-level confidence scores for EM stations.
The returned table has one row for each station-frequency sample. It is
designed as a reusable quality-control source for plots, masking rules,
and inversion-preparation reports. ``method="presence"`` scores only
finite impedance-tensor availability. ``method="composite"`` combines
coverage, tensor uncertainty, off-diagonal consistency, diagonal leakage,
phase smoothness, and same-frequency spatial coherence.
"""
method = str(method).lower()
if method not in {"presence", "composite"}:
msg = "method must be 'presence' or 'composite'."
raise ValueError(msg)
weights = {**DEFAULT_CONFIDENCE_WEIGHTS, **(weights or {})}
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
items = list(_iter_items(S))
positions = _station_positions(items, spacing_m)
rows: list[dict[str, Any]] = []
for station_index, ed in enumerate(items):
station = _name(ed, station_index)
Z, z, fr = _get_z_block(ed)
if Z is None or z is None or fr is None:
continue
ze = getattr(Z, "z_err", None)
z_abs = np.abs(z)
coverage = np.isfinite(z.reshape(z.shape[0], -1)).mean(axis=1)
zxy = z[:, 0, 1]
zyx = z[:, 1, 0]
logrho_proxy = 0.5 * (
np.log10(np.abs(zxy) ** 2 / np.maximum(fr, 1e-24) + 1e-24)
+ np.log10(np.abs(zyx) ** 2 / np.maximum(fr, 1e-24) + 1e-24)
)
uncertainty = np.full(z.shape[0], np.nan, dtype=float)
if ze is not None:
rel = np.abs(ze) / (z_abs + 1e-24)
rel_med = _row_nanmedian(rel.reshape(rel.shape[0], -1))
uncertainty = np.asarray(
[
_clip01(1.0 - value / max(float(relerr_threshold), 1e-12))
for value in rel_med
],
dtype=float,
)
ratio = np.log10((np.abs(zxy) + 1e-24) / (np.abs(zyx) + 1e-24))
offdiag = np.asarray(
[
_clip01(
1.0
- abs(value) / max(float(offdiag_tolerance_log10), 1e-12),
)
for value in ratio
],
dtype=float,
)
diag = _row_nanmedian(
np.stack([np.abs(z[:, 0, 0]), np.abs(z[:, 1, 1])], axis=1),
)
off = _row_nanmedian(
np.stack([np.abs(z[:, 0, 1]), np.abs(z[:, 1, 0])], axis=1),
)
frac = diag / (off + diag + 1e-24)
diagonal = np.asarray(
[
_clip01(
1.0 - value / max(float(diagonal_leakage_max), 1e-12),
)
for value in frac
],
dtype=float,
)
phase = _frequency_phase_jump_score(z, phase_jump_tolerance_deg)
for freq_index, freq in enumerate(fr):
parts = {
"coverage": float(coverage[freq_index]),
"uncertainty": float(uncertainty[freq_index]),
"offdiag": float(offdiag[freq_index]),
"diagonal": float(diagonal[freq_index]),
"phase": float(phase[freq_index]),
}
confidence = parts["coverage"]
if method == "composite":
confidence = confidence_ratio(parts, weights=weights)
error_parts = (
parts
if method == "composite"
else {
"coverage": parts["coverage"],
}
)
row = dict(
station=station,
station_index=int(station_index),
distance_m=(
float(positions[station_index])
if station_index < positions.size
else np.nan
),
frequency_hz=float(freq),
period_s=float(1.0 / freq) if freq else np.nan,
log10_period=(
float(np.log10(1.0 / freq)) if freq > 0 else np.nan
),
confidence=float(confidence),
confidence_err=_confidence_error(error_parts, 1, confidence),
method=method,
n_components=int(np.isfinite(z[freq_index]).sum()),
coverage=parts["coverage"],
uncertainty=parts["uncertainty"],
offdiag=parts["offdiag"],
diagonal=parts["diagonal"],
phase=parts["phase"],
spatial=np.nan,
logrho_proxy=float(logrho_proxy[freq_index]),
flags="",
)
rows.append(row)
columns = [
"station",
"station_index",
"distance_m",
"frequency_hz",
"period_s",
"log10_period",
"confidence",
"confidence_err",
"method",
"n_components",
"coverage",
"uncertainty",
"offdiag",
"diagonal",
"phase",
"spatial",
"logrho_proxy",
"flags",
]
if not rows:
df = pd.DataFrame(columns=columns)
return maybe_wrap_frame(
df,
api=api,
name="frequency_confidence_table",
kind="emtools.qc.frequency_confidence",
source=sites,
)
table = pd.DataFrame.from_records(rows, columns=columns)
spatial = _frequency_spatial_scores(table, spatial_tolerance_log10)
table["spatial"] = spatial
if method == "composite":
for index, row in table.iterrows():
parts = {
key: row[key]
for key in (
"coverage",
"uncertainty",
"offdiag",
"diagonal",
"phase",
"spatial",
)
}
confidence = confidence_ratio(parts, weights=weights)
table.at[index, "confidence"] = confidence
table.at[index, "confidence_err"] = _confidence_error(
parts,
1,
confidence,
)
table["flags"] = [
_frequency_flags(row, ci_hi, ci_lo) for _, row in table.iterrows()
]
return maybe_wrap_frame(
table,
api=api,
name="frequency_confidence_table",
kind="emtools.qc.frequency_confidence",
source=sites,
description="Frequency-level transfer-function confidence scores.",
)
# -------------------- confidence profile (Kouadio et al. 2024 Fig. 3) --- #
[docs]
def plot_confidence_profile(
sites: Any,
*,
method: str = "presence",
ci_hi: float = DEFAULT_CI_HI,
ci_lo: float = DEFAULT_CI_LO,
shade_recoverable: bool = True,
shade_mode: str = "score",
annotate_low: bool = True,
station_labels: bool = True,
station_label_step: int | None = None,
show_errorbars: bool = True,
smart_ylim: bool = True,
ylim: tuple[float, float] | None = None,
weights: dict[str, float] | None = None,
spacing_m: float = 200.0,
figsize: tuple[float, float] = (9.0, 4.0),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
"""
Profile confidence-ratio (CR) scatter plot along the survey line.
Reproduces the Fig. 3 style from Kouadio et al. (2024): one dot per
station coloured green (CR >= ``ci_hi``), pink
(``ci_lo`` <= CR < ``ci_hi``), or red (CR < ``ci_lo``), with
dashed threshold lines.
With ``method="presence"``, CR is the fraction of frequencies with a
valid finite Z tensor. With ``method="composite"``, CR combines
coverage, tensor uncertainty, off-diagonal consistency, diagonal
leakage, phase smoothness, and neighbor coherence.
Parameters
----------
sites : path, EDI-like, Sites, or iterable
Input sites.
ci_hi : float
Upper CR threshold (default 0.95, "safe", green).
ci_lo : float
Lower CR threshold (default 0.85, "recoverable", pink).
shade_recoverable : bool
If ``True``, draw an interval cue for stations below ``ci_hi``.
shade_mode : {"score", "full", "none"}
``"score"`` draws compact vertical intervals tied to each station
point. ``"full"`` preserves the older full-height station shading.
``"none"`` disables station interval shading.
station_label_step : int or None
Gap between visible station labels on the top axis. ``None`` chooses
a readable spacing automatically while keeping all station tick marks.
show_errorbars : bool
If ``True``, draw the station-level confidence uncertainty returned by
:func:`station_confidence_table`.
smart_ylim : bool
If ``True``, zoom the lower y-limit when every station confidence is
above ``ci_lo`` so small departures from the safe threshold remain
visible.
ylim : tuple of float or None
Explicit y-axis limits. Overrides ``smart_ylim`` when provided.
spacing_m : float
Fallback station spacing [m] used when no coordinate metadata is
available on the EDI objects.
figsize : tuple
Figure size when a new figure is created.
recursive, on_dup, strict, verbose
Passed to :func:`ensure_sites`.
ax : matplotlib.axes.Axes or None
Axes to draw on; created if *None*.
Returns
-------
ax : matplotlib.axes.Axes
"""
if ax is None:
_, ax = plt.subplots(figsize=figsize)
shade_mode = str(shade_mode).lower()
if shade_mode not in {"score", "full", "none"}:
msg = "shade_mode must be 'score', 'full', or 'none'."
raise ValueError(msg)
tb = station_confidence_table(
sites,
method=method,
weights=weights,
spacing_m=spacing_m,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
if tb.empty:
ax.text(0.5, 0.5, "no stations", ha="center", va="center")
ax.set_xlabel("Distance along profile (m)")
ax.set_ylabel("Confidence ratio")
return ax
xs = tb["distance_m"].to_numpy(dtype=float)
ys = tb["confidence"].to_numpy(dtype=float)
yerr = tb.get(
"confidence_err",
pd.Series(np.nan, index=tb.index),
).to_numpy(dtype=float)
names = tb["station"].astype(str).tolist()
colors = np.full(len(tb), "#d62728", dtype=object)
colors[ys >= ci_lo] = "#ff99c8"
colors[ys >= ci_hi] = "#20b455"
finite_xs = xs[np.isfinite(xs)]
if finite_xs.size > 1:
step_width = float(np.nanmedian(np.diff(np.sort(finite_xs))))
else:
step_width = float(spacing_m)
bar_width = max(step_width * 0.18, 1.0)
if shade_recoverable and shade_mode == "full":
order = np.argsort(xs)
xs_ordered = xs[order]
for idx, xpos in zip(order, xs_ordered):
if ci_lo <= ys[idx] < ci_hi:
if xs_ordered.size > 1:
diffs = np.diff(xs_ordered)
step = float(np.nanmedian(diffs))
else:
step = spacing_m
ax.axvspan(
xpos - 0.35 * step,
xpos + 0.35 * step,
ymin=0.0,
ymax=1.0,
color="#f3a6c9",
alpha=0.35,
lw=0,
zorder=0,
)
elif shade_recoverable and shade_mode == "score":
for x, y in zip(xs, ys):
if not np.isfinite(x) or not np.isfinite(y):
continue
if y >= ci_hi:
continue
if y >= ci_lo:
ax.bar(
x,
y - ci_lo,
bottom=ci_lo,
width=bar_width,
color="#f3a6c9",
alpha=0.45,
lw=0,
zorder=1,
)
ax.bar(
x,
ci_hi - y,
bottom=y,
width=bar_width,
color="#8fd19e",
alpha=0.35,
lw=0,
zorder=1,
)
else:
ax.bar(
x,
ci_lo - y,
bottom=y,
width=bar_width,
color="#d62728",
alpha=0.30,
lw=0,
zorder=1,
)
ax.bar(
x,
ci_hi - ci_lo,
bottom=ci_lo,
width=bar_width,
color="#f3a6c9",
alpha=0.35,
lw=0,
zorder=1,
)
if len(xs):
ax.plot(xs, ys, color="black", lw=1.5, zorder=2)
if show_errorbars and np.isfinite(yerr).any():
ax.errorbar(
xs,
ys,
yerr=np.clip(yerr, 0.0, 0.5),
fmt="none",
ecolor="0.25",
elinewidth=0.8,
capsize=2.5,
alpha=0.65,
zorder=2,
)
ax.scatter(
xs,
ys,
c=colors,
s=64,
zorder=3,
edgecolors="black",
linewidths=1.0,
)
if annotate_low:
for x, y, name in zip(xs, ys, names):
if y < ci_lo:
ax.text(
x,
max(y + 0.04, 0.04),
name,
ha="center",
va="bottom",
rotation=90,
fontsize=7,
)
ax.axhline(
ci_hi,
ls="--",
color="black",
lw=1.1,
alpha=0.85,
)
ax.axhline(
ci_lo,
ls="--",
color="black",
lw=1.1,
alpha=0.85,
)
handles = [
plt.Line2D(
[],
[],
marker="o",
ls="",
mfc="#20b455",
mec="black",
label=f"Conf. >= {ci_hi:.2f}",
),
plt.Line2D(
[],
[],
marker="o",
ls="",
mfc="#ff99c8",
mec="black",
label=f"{ci_lo:.2f} <= Conf. < {ci_hi:.2f}",
),
plt.Line2D(
[],
[],
marker="o",
ls="",
mfc="#8b0026",
mec="black",
label=f"Conf. < {ci_lo:.2f}",
),
]
if station_labels:
top = ax.secondary_xaxis("top")
top.set_xticks(xs, minor=True)
top.tick_params(which="minor", length=3)
if station_label_step is None:
if len(xs) > 18:
step = max(1, int(np.ceil(len(xs) / 12)))
else:
step = 1
else:
step = max(1, int(station_label_step))
idx = np.arange(0, len(xs), step, dtype=int)
if len(xs) and len(xs) - 1 not in idx:
idx = np.r_[idx, len(xs) - 1]
top.set_xticks(xs[idx])
top.set_xticklabels(
[names[i] for i in idx],
rotation=90,
fontsize=7,
)
top.tick_params(which="major", length=5)
top.set_xlabel("Station")
if ylim is not None:
ax.set_ylim(*ylim)
elif smart_ylim and np.nanmin(ys) >= ci_lo:
low = max(0.0, min(ci_lo - 0.05, np.nanmin(ys) - 0.05))
ax.set_ylim(low, 1.03)
else:
low = min(0.0, np.nanmin(ys) - 0.05)
ax.set_ylim(max(-0.03, low), 1.08)
ticks = sorted({0.0, ci_lo, ci_hi, 1.0})
ticks = [
tick for tick in ticks if ax.get_ylim()[0] <= tick <= ax.get_ylim()[1]
]
if ticks:
ax.set_yticks(ticks)
ax.set_xlabel("Distance along profile (m)")
ax.set_ylabel("Confidence ratio")
ax.legend(handles=handles, fontsize=8, loc="lower left")
title = "Station confidence"
if method != "presence":
title += f" ({method})"
ax.set_title(title, fontsize=10)
ax.grid(True, ls=":", alpha=0.4)
return ax
[docs]
def plot_frequency_confidence_psection(
sites: Any,
*,
method: str = "composite",
ci_hi: float = DEFAULT_CI_HI,
ci_lo: float = DEFAULT_CI_LO,
metric: str = "confidence",
cmap: str = "RdYlGn",
section: str | SectionStyle = "dynamic",
figsize: tuple[float, float] | None = None,
station_label_step: int | None = None,
station_preset: str = "pseudosection",
station_style: StationAxisStyle | None = None,
spacing_m: float = 200.0,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
"""Plot frequency confidence as a station-period pseudo-section."""
section_style = _resolve_section_style(section)
# "down" triggers invert_yaxis() so short T (high freq, shallow) is at TOP.
section_style.axis.y_direction = "down"
tb = frequency_confidence_table(
sites,
method=method,
ci_hi=ci_hi,
ci_lo=ci_lo,
spacing_m=spacing_m,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
if tb.empty:
if ax is None:
_, ax = plt.subplots(
figsize=figsize or section_style.figsize_for(),
)
ax.text(0.5, 0.5, "no stations", ha="center", va="center")
return ax
if metric not in tb.columns:
msg = f"metric {metric!r} is not available in the confidence table."
raise ValueError(msg)
stations = tb.drop_duplicates("station").sort_values("station_index")
station_names = stations["station"].astype(str).tolist()
yvals = np.sort(tb["log10_period"].dropna().unique())
if ax is None:
_, ax = plt.subplots(
figsize=figsize
or section_style.figsize_for(
n_stations=len(station_names),
n_y=yvals.size,
labels=station_names,
colorbar=True,
),
)
matrix = np.full((yvals.size, len(station_names)), np.nan, dtype=float)
for j, station in enumerate(station_names):
sub = tb[tb["station"] == station]
lookup = {
float(row.log10_period): float(row[metric])
for _, row in sub.iterrows()
if np.isfinite(row.log10_period)
}
for i, yval in enumerate(yvals):
matrix[i, j] = lookup.get(float(yval), np.nan)
im = ax.imshow(
matrix,
aspect="auto",
origin="lower",
interpolation="nearest",
cmap=cmap,
vmin=0.0,
vmax=1.0,
extent=(-0.5, len(station_names) - 0.5, yvals.min(), yvals.max()),
)
ticks = np.arange(len(station_names))
style = station_style or PYCSAMT_STATION_RENDERING.style_for(
station_preset or section_style.station_preset,
)
if station_label_step is not None:
style = copy.copy(style)
style.every = int(station_label_step)
style.apply(
ax,
ticks,
station_names,
xlim=(-0.5, len(station_names) - 0.5),
)
section_style.apply_axis(
ax,
xlabel="Station",
ylabel=r"$\log_{10}T$ (s)",
title=f"Frequency confidence ({method})",
)
section_style.add_colorbar(
im,
ax,
label=metric.replace("_", " ").title(),
)
return ax
[docs]
def plot_station_confidence_spectrum(
sites: Any,
*,
station: str | None = None,
method: str = "composite",
ci_hi: float = DEFAULT_CI_HI,
ci_lo: float = DEFAULT_CI_LO,
figsize: tuple[float, float] = (7.0, 4.0),
spacing_m: float = 200.0,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
"""Plot confidence components versus period for one station."""
tb = frequency_confidence_table(
sites,
method=method,
ci_hi=ci_hi,
ci_lo=ci_lo,
spacing_m=spacing_m,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
if ax is None:
_, ax = plt.subplots(figsize=figsize)
if tb.empty:
ax.text(0.5, 0.5, "no stations", ha="center", va="center")
return ax
if station is None:
station = str(tb["station"].iloc[0])
sub = tb[tb["station"].astype(str) == str(station)].sort_values(
"log10_period",
)
if sub.empty:
msg = f"station {station!r} is not present in the confidence table."
raise ValueError(msg)
x = sub["log10_period"].to_numpy(dtype=float)
y = sub["confidence"].to_numpy(dtype=float)
yerr = sub["confidence_err"].to_numpy(dtype=float)
ax.fill_between(
x,
ci_lo,
ci_hi,
color="#f3a6c9",
alpha=0.20,
zorder=0,
)
ax.axhline(ci_hi, color="black", ls="--", lw=1.0)
ax.axhline(ci_lo, color="black", ls="--", lw=1.0)
ax.plot(x, y, color="black", lw=1.4, label="confidence")
if np.isfinite(yerr).any():
ax.errorbar(
x,
y,
yerr=np.clip(yerr, 0.0, 0.5),
fmt="none",
ecolor="0.25",
elinewidth=0.8,
capsize=2,
alpha=0.65,
)
colors = np.full(y.size, "#d62728", dtype=object)
colors[y >= ci_lo] = "#ff99c8"
colors[y >= ci_hi] = "#20b455"
ax.scatter(x, y, c=colors, edgecolors="black", s=42, zorder=3)
for key, color in (
("coverage", "#4e79a7"),
("offdiag", "#f28e2b"),
("diagonal", "#e15759"),
("phase", "#76b7b2"),
("spatial", "#59a14f"),
):
vals = sub[key].to_numpy(dtype=float)
if np.isfinite(vals).any():
ax.plot(x, vals, lw=0.9, alpha=0.70, color=color, label=key)
ax.set_ylim(-0.03, 1.05)
ax.set_xlabel(r"$\log_{10}T$ (s)")
ax.set_ylabel("Confidence")
ax.set_title(f"{station} frequency confidence", fontsize=10)
ax.grid(True, ls=":", alpha=0.4)
ax.legend(fontsize=7, ncol=2)
return ax
def _confidence_panel_background(
ax: plt.Axes, ci_hi: float, ci_lo: float
) -> None:
"""Draw confidence threshold bands for one dashboard axis."""
ax.axhspan(0.0, ci_lo, color="#d62728", alpha=0.06, lw=0)
ax.axhspan(ci_lo, ci_hi, color="#f3a6c9", alpha=0.10, lw=0)
ax.axhspan(ci_hi, 1.0, color="#8fd19e", alpha=0.08, lw=0)
ax.axhline(ci_hi, color="black", ls="--", lw=0.8, alpha=0.75)
ax.axhline(ci_lo, color="black", ls="--", lw=0.8, alpha=0.75)
def _confidence_panel_line(
ax: plt.Axes,
x: np.ndarray,
y: np.ndarray,
*,
color: str,
label: str,
ci_hi: float,
ci_lo: float,
yerr: np.ndarray | None = None,
) -> None:
"""Plot one dashboard line with threshold colouring."""
_confidence_panel_background(ax, ci_hi, ci_lo)
ax.plot(x, y, color=color, lw=1.35, label=label)
if yerr is not None and np.isfinite(yerr).any():
ax.errorbar(
x,
y,
yerr=np.clip(yerr, 0.0, 0.5),
fmt="none",
ecolor="0.25",
elinewidth=0.75,
capsize=2,
alpha=0.60,
)
marker_colors = np.full(y.size, "#d62728", dtype=object)
marker_colors[y >= ci_lo] = "#ff99c8"
marker_colors[y >= ci_hi] = "#20b455"
ax.scatter(
x,
y,
c=marker_colors,
edgecolors="black",
linewidths=0.55,
s=24,
zorder=3,
)
ax.set_ylim(-0.03, 1.05)
ax.grid(True, ls=":", alpha=0.35)
[docs]
def plot_station_confidence_dashboard(
sites: Any,
*,
station: str | None = None,
method: str = "composite",
ci_hi: float = DEFAULT_CI_HI,
ci_lo: float = DEFAULT_CI_LO,
axes=None,
figsize: tuple[float, float] = (10.5, 6.0),
spacing_m: float = 200.0,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> plt.Figure:
"""Plot a 2-by-3 confidence dashboard for one station.
The dashboard separates the final confidence score from the diagnostic
components used to build it, avoiding the visual crowding of a single
overlay axis.
"""
tb = frequency_confidence_table(
sites,
method=method,
ci_hi=ci_hi,
ci_lo=ci_lo,
spacing_m=spacing_m,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
axes_given = _axes_list(axes, 6) if axes is not None else None
if axes_given is None:
fig, axes_grid = plt.subplots(
2,
3,
figsize=figsize,
sharex=True,
sharey=True,
constrained_layout=True,
)
flat_axes = axes_grid.ravel()
else:
flat_axes = np.asarray(axes_given, dtype=object)
fig = flat_axes[0].figure
if tb.empty:
flat_axes[0].text(0.5, 0.5, "no stations", ha="center", va="center")
return fig
if station is None:
station = str(tb["station"].iloc[0])
sub = tb[tb["station"].astype(str) == str(station)].sort_values(
"log10_period",
)
if sub.empty:
msg = f"station {station!r} is not present in the confidence table."
raise ValueError(msg)
x = sub["log10_period"].to_numpy(dtype=float)
panel_specs = [
(
"Overall confidence",
"confidence",
"black",
sub["confidence_err"].to_numpy(dtype=float),
),
("Data coverage", "coverage", "#4e79a7", None),
("Tensor uncertainty", "uncertainty", "#9c755f", None),
("Offdiag consistency", "offdiag", "#f28e2b", None),
("Diagonal leakage", "diagonal", "#e15759", None),
("Phase + spatial coherence", None, "#76b7b2", None),
]
for ax, (title, key, color, yerr) in zip(flat_axes, panel_specs):
if key is None:
_confidence_panel_background(ax, ci_hi, ci_lo)
for sub_key, sub_color in (
("phase", "#76b7b2"),
("spatial", "#59a14f"),
):
y = sub[sub_key].to_numpy(dtype=float)
if np.isfinite(y).any():
ax.plot(x, y, color=sub_color, lw=1.25, label=sub_key)
ax.scatter(
x,
y,
color=sub_color,
edgecolors="black",
linewidths=0.45,
s=20,
zorder=3,
)
ax.legend(fontsize=7, loc="lower left")
ax.set_ylim(-0.03, 1.05)
ax.grid(True, ls=":", alpha=0.35)
else:
y = sub[key].to_numpy(dtype=float)
if np.isfinite(y).any():
_confidence_panel_line(
ax,
x,
y,
color=color,
label=key,
ci_hi=ci_hi,
ci_lo=ci_lo,
yerr=yerr,
)
else:
_confidence_panel_background(ax, ci_hi, ci_lo)
ax.text(
0.5,
0.5,
"not available",
ha="center",
va="center",
transform=ax.transAxes,
color="0.35",
)
ax.set_ylim(-0.03, 1.05)
ax.grid(True, ls=":", alpha=0.35)
ax.set_title(title, fontsize=9)
axes_grid = np.asarray(flat_axes, dtype=object).reshape(2, 3)
for ax in axes_grid[:, 0]:
ax.set_ylabel("Confidence")
for ax in axes_grid[-1, :]:
ax.set_xlabel(r"$\log_{10}T$ (s)")
fig.suptitle(
f"{station} frequency-confidence dashboard ({method})",
fontsize=11,
)
return fig
[docs]
def plot_confidence_band_summary(
sites: Any,
*,
method: str = "composite",
ci_hi: float = DEFAULT_CI_HI,
ci_lo: float = DEFAULT_CI_LO,
figsize: tuple[float, float] = (8.0, 4.0),
spacing_m: float = 200.0,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
"""Plot line-wide confidence statistics for each period sample."""
tb = frequency_confidence_table(
sites,
method=method,
ci_hi=ci_hi,
ci_lo=ci_lo,
spacing_m=spacing_m,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
if ax is None:
_, ax = plt.subplots(figsize=figsize)
if tb.empty:
ax.text(0.5, 0.5, "no stations", ha="center", va="center")
return ax
summary = (
tb.groupby("log10_period")["confidence"]
.agg(["median", "mean"])
.reset_index()
.sort_values("log10_period")
)
bands = (
tb.assign(
safe=tb["confidence"] >= ci_hi,
recoverable=(tb["confidence"] >= ci_lo)
& (tb["confidence"] < ci_hi),
reject=tb["confidence"] < ci_lo,
)
.groupby("log10_period")[["safe", "recoverable", "reject"]]
.mean()
.reset_index()
.sort_values("log10_period")
)
x = summary["log10_period"].to_numpy(dtype=float)
ax.plot(
x,
summary["median"].to_numpy(dtype=float),
color="black",
lw=1.5,
label="median confidence",
)
ax.plot(
x,
summary["mean"].to_numpy(dtype=float),
color="0.35",
lw=1.0,
ls="--",
label="mean confidence",
)
ax.fill_between(
x,
0.0,
bands["reject"].to_numpy(dtype=float),
color="#d62728",
alpha=0.25,
label="rejected fraction",
)
ax.fill_between(
x,
bands["reject"].to_numpy(dtype=float),
(
bands["reject"].to_numpy(dtype=float)
+ bands["recoverable"].to_numpy(dtype=float)
),
color="#f3a6c9",
alpha=0.30,
label="recoverable fraction",
)
ax.axhline(ci_hi, color="black", ls="--", lw=1.0)
ax.axhline(ci_lo, color="black", ls="--", lw=1.0)
ax.set_ylim(-0.03, 1.05)
ax.set_xlabel(r"$\log_{10}T$ (s)")
ax.set_ylabel("Confidence / station fraction")
ax.set_title(f"Period-band confidence summary ({method})", fontsize=10)
ax.grid(True, ls=":", alpha=0.4)
ax.legend(fontsize=7)
return ax
# ----------------------------- coverage plot ----------------------------- #
[docs]
def plot_coverage_psection(
sites: Any,
*,
metric: str = "presence", # presence|snr|offdiag
alpha_by: str = "none", # none|snr
section: str | SectionStyle = "dynamic",
figsize: tuple[float, float] | None = None,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
section_style = _resolve_section_style(section)
# "down" triggers invert_yaxis() so short T (high freq, shallow) is at TOP.
section_style.axis.y_direction = "down"
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
sts: list[str] = []
Ys: list[np.ndarray] = []
Ms: list[np.ndarray] = []
As: list[np.ndarray] = []
for i, ed in enumerate(_iter_items(S)):
st = _name(ed, i)
Z, z, fr = _get_z_block(ed)
if Z is None:
continue
sts.append(st)
lp = np.log10(np.maximum(1.0 / fr, 1e-9))
Ys.append(lp)
if metric == "offdiag":
M = _offdiag_logmag(z)
elif metric == "snr":
ze = Z[3] if isinstance(Z, tuple) else None
if ze is None:
_Zobj = getattr(ed, "Z", None) or getattr(
getattr(ed, "edi", None), "Z", None
)
ze = getattr(_Zobj, "z_err", None)
M = _snr_rows(z, ze)
else:
M = _row_ok_z(z).astype(float)
Ms.append(M.astype(float))
if alpha_by == "snr":
_Zobj = getattr(ed, "Z", None) or getattr(
getattr(ed, "edi", None), "Z", None
)
ze = getattr(_Zobj, "z_err", None)
A = _snr_rows(z, ze)
else:
A = np.ones_like(M, dtype=float)
As.append(A)
if not sts:
if ax is None:
_, ax = plt.subplots(
figsize=figsize or section_style.figsize_for(),
)
ax.text(0.5, 0.5, "no data", ha="center", va="center")
return ax
yall = np.unique(np.concatenate(Ys))
nx = len(sts)
Zm = np.zeros((yall.size, nx, 4), dtype=float)
v = []
a = []
for j, (lp, m, al) in enumerate(zip(Ys, Ms, As)):
i = np.searchsorted(yall, lp)
i = np.clip(i, 0, yall.size - 1)
vv = np.nan_to_num(m, nan=np.nan)
# RGBA alpha must be in [0, 1]; alpha_by="snr" feeds raw SNR
# ratios (routinely > 1), so clip rather than pass them straight
# through (imshow silently clips anyway, with a warning).
aa = np.clip(np.nan_to_num(al, nan=0.0), 0.0, 1.0)
v.append(vv)
a.append(aa)
# map metric to color
if metric == "presence":
col = (0.20, 0.60, 0.20)
Zm[i, j, :3] = col
Zm[i, j, 3] = aa
else:
# use viridis for metric
pass
if metric != "presence":
V = np.concatenate(v)
V = V[np.isfinite(V)]
v0 = np.nanpercentile(V, 5) if V.size else 0.0
v1 = np.nanpercentile(V, 95) if V.size else 1.0
for j, (lp, m, al) in enumerate(zip(Ys, Ms, As)):
i = np.searchsorted(yall, lp)
i = np.clip(i, 0, yall.size - 1)
sc = (m - v0) / (v1 - v0 + 1e-12)
sc = np.clip(sc, 0.0, 1.0)
rgb = plt.cm.viridis(sc)
Zm[i, j, :3] = rgb[:, :3]
Zm[i, j, 3] = np.clip(np.nan_to_num(al, nan=0.0), 0.0, 1.0)
if ax is None:
_, ax = plt.subplots(
figsize=figsize
or section_style.figsize_for(
n_stations=len(sts),
n_y=yall.size,
labels=sts,
colorbar=False,
),
)
ax.imshow(
Zm,
aspect="auto",
origin="lower",
interpolation="nearest",
)
section_style.apply_axis(
ax,
xlabel="Station",
ylabel=r"$\log_{10}T$ (s)",
)
section_style.apply_stations(
ax,
np.arange(nx),
sts,
xlim=(-0.5, nx - 0.5),
)
yt, yl = _y_ticks(yall, 8)
ax.set_yticks(yt)
ax.set_yticklabels(yl)
return ax
# ----------------------------- SNR histogram ----------------------------- #
[docs]
def plot_snr_hist(
sites: Any,
*,
bins: int = 40,
figsize: tuple[float, float] = (7.2, 3.6),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
vals: list[float] = []
for _, ed in enumerate(_iter_items(S)):
Z, z, fr = _get_z_block(ed)
if Z is None:
continue
if isinstance(Z, tuple) and len(Z) == 4:
_, z, fr, ze = Z
else:
_Zobj = getattr(ed, "Z", None) or getattr(
getattr(ed, "edi", None), "Z", None
)
ze = getattr(_Zobj, "z_err", None)
snr = _snr_rows(z, ze)
vals.extend(list(snr))
v = np.array(vals, dtype=float)
v = v[np.isfinite(v)]
if ax is None:
_, ax = plt.subplots(figsize=figsize)
if v.size == 0:
ax.text(
0.5,
0.5,
"SNR histogram requires impedance\nerror data (z_err not available)",
ha="center",
va="center",
fontsize=9,
color="#888888",
transform=ax.transAxes,
)
else:
ax.hist(v, bins=int(max(8, bins)))
ax.set_xlabel("row SNR (|Z|/σ)")
ax.set_ylabel("count")
ax.set_title("SNR Histogram")
return ax
# ----------------------------- quicklook -------------------------------- #
[docs]
def plot_qc_quicklook(
sites: Any,
*,
axes=None,
figsize: tuple[float, float] = (10.0, 8.0),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
):
axes_given = _axes_list(axes, 3) if axes is not None else None
if axes_given is None:
fig = plt.figure(figsize=figsize)
gs = fig.add_gridspec(2, 2, hspace=0.35, wspace=0.25)
ax1 = fig.add_subplot(gs[0, :])
ax2 = fig.add_subplot(gs[1, 0])
ax3 = fig.add_subplot(gs[1, 1])
else:
ax1, ax2, ax3 = axes_given
fig = ax1.figure
plot_coverage_psection(
sites,
metric="presence",
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
ax=ax1,
)
plot_coverage_psection(
sites,
metric="snr",
alpha_by="snr",
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
ax=ax2,
)
plot_snr_hist(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
ax=ax3,
)
return fig
# ---------------------- z-block helper (errors) ------------------------- #
def _zblk(ed: Any, need_err: bool = False):
try:
return _get_z_block(ed, with_errors=need_err)
except TypeError:
try:
return _get_z_block(ed, with_errors=need_err)
except TypeError:
return _get_z_block(ed)
# ----------------------- rho_a + error propagation ---------------------- #
def _rhoa_xy_yx(
z: np.ndarray, fr: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
c = 0.2 / (fr + 1e-24)
rxy = c * (np.abs(z[:, 0, 1]) ** 2)
ryx = c * (np.abs(z[:, 1, 0]) ** 2)
return rxy, ryx
def _rhoa_ci(
z: np.ndarray,
ze: np.ndarray | None,
fr: np.ndarray,
*,
comp: str = "xy", # xy|yx
pcts: tuple[float, ...] = (10.0, 50.0, 90.0),
n_draws: int = 200,
seed: int | None = 0,
) -> np.ndarray:
a, b = (0, 1) if comp == "xy" else (1, 0)
zz = z[:, a, b]
if ze is None:
c = 0.2 / (fr + 1e-24)
m = c * (np.abs(zz) ** 2)
P = [np.zeros_like(m) for _ in pcts]
return np.vstack([m] + P).T
ee = ze[:, a, b]
g = np.isfinite(zz) & np.isfinite(ee)
if not np.any(g):
m = np.full(z.shape[0], np.nan, dtype=float)
P = [np.full_like(m, np.nan) for _ in pcts]
return np.vstack([m] + P).T
rng = np.random.default_rng(seed)
nf = zz.size
n = int(max(16, n_draws))
# complex Gaussian, σ equals |ze|
E = (
rng.standard_normal((n, nf)) + 1j * rng.standard_normal((n, nf))
) / np.sqrt(2.0)
E = E * ee[None, :]
Zs = zz[None, :] + E
c = 0.2 / (fr + 1e-24)
R = c[None, :] * (np.abs(Zs) ** 2)
M = np.nanmedian(R, axis=0)
Q = [np.nanpercentile(R, q, axis=0) for q in pcts]
return np.vstack([M] + Q).T
def _shade_band(
ax: plt.Axes,
x: np.ndarray,
lo: np.ndarray,
hi: np.ndarray,
*,
alpha: float = 0.25,
color: str = "C0",
):
ax.fill_between(x, lo, hi, alpha=alpha, color=color)
# ----------------------- 19) Consistency fan chart ---------------------- #
[docs]
def plot_consistency_fan(
sites: Any,
*,
station: str | None = None,
other: Any | None = None, # optional comparison Sites
comps: tuple[str, str] = ("xy", "yx"),
pcts: tuple[float, float, float] = (10.0, 50.0, 90.0),
n_draws: int = 200,
figsize: tuple[float, float] = (8.6, 4.2),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
ed_map = {}
for i, ed in enumerate(_iter_items(S)):
ed_map[_name(ed, i)] = ed
if not ed_map:
if ax is None:
_, ax = plt.subplots(figsize=figsize)
ax.text(0.5, 0.5, "no sites", ha="center", va="center")
return ax
if station is None:
station = sorted(ed_map.keys())[0]
ed = ed_map.get(station, None)
if ed is None:
if ax is None:
_, ax = plt.subplots(figsize=figsize)
ax.text(0.5, 0.5, "station not found", ha="center", va="center")
return ax
out = _zblk(ed, need_err=True)
if len(out) == 4:
Z, z, fr, ze = out
else:
Z, z, fr = out[:3]
ze = None
if Z is None:
if ax is None:
_, ax = plt.subplots(figsize=figsize)
ax.text(0.5, 0.5, "no Z", ha="center", va="center")
return ax
per = 1.0 / fr
x = per
if ax is None:
_, ax = plt.subplots(figsize=figsize)
ax.set_xscale("log")
cols = {"xy": "C0", "yx": "C2"}
for c in comps:
CI = _rhoa_ci(z, ze, fr, comp=c, pcts=pcts, n_draws=n_draws)
med = CI[:, 0]
lo = np.minimum(CI[:, 1], CI[:, 2])
hi = np.maximum(CI[:, 1], CI[:, 2])
_shade_band(ax, x, lo, hi, color=cols[c], alpha=0.20)
ax.plot(x, med, "-", lw=2.0, color=cols[c], label=f"ρa_{c}")
if other is not None:
So = ensure_sites(other, recursive=False, strict=False)
# overlay only medians (dashed)
for i2, edo in enumerate(_iter_items(So)):
if _name(edo, i2) != station:
continue
Z2, z2, fr2 = _zblk(edo)[:3]
if Z2 is None:
break
x2 = 1.0 / fr2
rxy2, ryx2 = _rhoa_xy_yx(z2, fr2)
if "xy" in comps:
ax.plot(
x2, rxy2, "--", lw=1.2, color=cols["xy"], label="after xy"
)
if "yx" in comps:
ax.plot(
x2, ryx2, "--", lw=1.2, color=cols["yx"], label="after yx"
)
break
ax.set_xlabel("Period (s)")
ax.set_ylabel("ρa (Ω·m)")
ax.grid(True, alpha=0.25, which="both")
ax.set_title(str(station))
ax.legend(ncol=2, fontsize=8)
return ax
# ---------------------- 20) XY–YX crossover map ------------------------- #
[docs]
def plot_xyyx_crossover_map(
sites: Any,
*,
figsize: tuple[float, float] = (9.0, 4.6),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
Xs, Ys, labels = [], [], []
sts = []
for i, ed in enumerate(_iter_items(S)):
st = _name(ed, i)
sts.append(st)
Z, z, fr = _zblk(ed)[:3]
if Z is None:
continue
rxy, ryx = _rhoa_xy_yx(z, fr)
d = rxy - ryx
p = 1.0 / fr
if d.size < 2:
continue
s = np.sign(d)
sc = s[:-1] * s[1:] <= 0.0
idx = np.where(sc)[0]
if idx.size == 0:
continue
lp = np.log10(np.maximum(p, 1e-9))
for k in idx:
w1 = np.abs(d[k])
w2 = np.abs(d[k + 1])
t = w1 / (w1 + w2 + 1e-24)
y = (1.0 - t) * lp[k] + t * lp[k + 1]
Xs.append(i)
Ys.append(y)
labels.append(st)
if ax is None:
_, ax = plt.subplots(figsize=figsize)
if not Xs:
ax.text(0.5, 0.5, "no crossovers", ha="center", va="center")
return ax
ax.scatter(Xs, Ys, s=16, c="crimson", alpha=0.8)
ax.set_ylabel(LOG10_PERIOD_LABEL)
PYCSAMT_STATION_RENDERING.apply(
ax,
np.arange(len(sts), dtype=float),
sts,
preset="pseudosection",
xlim=(-0.5, len(sts) - 0.5),
)
# y ticks from Ys
yall = np.array(Ys, dtype=float)
yt, yl = _y_ticks(yall, 8)
ax.set_yticks(yt)
lo, hi = float(np.nanmin(yall)), float(np.nanmax(yall))
ax.set_ylim(lo - 0.05 * (hi - lo), hi + 0.05 * (hi - lo))
if not ax.yaxis_inverted():
ax.invert_yaxis()
return ax
# ---------------------- 21) Noise cone overlay -------------------------- #
[docs]
def overlay_noise_cone(
ax: plt.Axes,
period: np.ndarray,
lo: np.ndarray,
hi: np.ndarray,
*,
color: str = "0.6",
alpha: float = 0.18,
):
x = period
_shade_band(ax, x, lo, hi, color=color, alpha=alpha)
# ---------------------- 22) Spectral hole finder ------------------------ #
[docs]
def overlay_spectral_holes(
ax: plt.Axes,
sites: Any,
*,
thresh_dec: float = 0.30,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
):
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
# assumes x = station index, y = log10(period)
xmap = {}
for i, ed in enumerate(_iter_items(S)):
xmap[_name(ed, i)] = i
for i, ed in enumerate(_iter_items(S)):
Z, z, fr = _zblk(ed)[:3]
if Z is None:
continue
p = 1.0 / fr
lp = np.sort(np.log10(np.maximum(p, 1e-9)))
if lp.size < 2:
continue
d = np.diff(lp)
holes = np.where(d > float(thresh_dec))[0]
for h in holes:
y0, y1 = lp[h], lp[h + 1]
r = _Rect(
(i - 0.45, y0),
0.90,
(y1 - y0),
facecolor=(0.8, 0.2, 0.2, 0.08),
edgecolor="none",
zorder=0.0,
)
ax.add_patch(r)
# Public quick-look helpers retain concise summaries for API autosummary.
overlay_noise_cone.__doc__ = (
"Overlay lower and upper noise envelopes on an existing period axis."
)
overlay_spectral_holes.__doc__ = (
"Highlight gaps in spectral coverage on an existing QC plot."
)
plot_consistency_fan.__doc__ = (
"Plot cross-station response consistency as a fan diagram."
)
plot_coverage_psection.__doc__ = (
"Plot frequency coverage and data availability as a pseudosection."
)
plot_qc_quicklook.__doc__ = (
"Create a compact multi-panel quality-control summary for a survey."
)
plot_snr_hist.__doc__ = (
"Plot the distribution of signal-to-noise ratios across survey data."
)
plot_xyyx_crossover_map.__doc__ = (
"Map XY/YX crossover behaviour across stations and frequencies."
)