"""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 pathlib import Path
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 ..compat.numpy import trapz as _trapz
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",
"export_confidence_map",
"plot_confidence_band_summary",
"plot_confidence_before_after",
"plot_confidence_component_map",
"plot_confidence_coverage_curve",
"plot_confidence_distribution",
"plot_confidence_heatmap",
"plot_confidence_map",
"plot_confidence_grid_map",
"plot_confidence_method_comparison",
"plot_confidence_profile",
"plot_confidence_rank",
"plot_confidence_risk_map",
"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 _station_map_coordinate(ed: Any) -> tuple[float, float, float, float]:
"""Return ``(longitude, latitude, easting, northing)`` for one site."""
lon = lat = east = north = np.nan
candidates = (ed, getattr(ed, "edi", None))
def _first(names: tuple[str, ...]) -> float:
for candidate in candidates:
if candidate is None:
continue
for name in names:
try:
value = float(getattr(candidate, name))
except (AttributeError, TypeError, ValueError):
continue
if np.isfinite(value):
return value
return np.nan
lon = _first(("longitude", "lon", "long"))
lat = _first(("latitude", "lat"))
east = _first(("east", "easting", "x"))
north = _first(("north", "northing", "y"))
if not (np.isfinite(lon) and np.isfinite(lat)):
try:
from ..site.utils import get_coords
coords = get_coords(getattr(ed, "edi", ed))
lat, lon = float(coords.lat), float(coords.lon)
except Exception:
coords = getattr(ed, "coords", None)
try:
lat, lon = float(coords[0]), float(coords[1])
except (TypeError, ValueError, IndexError):
pass
return lon, lat, east, north
def _order_map_route(
group: pd.DataFrame, xkey: str, ykey: str
) -> pd.DataFrame:
"""Order one survey line by coordinate-derived principal chainage."""
if len(group) < 3:
return group
xy = group[[xkey, ykey]].to_numpy(dtype=float)
centred = xy - np.nanmean(xy, axis=0)
if xkey == "longitude":
centred[:, 0] *= np.cos(np.deg2rad(np.nanmean(group[ykey])))
try:
_, _, vh = np.linalg.svd(centred, full_matrices=False)
except np.linalg.LinAlgError:
return group
chainage = centred @ vh[0]
return group.iloc[np.argsort(chainage, kind="stable")]
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:
"""Return per-row SNR without warning for all-NaN rows.
Same "no warning" convention as :func:`_row_nanmedian`: a frequency
row where every tensor component is NaN (a real per-frequency data
gap, not a bug) yields NaN silently instead of a "Mean of empty
slice" RuntimeWarning.
"""
n = z.shape[0]
if ze is None:
return np.full(n, np.nan, dtype=float)
z2 = np.abs(z) ** 2
ze2 = np.abs(ze) ** 2
valid_z = np.isfinite(z2).any(axis=(1, 2))
valid_e = np.isfinite(ze2).any(axis=(1, 2))
a = np.full(n, np.nan, dtype=float)
e = np.full(n, np.nan, dtype=float)
if valid_z.any():
a[valid_z] = np.sqrt(np.nanmean(z2[valid_z], axis=(1, 2)))
if valid_e.any():
e[valid_e] = np.sqrt(np.nanmean(ze2[valid_e], 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,
force_spacing: bool = False,
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.
``distance_m`` in the returned table is the real inter-station
distance projected along the survey line, derived from EDI
coordinates (east/north, or lat/lon as a fallback) whenever at least
two stations carry usable coordinates. ``spacing_m`` is only used as
a uniform per-station fallback for stations without coordinates, or
for the whole line when no station has any. Pass
``force_spacing=True`` to bypass coordinate lookup entirely and lay
every station out at uniform ``spacing_m`` steps -- e.g. when the
available coordinates are known to be unreliable.
"""
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, force_spacing=force_spacing
)
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,
)
longitude, latitude, easting, northing = _station_map_coordinate(ed)
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,
longitude=longitude,
latitude=latitude,
easting=easting,
northing=northing,
)
)
if not rows:
df = pd.DataFrame(
columns=[
"station",
"distance_m",
"confidence",
"method",
"confidence_err",
"n_freq",
"n_ok",
"coverage",
"uncertainty",
"offdiag",
"diagonal",
"phase",
"spatial",
"longitude",
"latitude",
"easting",
"northing",
]
)
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,
force_spacing: bool = False,
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.
See :func:`station_confidence_table` for how ``distance_m``,
``spacing_m``, and ``force_spacing`` interact.
"""
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, force_spacing=force_spacing
)
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 maps ---------------------------- #
[docs]
def export_confidence_map(
sites: Any,
*,
csv_path: str | Path | None = None,
surfer_path: str | Path | None = None,
method: str = "composite",
coordinate_system: str = "auto",
line_labels: Any = None,
grid_shape: tuple[int, int] = (200, 200),
max_triangle_edge: float | None = None,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> dict[str, Path]:
"""Export station confidence to CSV and/or a Surfer DSAA grid.
CSV contains station coordinates, confidence, uncertainty, and composite
components. The Surfer grid contains linearly interpolated confidence
inside the station convex hull; cells outside it (and optionally across
triangles longer than ``max_triangle_edge``) use Surfer's blank value.
``grid_shape`` is ``(nx, ny)``.
"""
if csv_path is None and surfer_path is None:
raise ValueError("provide csv_path, surfer_path, or both.")
coordinate_system = str(coordinate_system).lower()
if coordinate_system not in {"auto", "geographic", "projected"}:
raise ValueError(
"coordinate_system must be 'auto', 'geographic', or 'projected'."
)
tb = station_confidence_table(
sites,
method=method,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
api=False,
).copy()
geographic = np.isfinite(tb[["longitude", "latitude"]]).all(axis=1)
projected = np.isfinite(tb[["easting", "northing"]]).all(axis=1)
if coordinate_system == "auto":
coordinate_system = "geographic" if geographic.any() else "projected"
valid = geographic if coordinate_system == "geographic" else projected
xkey, ykey = (
("longitude", "latitude")
if coordinate_system == "geographic"
else ("easting", "northing")
)
tb.insert(1, "coordinate_system", coordinate_system)
tb.insert(2, "x", tb[xkey])
tb.insert(3, "y", tb[ykey])
if line_labels is not None:
if isinstance(line_labels, str):
tb.insert(1, "line", line_labels)
elif hasattr(line_labels, "get"):
tb.insert(
1,
"line",
tb["station"].astype(str).map(line_labels),
)
else:
values = list(line_labels)
if len(values) != len(tb):
raise ValueError("line_labels must contain one value per row.")
tb.insert(1, "line", values)
outputs: dict[str, Path] = {}
if csv_path is not None:
out = Path(csv_path)
out.parent.mkdir(parents=True, exist_ok=True)
tb.to_csv(out, index=False)
outputs["csv"] = out
if surfer_path is not None:
grid_tb = (
tb.loc[valid, ["x", "y", "confidence"]]
.groupby(["x", "y"], as_index=False)["confidence"]
.mean()
)
xy = grid_tb[["x", "y"]].to_numpy(dtype=float)
if len(xy) < 3 or np.linalg.matrix_rank(xy - xy.mean(axis=0)) < 2:
raise ValueError(
"Surfer-grid export requires at least three non-collinear "
"stations; export CSV for a single survey line."
)
from matplotlib.tri import LinearTriInterpolator, Triangulation
triangulation = Triangulation(xy[:, 0], xy[:, 1])
if max_triangle_edge is not None:
limit = float(max_triangle_edge)
if not np.isfinite(limit) or limit <= 0.0:
raise ValueError(
"max_triangle_edge must be positive and finite."
)
vertices = xy[triangulation.triangles]
edges = np.linalg.norm(
vertices - np.roll(vertices, -1, axis=1), axis=2
)
triangulation.set_mask((edges > limit).any(axis=1))
if np.all(triangulation.mask):
raise ValueError(
"max_triangle_edge masks every grid triangle."
)
nx, ny = (int(grid_shape[0]), int(grid_shape[1]))
if nx < 2 or ny < 2:
raise ValueError("grid_shape values must both be at least 2.")
x_axis = np.linspace(float(xy[:, 0].min()), float(xy[:, 0].max()), nx)
y_axis = np.linspace(float(xy[:, 1].min()), float(xy[:, 1].max()), ny)
gx, gy = np.meshgrid(x_axis, y_axis)
interpolator = LinearTriInterpolator(
triangulation,
grid_tb["confidence"].to_numpy(dtype=float),
)
grid = np.ma.asarray(interpolator(gx, gy)).filled(np.nan)
from ..interp.export import to_surfer_dsaa
outputs["surfer"] = to_surfer_dsaa(
x_axis,
y_axis,
grid,
surfer_path,
)
return outputs
[docs]
def plot_confidence_map(
sites: Any,
*,
method: str = "composite",
coordinate_system: str = "auto",
mode: str = "auto",
line_labels: Any = None,
connect: bool = True,
contour_levels: int | Any = 12,
colorbar_min: float | str = "auto",
segmented_colors: bool = True,
max_triangle_edge: float | None = None,
show_stations: bool = True,
show_confidence_values: bool = False,
confidence_value_step: int | None = None,
confidence_value_fmt: str = "{:.2f}",
confidence_value_fontsize: float = 7.0,
show_contour_lines: bool = False,
contour_line_levels: Any = None,
contour_line_colors: Any = "0.25",
contour_linewidths: Any = 0.65,
contour_linestyles: Any = "solid",
contour_labels: bool = True,
contour_label_fmt: str | Any = "%.2f",
contour_label_fontsize: float = 7.0,
contour_label_inline: bool = True,
show_threshold_contours: bool = True,
threshold_line_color: Any = "black",
threshold_linewidth: float = 1.35,
threshold_linestyle: str = "solid",
station_labels: bool = False,
station_label_step: int | None = None,
ci_hi: float = DEFAULT_CI_HI,
ci_lo: float = DEFAULT_CI_LO,
boundary_levels: Any = None,
cmap: str = "RdYlGn",
marker_size: float = 72.0,
figsize: tuple[float, float] = (7.5, 5.5),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
"""Map station-level confidence at geographic or projected coordinates.
Route and scatter modes do not interpolate between stations. Contour mode
uses a triangular surface inside the survey's convex hull and rejects
collinear station layouts, so a single profile cannot accidentally appear
to provide two-dimensional spatial coverage.
Parameters
----------
sites : path, EDI-like, Sites, or iterable
Input stations accepted by :func:`station_confidence_table`.
method : {"presence", "composite"}
Confidence scoring method.
coordinate_system : {"auto", "geographic", "projected"}
``"auto"`` prefers longitude/latitude and falls back to
easting/northing. The selected pair must be available per station.
mode : {"auto", "scatter", "route", "contour"}
Map representation. ``"auto"`` selects ``"route"`` when ``connect``
is true and ``"scatter"`` otherwise. ``"contour"`` requires at
least three non-collinear station coordinates.
line_labels : mapping, sequence, str, or None
Optional survey-line membership. A mapping is keyed by station name;
a sequence follows table order; one string assigns every station to
that line. When omitted all stations form one route.
connect : bool
Overlay routes within each line. In contour mode this is useful for
retaining the acquisition geometry above the interpolated surface.
contour_levels : int or array-like
Number of discrete filled intervals, or explicit boundaries.
colorbar_min : float or "auto"
Lower contour/colorbar boundary. ``"auto"`` rounds down the observed
minimum: to a 0.05 step when all CR values are at least 0.5, otherwise
to a 0.1 step. Applies consistently to contour, route, and scatter
modes. Pass ``0.0`` to retain the original full 0--1 scale.
segmented_colors : bool
Use discrete color intervals with explicit breaks at 0.50,
``ci_lo``, ``ci_hi``, and 1.00. Set false for a continuous gradient.
max_triangle_edge : float or None
Optional maximum triangle-edge length, expressed in the selected map
units. Triangles crossing a larger unsurveyed gap are masked.
show_stations : bool
Draw confidence-coloured station markers over the map.
show_confidence_values : bool
Annotate the numeric confidence beside each displayed station.
confidence_value_step : int or None
Label every Nth station. ``None`` automatically thins surveys with
more than 20 stations while retaining the final station value.
confidence_value_fmt : str
Python format string for station confidence annotations.
confidence_value_fontsize : float
Font size for station confidence annotations.
show_contour_lines : bool
Overlay ordinary confidence isolines and, by default, their values.
contour_line_levels : array-like or None
Isoline values. ``None`` uses the discrete filled-contour boundaries
that fall within the observed confidence range.
contour_line_colors, contour_linewidths, contour_linestyles
Matplotlib styling passed to :meth:`~matplotlib.axes.Axes.tricontour`.
contour_labels : bool
Label ordinary contour lines with their confidence values.
contour_label_fmt : str, mapping, or callable
Label formatter passed to :meth:`~matplotlib.axes.Axes.clabel`.
contour_label_fontsize : float
Numeric contour-label size.
contour_label_inline : bool
Remove the line beneath each numeric contour label.
show_threshold_contours : bool
Delineate 0.50, ``ci_lo``, and ``ci_hi`` on the contour surface when
those values are crossed by the observed data. The colorbar always
marks every applicable boundary.
threshold_line_color, threshold_linewidth, threshold_linestyle
Styling for the emphasized confidence-class borders.
station_labels : bool
Annotate station names. ``station_label_step`` controls thinning.
ci_hi, ci_lo : float
Safe and recoverable/review confidence thresholds.
boundary_levels : array-like or None
Confidence-class borders used by segmented colors, emphasized
isolines, and colorbar ticks. ``None`` uses
``(0.50, ci_lo, ci_hi, 1.00)``. For example, pass
``(0.50, 0.85, 0.90, 1.00)`` for boundary-only contours at those
values.
cmap, marker_size, figsize
Matplotlib appearance controls.
recursive, on_dup, strict, verbose
Passed through the standard EMTools site-loading API.
ax : matplotlib.axes.Axes or None
Existing axes, or ``None`` to create one.
Returns
-------
matplotlib.axes.Axes
The map axes. Its ``_pycsamt_coordinate_system`` attribute records
the coordinate system selected by ``"auto"``.
"""
coordinate_system = str(coordinate_system).lower()
if coordinate_system not in {"auto", "geographic", "projected"}:
raise ValueError(
"coordinate_system must be 'auto', 'geographic', or 'projected'."
)
mode = str(mode).lower()
if mode not in {"auto", "scatter", "route", "contour"}:
raise ValueError(
"mode must be 'auto', 'scatter', 'route', or 'contour'."
)
if mode == "auto":
mode = "route" if connect else "scatter"
if not (0.0 <= ci_lo <= ci_hi <= 1.0):
raise ValueError(
"confidence thresholds must satisfy 0 <= ci_lo <= ci_hi <= 1."
)
if ax is None:
_, ax = plt.subplots(figsize=figsize)
tb = station_confidence_table(
sites,
method=method,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
api=False,
)
if tb.empty:
ax.text(0.5, 0.5, "no stations", ha="center", va="center")
return ax
geographic = np.isfinite(tb[["longitude", "latitude"]]).all(axis=1)
projected = np.isfinite(tb[["easting", "northing"]]).all(axis=1)
if coordinate_system == "auto":
coordinate_system = "geographic" if geographic.any() else "projected"
valid = geographic if coordinate_system == "geographic" else projected
xkey, ykey = (
("longitude", "latitude")
if coordinate_system == "geographic"
else ("easting", "northing")
)
tb = tb.loc[valid].copy()
ax._pycsamt_coordinate_system = coordinate_system
if tb.empty:
ax.text(
0.5,
0.5,
f"no {coordinate_system} coordinates",
ha="center",
va="center",
)
ax.set_xlabel(
"Longitude (deg)"
if coordinate_system == "geographic"
else "Easting (m)"
)
ax.set_ylabel(
"Latitude (deg)"
if coordinate_system == "geographic"
else "Northing (m)"
)
return ax
if line_labels is None:
labels = pd.Series("survey", index=tb.index, dtype=object)
elif isinstance(line_labels, str):
labels = pd.Series(line_labels, index=tb.index, dtype=object)
elif hasattr(line_labels, "get"):
labels = tb["station"].astype(str).map(line_labels).fillna("survey")
else:
values = list(line_labels)
if len(values) != len(valid):
raise ValueError(
"line_labels must contain one value per confidence-table row."
)
labels = pd.Series(values, index=valid.index).loc[tb.index]
tb["_line"] = labels.astype(str)
plot_vmin = 0.0
norm = None
filled_levels = contour_levels
threshold_values = np.asarray(
(0.5, ci_lo, ci_hi, 1.0)
if boundary_levels is None
else boundary_levels,
dtype=float,
)
threshold_values = np.unique(threshold_values)
if (
threshold_values.ndim != 1
or threshold_values.size < 2
or not np.isfinite(threshold_values).all()
or threshold_values[0] < 0.0
or threshold_values[-1] > 1.0
):
raise ValueError(
"boundary_levels must be finite values within [0, 1]."
)
spatial_boundaries = threshold_values[~np.isclose(threshold_values, 1.0)]
observed_min = float(tb["confidence"].min())
if isinstance(colorbar_min, str):
if colorbar_min.lower() != "auto":
raise ValueError("colorbar_min must be a number or 'auto'.")
step = 0.05 if observed_min >= 0.5 else 0.1
plot_vmin = max(
0.0,
np.floor((observed_min + 1e-12) / step) * step,
)
plot_vmin = min(plot_vmin, 0.95)
else:
plot_vmin = float(colorbar_min)
if not np.isfinite(plot_vmin) or not 0.0 <= plot_vmin < 1.0:
raise ValueError("colorbar_min must satisfy 0 <= value < 1.")
if segmented_colors or mode == "contour":
if np.isscalar(contour_levels):
n_levels = int(contour_levels)
if n_levels < 2:
raise ValueError("contour_levels must be at least 2.")
filled_levels = np.linspace(plot_vmin, 1.0, n_levels + 1)
else:
filled_levels = np.asarray(contour_levels, dtype=float)
filled_levels = np.unique(
np.r_[
filled_levels,
[v for v in threshold_values if plot_vmin <= v <= 1.0],
plot_vmin,
1.0,
]
)
if filled_levels.size < 2 or np.any(np.diff(filled_levels) <= 0.0):
raise ValueError(
"contour_levels must define increasing boundaries."
)
from matplotlib.colors import BoundaryNorm
norm = BoundaryNorm(filled_levels, plt.get_cmap(cmap).N, clip=True)
else:
from matplotlib.colors import Normalize
norm = Normalize(plot_vmin, 1.0, clip=True)
draw_routes = connect and mode != "scatter"
if mode == "contour" and line_labels is None:
draw_routes = False
if draw_routes:
for _, group in tb.groupby("_line", sort=False):
group = _order_map_route(group, xkey, ykey)
ax.plot(
group[xkey],
group[ykey],
color="0.35",
lw=1.2,
alpha=0.75,
zorder=2 if mode == "contour" else 1,
)
mappable = None
if mode == "contour":
from matplotlib.tri import Triangulation
xy = tb[[xkey, ykey]].to_numpy(dtype=float)
if len(xy) < 3 or np.linalg.matrix_rank(xy - xy.mean(axis=0)) < 2:
raise ValueError(
"contour mode requires at least three non-collinear stations; "
"use mode='route' for a single survey line."
)
try:
triangulation = Triangulation(xy[:, 0], xy[:, 1])
except (RuntimeError, ValueError) as exc:
raise ValueError(
"confidence contour triangulation failed; check for duplicate "
"or nearly collinear station coordinates."
) from exc
if max_triangle_edge is not None:
max_triangle_edge = float(max_triangle_edge)
if not np.isfinite(max_triangle_edge) or max_triangle_edge <= 0.0:
raise ValueError(
"max_triangle_edge must be a positive finite value."
)
triangles = triangulation.triangles
vertices = xy[triangles]
edge_lengths = np.linalg.norm(
vertices - np.roll(vertices, -1, axis=1), axis=2
)
triangulation.set_mask(
(edge_lengths > max_triangle_edge).any(axis=1)
)
if np.all(triangulation.mask):
raise ValueError(
"max_triangle_edge masks every contour triangle; increase "
"the limit or use mode='route'."
)
mappable = ax.tricontourf(
triangulation,
tb["confidence"].to_numpy(dtype=float),
levels=filled_levels,
cmap=cmap,
norm=norm,
extend="neither",
zorder=0,
)
observed_max = float(tb["confidence"].max())
if show_contour_lines:
line_levels = (
np.asarray(filled_levels, dtype=float)
if contour_line_levels is None
else np.asarray(contour_line_levels, dtype=float)
)
line_levels = np.unique(
line_levels[
(line_levels >= observed_min)
& (line_levels <= observed_max)
]
)
if show_threshold_contours:
line_levels = np.asarray(
[
value
for value in line_levels
if not any(
np.isclose(value, threshold)
for threshold in spatial_boundaries
)
],
dtype=float,
)
if line_levels.size:
isolines = ax.tricontour(
triangulation,
tb["confidence"].to_numpy(dtype=float),
levels=line_levels,
colors=contour_line_colors,
linewidths=contour_linewidths,
linestyles=contour_linestyles,
zorder=1,
)
if contour_labels:
ax.clabel(
isolines,
fmt=contour_label_fmt,
fontsize=contour_label_fontsize,
inline=contour_label_inline,
)
crossed = [
value
for value in spatial_boundaries
if observed_min <= value <= observed_max
]
if show_threshold_contours and crossed:
borders = ax.tricontour(
triangulation,
tb["confidence"].to_numpy(dtype=float),
levels=crossed,
colors=threshold_line_color,
linewidths=threshold_linewidth,
linestyles=threshold_linestyle,
zorder=1.5,
)
if contour_labels:
ax.clabel(
borders,
fmt=contour_label_fmt,
fontsize=contour_label_fontsize,
inline=contour_label_inline,
)
if show_stations:
points = ax.scatter(
tb[xkey],
tb[ykey],
c=tb["confidence"],
cmap=cmap,
norm=norm,
vmin=None,
vmax=None,
s=marker_size,
edgecolors="black",
linewidths=0.8,
zorder=3 if mode == "contour" else 2,
)
if mappable is None:
mappable = points
if mappable is not None:
colorbar = ax.figure.colorbar(mappable, ax=ax, pad=0.02)
else:
from matplotlib.cm import ScalarMappable
mappable = ScalarMappable(norm=norm, cmap=cmap)
colorbar = ax.figure.colorbar(mappable, ax=ax, pad=0.02)
colorbar.set_label("Confidence ratio")
colorbar_ticks = np.unique(
np.r_[plot_vmin, threshold_values[threshold_values >= plot_vmin]]
).tolist()
colorbar.set_ticks(colorbar_ticks)
if segmented_colors:
for value in colorbar_ticks[1:]:
colorbar.ax.axhline(value, color="black", lw=0.8, alpha=0.85)
if station_labels:
step = max(1, int(station_label_step or np.ceil(len(tb) / 20)))
keep = list(range(0, len(tb), step))
if len(tb) - 1 not in keep:
keep.append(len(tb) - 1)
for i in keep:
row = tb.iloc[i]
ax.annotate(
str(row["station"]),
(row[xkey], row[ykey]),
xytext=(4, 4),
textcoords="offset points",
fontsize=7,
)
if show_confidence_values:
value_step = max(
1,
int(confidence_value_step or np.ceil(len(tb) / 20)),
)
value_indices = list(range(0, len(tb), value_step))
if len(tb) - 1 not in value_indices:
value_indices.append(len(tb) - 1)
for i in value_indices:
row = tb.iloc[i]
try:
label = confidence_value_fmt.format(float(row["confidence"]))
except (AttributeError, ValueError):
label = f"{float(row['confidence']):.2f}"
ax.annotate(
label,
(row[xkey], row[ykey]),
xytext=(4, -6),
textcoords="offset points",
ha="left",
va="top",
fontsize=confidence_value_fontsize,
color="0.15",
)
if tb["_line"].nunique() > 1:
for line, group in tb.groupby("_line", sort=False):
row = group.iloc[len(group) // 2]
ax.annotate(
str(line),
(row[xkey], row[ykey]),
xytext=(5, -10),
textcoords="offset points",
fontsize=8,
fontweight="bold",
)
ax.set_xlabel(
"Longitude (deg)"
if coordinate_system == "geographic"
else "Easting (m)"
)
ax.set_ylabel(
"Latitude (deg)"
if coordinate_system == "geographic"
else "Northing (m)"
)
ax.set_title(f"Station confidence map ({method}, {mode})", fontsize=10)
ax.grid(True, ls=":", alpha=0.35)
return ax
[docs]
def plot_confidence_grid_map(
sites: Any,
*,
method: str = "composite",
coordinate_system: str = "auto",
line_labels: Any = None,
grid_shape: tuple[int, int] = (180, 180),
interpolation: str = "linear",
max_triangle_edge: float | None = None,
ci_hi: float = DEFAULT_CI_HI,
ci_lo: float = DEFAULT_CI_LO,
levels: Any = None,
cmap: str = "RdYlGn",
segmented_colors: bool = True,
show_grid_edges: bool = False,
grid_edgecolor: str = "white",
grid_linewidth: float = 0.15,
show_threshold_contours: bool = True,
threshold_colors: Any = ("#9a6700", "#1b5e20"),
threshold_linewidths: Any = (1.4, 1.6),
threshold_linestyles: Any = ("--", "-"),
contour_labels: bool = True,
show_stations: bool = True,
marker_size: float = 18.0,
connect: bool = True,
line_names: bool = True,
nodata_color: str = "white",
map_aspect: str = "auto",
figsize: tuple[float, float] = (8.2, 5.8),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
"""Render confidence on a regular map grid inside the survey hull.
Unlike :func:`plot_confidence_map` contour mode, this function displays
the actual regular cells used by raster and Surfer-style workflows.
Cells outside the triangulated survey footprint remain blank.
``grid_shape`` is ``(nx, ny)`` and ``interpolation`` may be ``'linear'``
or ``'cubic'``.
"""
if not (0.0 <= ci_lo <= ci_hi <= 1.0):
raise ValueError(
"confidence thresholds must satisfy 0 <= ci_lo <= ci_hi <= 1."
)
coordinate_system = str(coordinate_system).lower()
if coordinate_system not in {"auto", "geographic", "projected"}:
raise ValueError(
"coordinate_system must be 'auto', 'geographic', or 'projected'."
)
interpolation = str(interpolation).lower()
if interpolation not in {"linear", "cubic"}:
raise ValueError("interpolation must be 'linear' or 'cubic'.")
map_aspect = str(map_aspect).lower()
if map_aspect not in {"auto", "equal", "geographic"}:
raise ValueError(
"map_aspect must be 'auto', 'equal', or 'geographic'."
)
nx, ny = int(grid_shape[0]), int(grid_shape[1])
if nx < 2 or ny < 2:
raise ValueError("grid_shape values must both be at least 2.")
table = station_confidence_table(
sites,
method=method,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
api=False,
).copy()
geographic = np.isfinite(table[["longitude", "latitude"]]).all(axis=1)
projected = np.isfinite(table[["easting", "northing"]]).all(axis=1)
if coordinate_system == "auto":
coordinate_system = "geographic" if geographic.any() else "projected"
valid = geographic if coordinate_system == "geographic" else projected
xkey, ykey = (
("longitude", "latitude")
if coordinate_system == "geographic"
else ("easting", "northing")
)
table = table.loc[valid & np.isfinite(table["confidence"])].copy()
if ax is None:
_, ax = plt.subplots(figsize=figsize, constrained_layout=True)
ax._pycsamt_coordinate_system = coordinate_system
if table.empty:
ax.text(0.5, 0.5, "no mapped stations", ha="center", va="center")
return ax
if line_labels is None:
labels = pd.Series("survey", index=table.index, dtype=object)
elif isinstance(line_labels, str):
labels = pd.Series(line_labels, index=table.index, dtype=object)
elif hasattr(line_labels, "get"):
labels = table["station"].astype(str).map(line_labels).fillna("survey")
else:
values = list(line_labels)
if len(values) != len(valid):
raise ValueError(
"line_labels must contain one value per table row."
)
labels = pd.Series(values, index=valid.index).loc[table.index]
table["_line"] = labels.astype(str)
grouped = (
table.groupby([xkey, ykey], as_index=False)
.agg({"confidence": "mean"})
.copy()
)
# ``.to_numpy()`` can hand back a read-only view (pandas copy-on-write);
# matplotlib's Triangulation / CubicTriInterpolator assume writeable
# input and raise "assignment destination is read-only" otherwise.
xy = np.array(grouped[[xkey, ykey]].to_numpy(dtype=float), dtype=float)
if len(xy) < 3 or np.linalg.matrix_rank(xy - xy.mean(axis=0)) < 2:
raise ValueError(
"grid maps require at least three non-collinear station "
"coordinates; use plot_confidence_map(..., mode='route') for "
"one survey line."
)
from matplotlib.tri import (
CubicTriInterpolator,
LinearTriInterpolator,
Triangulation,
)
triangulation = Triangulation(xy[:, 0], xy[:, 1])
if max_triangle_edge is not None:
limit = float(max_triangle_edge)
if not np.isfinite(limit) or limit <= 0.0:
raise ValueError("max_triangle_edge must be positive and finite.")
vertices = xy[triangulation.triangles]
edges = np.linalg.norm(
vertices - np.roll(vertices, -1, axis=1), axis=2
)
triangulation.set_mask((edges > limit).any(axis=1))
if np.all(triangulation.mask):
raise ValueError("max_triangle_edge masks every grid triangle.")
x_axis = np.linspace(float(xy[:, 0].min()), float(xy[:, 0].max()), nx)
y_axis = np.linspace(float(xy[:, 1].min()), float(xy[:, 1].max()), ny)
grid_x, grid_y = np.meshgrid(x_axis, y_axis)
interpolator_class = (
CubicTriInterpolator
if interpolation == "cubic"
else LinearTriInterpolator
)
interpolator = interpolator_class(
triangulation,
np.array(grouped["confidence"].to_numpy(dtype=float), dtype=float),
)
confidence_grid = np.ma.asarray(
interpolator(np.array(grid_x), np.array(grid_y))
)
confidence_grid = np.ma.masked_invalid(
np.ma.clip(confidence_grid, 0.0, 1.0)
)
ax._pycsamt_grid_x = x_axis
ax._pycsamt_grid_y = y_axis
ax._pycsamt_confidence_grid = confidence_grid
from matplotlib.colors import BoundaryNorm, Normalize
if levels is None:
levels = sorted({0.0, 0.25, 0.50, 0.75, ci_lo, ci_hi, 1.0})
levels = np.unique(np.clip(np.asarray(levels, dtype=float), 0.0, 1.0))
if len(levels) < 2 or levels[0] > 0.0 or levels[-1] < 1.0:
raise ValueError("levels must span 0 to 1 with at least two values.")
use_cmap = plt.get_cmap(cmap).copy()
use_cmap.set_bad(nodata_color)
norm = (
BoundaryNorm(levels, use_cmap.N, clip=True)
if segmented_colors
else Normalize(0.0, 1.0, clip=True)
)
grid_plot = ax.pcolormesh(
grid_x,
grid_y,
confidence_grid,
shading="auto",
cmap=use_cmap,
norm=norm,
edgecolors=grid_edgecolor if show_grid_edges else "none",
linewidth=grid_linewidth if show_grid_edges else 0.0,
rasterized=True,
zorder=0,
)
if show_threshold_contours:
finite = confidence_grid.compressed()
boundaries = [
value
for value in (ci_lo, ci_hi)
if finite.size and finite.min() < value < finite.max()
]
if boundaries:
colors = list(np.atleast_1d(threshold_colors))[: len(boundaries)]
widths = list(np.atleast_1d(threshold_linewidths))[
: len(boundaries)
]
styles = list(np.atleast_1d(threshold_linestyles))[
: len(boundaries)
]
contours = ax.contour(
grid_x,
grid_y,
confidence_grid,
levels=boundaries,
colors=colors,
linewidths=widths,
linestyles=styles,
zorder=2,
)
if contour_labels:
ax.clabel(contours, fmt="CR %.2f", fontsize=7, inline=True)
if connect:
for _, group in table.groupby("_line", sort=False):
group = _order_map_route(group, xkey, ykey)
ax.plot(
group[xkey],
group[ykey],
color="0.25",
lw=0.7,
alpha=0.72,
zorder=3,
)
if show_stations:
ax.scatter(
table[xkey],
table[ykey],
c=table["confidence"],
cmap=use_cmap,
norm=norm,
s=marker_size,
edgecolors="white",
linewidths=0.45,
zorder=4,
)
if line_names and table["_line"].nunique() > 1:
for line, group in table.groupby("_line", sort=False):
row = group.loc[group[ykey].idxmax()]
ax.annotate(
str(line),
(row[xkey], row[ykey]),
xytext=(0, 4),
textcoords="offset points",
ha="center",
va="bottom",
fontsize=7,
fontweight="semibold",
zorder=5,
)
colorbar = ax.figure.colorbar(grid_plot, ax=ax, pad=0.018, fraction=0.045)
colorbar.set_label("Confidence ratio", fontsize=9)
colorbar.set_ticks(sorted({0.0, 0.5, ci_lo, ci_hi, 1.0}))
colorbar.ax.tick_params(labelsize=7.5)
ax.set_xlabel(
"Longitude (°)" if coordinate_system == "geographic" else "Easting (m)"
)
ax.set_ylabel(
"Latitude (°)" if coordinate_system == "geographic" else "Northing (m)"
)
ax.set_title(
f"Regular-grid confidence map ({method}; {nx} × {ny})",
loc="left",
fontsize=11,
fontweight="semibold",
)
xpad = max(float(x_axis[-1] - x_axis[0]) * 0.025, 1e-12)
ypad = max(float(y_axis[-1] - y_axis[0]) * 0.035, 1e-12)
ax.set_xlim(float(x_axis[0]) - xpad, float(x_axis[-1]) + xpad)
ax.set_ylim(float(y_axis[0]) - ypad, float(y_axis[-1]) + ypad)
ax.grid(True, ls=":", lw=0.45, color="0.72", alpha=0.55)
ax.tick_params(labelsize=8, direction="out", length=3)
if map_aspect == "geographic" and coordinate_system == "geographic":
mean_lat = float(table[ykey].mean())
ax.set_aspect(1.0 / max(np.cos(np.deg2rad(mean_lat)), 1e-6))
elif map_aspect == "equal":
ax.set_aspect("equal", adjustable="box")
else:
ax.set_aspect("auto")
return ax
[docs]
def plot_confidence_component_map(
sites: Any,
*,
method: str = "composite",
components: Any = None,
coordinate_system: str = "auto",
line_labels: Any = None,
connect: bool = True,
cmap: str = "RdYlGn",
marker_size: float = 38.0,
station_labels: bool = False,
station_label_step: int | None = None,
line_names: bool = True,
ncols: int = 4,
figsize: tuple[float, float] | None = None,
map_aspect: str = "auto",
panel_letters: bool = True,
colorbar_label: str = "Confidence component score",
axes: Any = None,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> plt.Figure:
"""Plot publication-style maps of confidence and its components.
Every panel uses the same station geometry, extent, and fixed 0--1 color
normalization, allowing direct scientific comparison between component
scores. The default seven panels are overall confidence, coverage,
uncertainty, off-diagonal consistency, diagonal leakage, phase
smoothness, and spatial coherence.
``map_aspect="auto"`` is the compact publication default. Use
``"geographic"`` to preserve longitude/latitude ground proportions or
``"equal"`` for equal numeric axis units.
"""
default_components = (
"confidence",
"coverage",
"uncertainty",
"offdiag",
"diagonal",
"phase",
"spatial",
)
components = tuple(
default_components if components is None else components
)
if not components:
raise ValueError("components must contain at least one table column.")
coordinate_system = str(coordinate_system).lower()
if coordinate_system not in {"auto", "geographic", "projected"}:
raise ValueError(
"coordinate_system must be 'auto', 'geographic', or 'projected'."
)
tb = station_confidence_table(
sites,
method=method,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
api=False,
)
missing = [component for component in components if component not in tb]
if missing:
raise ValueError(f"unknown confidence components: {missing}.")
geographic = np.isfinite(tb[["longitude", "latitude"]]).all(axis=1)
projected = np.isfinite(tb[["easting", "northing"]]).all(axis=1)
if coordinate_system == "auto":
coordinate_system = "geographic" if geographic.any() else "projected"
valid = geographic if coordinate_system == "geographic" else projected
xkey, ykey = (
("longitude", "latitude")
if coordinate_system == "geographic"
else ("easting", "northing")
)
tb = tb.loc[valid].copy()
ncols = max(1, min(int(ncols), len(components)))
nrows = int(np.ceil(len(components) / ncols))
map_aspect = str(map_aspect).lower()
if map_aspect not in {"auto", "equal", "geographic"}:
raise ValueError(
"map_aspect must be 'auto', 'equal', or 'geographic'."
)
colorbar_axes = None
if axes is None:
if figsize is None:
figsize = (2.85 * ncols, 3.05 * nrows)
fig = plt.figure(figsize=figsize)
grid = fig.add_gridspec(
nrows,
ncols,
left=0.075,
right=0.975,
bottom=0.105,
top=0.895,
wspace=0.10,
hspace=0.16,
)
flat_axes = []
shared = None
for index in range(len(components)):
ax = fig.add_subplot(
grid[index // ncols, index % ncols],
sharex=shared,
sharey=shared,
)
if shared is None:
shared = ax
flat_axes.append(ax)
flat_axes = np.asarray(flat_axes, dtype=object)
if len(components) < nrows * ncols:
host = fig.add_subplot(
grid[len(components) // ncols, len(components) % ncols]
)
host.set_axis_off()
colorbar_axes = host.inset_axes([0.43, 0.06, 0.14, 0.88])
else:
flat_axes = np.asarray(axes, dtype=object).ravel()
if flat_axes.size < len(components):
raise ValueError(
"axes must provide at least one axes per component."
)
fig = flat_axes[0].figure
if tb.empty:
flat_axes[0].text(
0.5, 0.5, "no mapped stations", ha="center", va="center"
)
return fig
if line_labels is None:
labels = pd.Series("survey", index=tb.index, dtype=object)
elif isinstance(line_labels, str):
labels = pd.Series(line_labels, index=tb.index, dtype=object)
elif hasattr(line_labels, "get"):
labels = tb["station"].astype(str).map(line_labels).fillna("survey")
else:
values = list(line_labels)
if len(values) != len(valid):
raise ValueError(
"line_labels must contain one value per table row."
)
labels = pd.Series(values, index=valid.index).loc[tb.index]
tb["_line"] = labels.astype(str)
from matplotlib.cm import ScalarMappable
from matplotlib.colors import Normalize
norm = Normalize(0.0, 1.0, clip=True)
titles = {
"confidence": "Overall confidence",
"coverage": "Data coverage",
"uncertainty": "Tensor uncertainty",
"offdiag": "Off-diagonal consistency",
"diagonal": "Diagonal leakage",
"phase": "Phase smoothness",
"spatial": "Spatial coherence",
}
letters = "abcdefghijklmnopqrstuvwxyz"
xpad = max(float(tb[xkey].max() - tb[xkey].min()) * 0.03, 1e-12)
ypad = max(float(tb[ykey].max() - tb[ykey].min()) * 0.03, 1e-12)
for panel_index, (ax, component) in enumerate(zip(flat_axes, components)):
if connect:
for _, group in tb.groupby("_line", sort=False):
group = _order_map_route(group, xkey, ykey)
ax.plot(
group[xkey],
group[ykey],
color="0.48",
lw=0.75,
alpha=0.75,
zorder=1,
)
finite = np.isfinite(tb[component].to_numpy(dtype=float))
if finite.any():
ax.scatter(
tb.loc[finite, xkey],
tb.loc[finite, ykey],
c=tb.loc[finite, component],
cmap=cmap,
norm=norm,
s=marker_size,
edgecolors="black",
linewidths=0.55,
zorder=2,
)
else:
ax.text(
0.5,
0.5,
"not available",
ha="center",
va="center",
transform=ax.transAxes,
color="0.35",
)
title = titles.get(component, component.replace("_", " ").title())
if panel_letters:
letter = (
letters[panel_index]
if panel_index < len(letters)
else str(panel_index + 1)
)
title = f"({letter}) {title}"
ax.set_title(title, loc="left", fontsize=9.2, fontweight="semibold")
ax.set_xlim(float(tb[xkey].min()) - xpad, float(tb[xkey].max()) + xpad)
ax.set_ylim(float(tb[ykey].min()) - ypad, float(tb[ykey].max()) + ypad)
ax.grid(True, ls=":", lw=0.55, color="0.78", alpha=0.75)
row, col = divmod(panel_index, ncols)
ax.tick_params(
labelsize=7.5,
direction="out",
length=3,
labelleft=col == 0,
labelbottom=row == nrows - 1,
)
if map_aspect == "geographic" and coordinate_system == "geographic":
mean_lat = float(tb[ykey].mean())
ax.set_aspect(1.0 / max(np.cos(np.deg2rad(mean_lat)), 1e-6))
elif map_aspect == "equal":
ax.set_aspect("equal", adjustable="box")
else:
ax.set_aspect("auto")
if station_labels and panel_index == 0:
step = max(1, int(station_label_step or np.ceil(len(tb) / 18)))
for i in range(0, len(tb), step):
row = tb.iloc[i]
ax.annotate(
str(row["station"]),
(row[xkey], row[ykey]),
xytext=(3, 3),
textcoords="offset points",
fontsize=6,
)
if line_names and panel_index == 0 and tb["_line"].nunique() > 1:
for line, group in tb.groupby("_line", sort=False):
row = group.loc[group[ykey].idxmax()]
ax.annotate(
str(line),
(row[xkey], row[ykey]),
xytext=(0, 3),
textcoords="offset points",
ha="center",
va="bottom",
rotation=90,
fontsize=6.5,
fontweight="bold",
)
visible_axes = list(flat_axes[: len(components)])
fig.supxlabel(
"Longitude (°)"
if coordinate_system == "geographic"
else "Easting (m)",
fontsize=9,
)
fig.supylabel(
"Latitude (°)"
if coordinate_system == "geographic"
else "Northing (m)",
fontsize=9,
)
color_mappable = ScalarMappable(norm=norm, cmap=cmap)
if colorbar_axes is not None:
colorbar = fig.colorbar(color_mappable, cax=colorbar_axes)
else:
colorbar = fig.colorbar(
color_mappable,
ax=visible_axes,
fraction=0.025,
pad=0.018,
aspect=32,
)
colorbar.set_label(colorbar_label, fontsize=9)
colorbar.set_ticks([0.0, 0.25, 0.50, 0.75, 1.0])
colorbar.ax.tick_params(labelsize=7.5)
fig.suptitle(
f"Station confidence components ({method})",
fontsize=11,
fontweight="semibold",
)
return fig
[docs]
def plot_confidence_method_comparison(
sites: Any,
*,
coordinate_system: str = "auto",
line_labels: Any = None,
connect: bool = True,
ci_hi: float = DEFAULT_CI_HI,
ci_lo: float = DEFAULT_CI_LO,
confidence_cmap: str = "RdYlGn",
difference_cmap: str = "RdBu",
marker_size: float = 42.0,
station_labels: bool = False,
station_label_step: int | None = None,
line_names: bool = True,
show_statistics: bool = True,
difference_limit: float | None = None,
map_aspect: str = "auto",
figsize: tuple[float, float] = (11.2, 4.5),
axes: Any = None,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> plt.Figure:
"""Compare presence and composite confidence on matched station maps.
Panels show presence confidence, composite confidence, and
``composite - presence``. The confidence panels share a fixed 0--1 color
scale; the difference panel uses a symmetric zero-centred scale so score
gains and penalties remain visually comparable.
"""
if not (0.0 <= ci_lo <= ci_hi <= 1.0):
raise ValueError(
"confidence thresholds must satisfy 0 <= ci_lo <= ci_hi <= 1."
)
coordinate_system = str(coordinate_system).lower()
if coordinate_system not in {"auto", "geographic", "projected"}:
raise ValueError(
"coordinate_system must be 'auto', 'geographic', or 'projected'."
)
map_aspect = str(map_aspect).lower()
if map_aspect not in {"auto", "equal", "geographic"}:
raise ValueError(
"map_aspect must be 'auto', 'equal', or 'geographic'."
)
presence = station_confidence_table(
sites,
method="presence",
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
api=False,
)
composite = station_confidence_table(
sites,
method="composite",
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
api=False,
)
coordinate_columns = ["longitude", "latitude", "easting", "northing"]
table = composite[
["station", "confidence", "confidence_err", *coordinate_columns]
].rename(
columns={
"confidence": "composite",
"confidence_err": "composite_err",
}
)
table = table.merge(
presence[["station", "confidence", "confidence_err"]].rename(
columns={
"confidence": "presence",
"confidence_err": "presence_err",
}
),
on="station",
how="inner",
)
table["difference"] = table["composite"] - table["presence"]
geographic = np.isfinite(table[["longitude", "latitude"]]).all(axis=1)
projected = np.isfinite(table[["easting", "northing"]]).all(axis=1)
if coordinate_system == "auto":
coordinate_system = "geographic" if geographic.any() else "projected"
valid = geographic if coordinate_system == "geographic" else projected
xkey, ykey = (
("longitude", "latitude")
if coordinate_system == "geographic"
else ("easting", "northing")
)
table = table.loc[valid].copy()
if axes is None:
fig = plt.figure(figsize=figsize)
grid = fig.add_gridspec(
3,
3,
height_ratios=(1.0, 0.045, 0.065),
left=0.075,
right=0.975,
bottom=0.075,
top=0.88,
wspace=0.08,
hspace=0.12,
)
map_axes = []
shared = None
for index in range(3):
ax = fig.add_subplot(grid[0, index], sharex=shared, sharey=shared)
if shared is None:
shared = ax
map_axes.append(ax)
longitude_ax = fig.add_subplot(grid[1, :])
longitude_ax.set_axis_off()
longitude_ax.text(
0.5,
0.5,
"Longitude (°)"
if coordinate_system == "geographic"
else "Easting (m)",
ha="center",
va="center",
fontsize=9,
)
confidence_cax = fig.add_subplot(grid[2, :2])
difference_cax = fig.add_subplot(grid[2, 2])
else:
map_axes = list(np.asarray(axes, dtype=object).ravel())
if len(map_axes) < 3:
raise ValueError("axes must provide three map axes.")
map_axes = map_axes[:3]
fig = map_axes[0].figure
confidence_cax = difference_cax = None
if table.empty:
map_axes[0].text(
0.5, 0.5, "no mapped stations", ha="center", va="center"
)
return fig
if line_labels is None:
labels = pd.Series("survey", index=table.index, dtype=object)
elif isinstance(line_labels, str):
labels = pd.Series(line_labels, index=table.index, dtype=object)
elif hasattr(line_labels, "get"):
labels = table["station"].astype(str).map(line_labels).fillna("survey")
else:
values = list(line_labels)
if len(values) != len(valid):
raise ValueError(
"line_labels must contain one value per table row."
)
labels = pd.Series(values, index=valid.index).loc[table.index]
table["_line"] = labels.astype(str)
from matplotlib.cm import ScalarMappable
from matplotlib.colors import Normalize, TwoSlopeNorm
confidence_norm = Normalize(0.0, 1.0, clip=True)
observed_abs = float(np.nanmax(np.abs(table["difference"])))
if difference_limit is None:
difference_limit = max(0.05, np.ceil(observed_abs * 20.0) / 20.0)
difference_limit = float(difference_limit)
if not np.isfinite(difference_limit) or difference_limit <= 0.0:
raise ValueError("difference_limit must be positive and finite.")
difference_norm = TwoSlopeNorm(
vmin=-difference_limit,
vcenter=0.0,
vmax=difference_limit,
)
panel_specs = (
(
"presence",
"(a) Presence confidence",
confidence_cmap,
confidence_norm,
),
(
"composite",
"(b) Composite confidence",
confidence_cmap,
confidence_norm,
),
(
"difference",
"(c) Composite − presence",
difference_cmap,
difference_norm,
),
)
xpad = max(float(table[xkey].max() - table[xkey].min()) * 0.03, 1e-12)
ypad = max(float(table[ykey].max() - table[ykey].min()) * 0.03, 1e-12)
for panel_index, (ax, (column, title, panel_cmap, norm)) in enumerate(
zip(map_axes, panel_specs)
):
if connect:
for _, group in table.groupby("_line", sort=False):
group = _order_map_route(group, xkey, ykey)
ax.plot(
group[xkey],
group[ykey],
color="0.48",
lw=0.8,
alpha=0.75,
zorder=1,
)
ax.scatter(
table[xkey],
table[ykey],
c=table[column],
cmap=panel_cmap,
norm=norm,
s=marker_size,
edgecolors="black",
linewidths=0.6,
zorder=2,
)
ax.set_title(title, loc="left", fontsize=10, fontweight="semibold")
ax.set_xlim(
float(table[xkey].min()) - xpad, float(table[xkey].max()) + xpad
)
ax.set_ylim(
float(table[ykey].min()) - ypad, float(table[ykey].max()) + ypad
)
ax.grid(True, ls=":", lw=0.55, color="0.78", alpha=0.75)
ax.tick_params(
labelsize=7.5,
direction="out",
length=3,
labelleft=panel_index == 0,
)
if map_aspect == "geographic" and coordinate_system == "geographic":
mean_lat = float(table[ykey].mean())
ax.set_aspect(1.0 / max(np.cos(np.deg2rad(mean_lat)), 1e-6))
elif map_aspect == "equal":
ax.set_aspect("equal", adjustable="box")
else:
ax.set_aspect("auto")
if show_statistics:
values = table[column].to_numpy(dtype=float)
if column == "difference":
summary = (
f"median = {np.nanmedian(values):+.2f}\n"
f"range = {np.nanmin(values):+.2f} to "
f"{np.nanmax(values):+.2f}"
)
else:
below = int(np.sum(values < ci_lo))
summary = (
f"median = {np.nanmedian(values):.2f}\n"
f"CR < {ci_lo:.2f}: {below}/{len(values)}"
)
ax.text(
0.025,
0.025,
summary,
transform=ax.transAxes,
ha="left",
va="bottom",
fontsize=7,
bbox={
"boxstyle": "round,pad=0.25",
"facecolor": "white",
"edgecolor": "0.55",
"alpha": 0.88,
"linewidth": 0.6,
},
zorder=4,
)
if station_labels:
step = max(1, int(station_label_step or np.ceil(len(table) / 18)))
for i in range(0, len(table), step):
row = table.iloc[i]
map_axes[0].annotate(
str(row["station"]),
(row[xkey], row[ykey]),
xytext=(3, 3),
textcoords="offset points",
fontsize=6,
)
if line_names and table["_line"].nunique() > 1:
for line, group in table.groupby("_line", sort=False):
row = group.loc[group[ykey].idxmax()]
map_axes[0].annotate(
str(line),
(row[xkey], row[ykey]),
xytext=(0, 3),
textcoords="offset points",
ha="center",
va="bottom",
rotation=90,
fontsize=6.5,
fontweight="bold",
)
confidence_mappable = ScalarMappable(
norm=confidence_norm, cmap=confidence_cmap
)
difference_mappable = ScalarMappable(
norm=difference_norm, cmap=difference_cmap
)
if confidence_cax is not None:
confidence_bar = fig.colorbar(
confidence_mappable,
cax=confidence_cax,
orientation="horizontal",
)
difference_bar = fig.colorbar(
difference_mappable,
cax=difference_cax,
orientation="horizontal",
)
else:
confidence_bar = fig.colorbar(
confidence_mappable,
ax=map_axes[:2],
orientation="horizontal",
fraction=0.05,
pad=0.14,
)
difference_bar = fig.colorbar(
difference_mappable,
ax=map_axes[2],
orientation="horizontal",
fraction=0.05,
pad=0.14,
)
confidence_bar.set_label("Confidence ratio", fontsize=8.5)
confidence_bar.set_ticks(sorted({0.0, ci_lo, ci_hi, 1.0}))
difference_bar.set_label("ΔCR (composite − presence)", fontsize=8.5)
difference_bar.set_ticks([-difference_limit, 0.0, difference_limit])
for bar in (confidence_bar, difference_bar):
bar.ax.tick_params(labelsize=7.5)
if axes is not None:
fig.supxlabel(
"Longitude (°)"
if coordinate_system == "geographic"
else "Easting (m)",
fontsize=9,
)
fig.supylabel(
"Latitude (°)"
if coordinate_system == "geographic"
else "Northing (m)",
fontsize=9,
)
fig.suptitle(
"Confidence-method comparison",
fontsize=12,
fontweight="semibold",
)
return fig
[docs]
def plot_confidence_before_after(
before_sites: Any,
after_sites: Any | None = None,
*,
method: str = "composite",
before_method: str | None = None,
after_method: str | None = None,
line_labels: Any = None,
before_label: str = "Before",
after_label: str = "After",
ci_hi: float = DEFAULT_CI_HI,
ci_lo: float = DEFAULT_CI_LO,
change_tolerance: float = 0.01,
show_errorbars: bool = True,
show_station_labels: bool | str = "auto",
station_label_step: int | None = None,
show_line_panel: bool = True,
improvement_color: str = "#1a9850",
degradation_color: str = "#d73027",
stable_color: str = "#7f7f7f",
delta_limit: float | None = None,
marker_size: float = 38.0,
figsize: tuple[float, float] = (12.2, 4.6),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
axes: Any = None,
) -> plt.Figure:
"""Compare matched station confidence before and after processing.
``after_sites`` defaults to ``before_sites`` so scoring-method changes can
also be audited explicitly with ``before_method`` and ``after_method``.
Stations are matched by name; unmatched stations are reported on the
returned figure but are excluded from paired change statistics.
"""
if not (0.0 <= ci_lo <= ci_hi <= 1.0):
raise ValueError(
"confidence thresholds must satisfy 0 <= ci_lo <= ci_hi <= 1."
)
tolerance = float(change_tolerance)
if not np.isfinite(tolerance) or tolerance < 0.0:
raise ValueError("change_tolerance must be non-negative and finite.")
before_method = str(before_method or method).lower()
after_method = str(after_method or method).lower()
for name in (before_method, after_method):
if name not in {"presence", "composite"}:
raise ValueError(
"confidence methods must be presence or composite."
)
if after_sites is None:
after_sites = before_sites
before = station_confidence_table(
before_sites,
method=before_method,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
api=False,
)
after = station_confidence_table(
after_sites,
method=after_method,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
api=False,
)
before_columns = [
"station",
"confidence",
"confidence_err",
"n_freq",
"n_ok",
]
after_columns = [
"station",
"confidence",
"confidence_err",
"n_freq",
"n_ok",
]
comparison = (
before[before_columns]
.rename(
columns={
"confidence": "before",
"confidence_err": "before_err",
"n_freq": "before_n_freq",
"n_ok": "before_n_ok",
}
)
.merge(
after[after_columns].rename(
columns={
"confidence": "after",
"confidence_err": "after_err",
"n_freq": "after_n_freq",
"n_ok": "after_n_ok",
}
),
on="station",
how="inner",
)
)
comparison = comparison.loc[
np.isfinite(comparison[["before", "after"]]).all(axis=1)
].copy()
comparison["delta"] = comparison["after"] - comparison["before"]
comparison["status"] = np.select(
[
comparison["delta"] > tolerance,
comparison["delta"] < -tolerance,
],
["improved", "degraded"],
default="stable",
)
if line_labels is None:
comparison["_line"] = "Survey"
elif isinstance(line_labels, str):
comparison["_line"] = line_labels
elif hasattr(line_labels, "get"):
comparison["_line"] = (
comparison["station"].astype(str).map(line_labels).fillna("Survey")
)
else:
values = list(line_labels)
if len(values) != len(before):
raise ValueError(
"line_labels must contain one value per before-data station."
)
mapping = dict(zip(before["station"].astype(str), values))
comparison["_line"] = comparison["station"].astype(str).map(mapping)
n_panels = 3 if show_line_panel else 2
if axes is None:
widths = (1.0, 1.45, 1.0) if show_line_panel else (1.0, 1.45)
fig, panel_axes = plt.subplots(
1,
n_panels,
figsize=figsize,
gridspec_kw={"width_ratios": widths},
constrained_layout=True,
)
else:
panel_axes = list(np.asarray(axes, dtype=object).ravel())
if len(panel_axes) < n_panels:
raise ValueError(f"axes must provide at least {n_panels} axes.")
panel_axes = panel_axes[:n_panels]
fig = panel_axes[0].figure
agreement_ax, delta_ax = panel_axes[:2]
line_ax = panel_axes[2] if show_line_panel else None
fig._pycsamt_before_after_table = comparison
fig._pycsamt_unmatched_before = sorted(
set(before["station"].astype(str)) - set(after["station"].astype(str))
)
fig._pycsamt_unmatched_after = sorted(
set(after["station"].astype(str)) - set(before["station"].astype(str))
)
if comparison.empty:
agreement_ax.text(
0.5, 0.5, "no matched stations", ha="center", va="center"
)
return fig
colors = comparison["status"].map(
{
"improved": improvement_color,
"degraded": degradation_color,
"stable": stable_color,
}
)
if show_errorbars:
agreement_ax.errorbar(
comparison["before"],
comparison["after"],
xerr=comparison["before_err"],
yerr=comparison["after_err"],
fmt="none",
ecolor="0.65",
elinewidth=0.55,
alpha=0.55,
zorder=1,
)
agreement_ax.scatter(
comparison["before"],
comparison["after"],
c=colors,
s=marker_size,
edgecolors="0.15",
linewidths=0.5,
alpha=0.88,
zorder=2,
)
agreement_ax.plot([0, 1], [0, 1], color="0.25", lw=1.0, ls="--")
for threshold in (ci_lo, ci_hi):
agreement_ax.axvline(threshold, color="0.55", lw=0.65, ls=":")
agreement_ax.axhline(threshold, color="0.55", lw=0.65, ls=":")
counts = comparison["status"].value_counts()
summary = (
f"Improved {counts.get('improved', 0):3d}\n"
f"Stable {counts.get('stable', 0):3d}\n"
f"Degraded {counts.get('degraded', 0):3d}\n"
f"Median Δ {np.nanmedian(comparison['delta']):+.3f}"
)
agreement_ax.text(
0.025,
0.975,
summary,
transform=agreement_ax.transAxes,
ha="left",
va="top",
fontsize=6.8,
family="monospace",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": "white",
"edgecolor": "0.55",
"alpha": 0.90,
"linewidth": 0.6,
},
)
agreement_ax.set_xlim(0.0, 1.02)
agreement_ax.set_ylim(0.0, 1.02)
agreement_ax.set_xlabel(f"{before_label} confidence")
agreement_ax.set_ylabel(f"{after_label} confidence")
agreement_ax.set_title(
"(a) Paired agreement", loc="left", fontsize=10, fontweight="semibold"
)
ordered = comparison.sort_values(
"delta", ascending=True, kind="stable"
).copy()
positions = np.arange(len(ordered)) + 1
delta_colors = ordered["status"].map(
{
"improved": improvement_color,
"degraded": degradation_color,
"stable": stable_color,
}
)
delta_ax.bar(
positions,
ordered["delta"],
color=delta_colors,
edgecolor="none",
width=0.82,
zorder=2,
)
delta_ax.axhline(0.0, color="0.18", lw=0.9)
delta_ax.axhspan(
-tolerance, tolerance, color=stable_color, alpha=0.12, lw=0
)
if delta_limit is None:
observed = float(np.nanmax(np.abs(comparison["delta"])))
delta_limit = max(tolerance * 1.5, observed * 1.12, 0.05)
delta_limit = float(delta_limit)
if not np.isfinite(delta_limit) or delta_limit <= 0.0:
raise ValueError("delta_limit must be positive and finite.")
delta_ax.set_ylim(-delta_limit, delta_limit)
delta_ax.set_xlim(0.25, len(ordered) + 0.75)
delta_ax.set_xlabel("Stations ordered by confidence change")
delta_ax.set_ylabel(f"ΔCR ({after_label} − {before_label})")
delta_ax.set_title(
"(b) Station-level change",
loc="left",
fontsize=10,
fontweight="semibold",
)
if show_station_labels == "auto":
annotate = len(ordered) <= 32
elif isinstance(show_station_labels, (bool, np.bool_)):
annotate = bool(show_station_labels)
else:
raise ValueError("show_station_labels must be bool or 'auto'.")
if annotate:
step = max(1, int(station_label_step or 1))
ticks = list(range(0, len(ordered), step))
if len(ordered) - 1 not in ticks:
ticks.append(len(ordered) - 1)
delta_ax.set_xticks(np.asarray(ticks) + 1)
delta_ax.set_xticklabels(
ordered.iloc[ticks]["station"].astype(str), rotation=65, ha="right"
)
else:
delta_ax.set_xticks([])
if line_ax is not None:
records = []
for line, group in comparison.groupby("_line", sort=False):
q1, median, q3 = np.nanpercentile(group["delta"], [25, 50, 75])
records.append(
{
"line": str(line),
"median_delta": float(median),
"q1": float(q1),
"q3": float(q3),
"improved": int(np.sum(group["status"] == "improved")),
"degraded": int(np.sum(group["status"] == "degraded")),
"n": len(group),
}
)
line_table = pd.DataFrame(records).sort_values(
"median_delta", ascending=True, kind="stable"
)
fig._pycsamt_before_after_line_table = line_table
y = np.arange(len(line_table))
line_colors = np.where(
line_table["median_delta"] > tolerance,
improvement_color,
np.where(
line_table["median_delta"] < -tolerance,
degradation_color,
stable_color,
),
)
line_ax.barh(
y,
line_table["median_delta"],
color=line_colors,
height=0.58,
edgecolor="0.20",
linewidth=0.5,
zorder=2,
)
lower = line_table["median_delta"] - line_table["q1"]
upper = line_table["q3"] - line_table["median_delta"]
line_ax.errorbar(
line_table["median_delta"],
y,
xerr=np.vstack([lower, upper]),
fmt="none",
ecolor="0.15",
elinewidth=1.0,
capsize=2.5,
zorder=3,
)
for position, row in zip(y, line_table.to_dict("records")):
line_ax.text(
delta_limit * 0.98,
position,
f"+{row['improved']} / −{row['degraded']} (n={row['n']})",
ha="right",
va="center",
fontsize=6.5,
)
line_ax.axvline(0.0, color="0.18", lw=0.9)
line_ax.axvspan(
-tolerance, tolerance, color=stable_color, alpha=0.12, lw=0
)
line_ax.set_xlim(-delta_limit, delta_limit)
line_ax.set_yticks(y)
line_ax.set_yticklabels(line_table["line"])
line_ax.set_xlabel("Median ΔCR (IQR)")
line_ax.set_title(
"(c) Survey-line change",
loc="left",
fontsize=10,
fontweight="semibold",
)
for ax in panel_axes:
ax.grid(True, ls=":", lw=0.5, color="0.78", alpha=0.7, zorder=0)
ax.tick_params(labelsize=7.5, direction="out", length=3)
unmatched = len(fig._pycsamt_unmatched_before) + len(
fig._pycsamt_unmatched_after
)
suffix = f"; {unmatched} unmatched" if unmatched else ""
title = (
f"Confidence before–after assessment "
f"({len(comparison)} matched{suffix})"
)
fig.suptitle(
title,
fontsize=12,
fontweight="semibold",
)
return fig
[docs]
def plot_confidence_risk_map(
sites: Any,
*,
method: str = "composite",
coordinate_system: str = "auto",
mode: str = "contour",
line_labels: Any = None,
connect: bool = True,
ci_hi: float = DEFAULT_CI_HI,
ci_lo: float = DEFAULT_CI_LO,
risk_levels: Any = None,
cmap: str = "YlOrRd",
segmented_colors: bool = True,
show_threshold_contours: bool = True,
threshold_colors: Any = ("#d98e00", "#a50f15"),
threshold_linewidths: Any = (1.4, 1.8),
threshold_linestyles: Any = ("--", "-"),
contour_labels: bool = True,
show_stations: bool = True,
marker_size: float = 38.0,
station_labels: bool = False,
station_label_step: int | None = None,
line_names: bool = True,
show_summary: bool = True,
max_triangle_edge: float | None = None,
map_aspect: str = "auto",
figsize: tuple[float, float] = (8.2, 5.8),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
"""Plot spatial confidence risk, defined as ``1 - confidence``.
The confidence limits map directly to operational risk classes:
``risk <= 1-ci_hi`` is low, ``1-ci_hi < risk <= 1-ci_lo`` is
moderate, and ``risk > 1-ci_lo`` is high. Contour mode is intended for
surveys containing at least three non-collinear stations; scatter mode
remains scientifically honest for a single profile.
"""
if not (0.0 <= ci_lo <= ci_hi <= 1.0):
raise ValueError(
"confidence thresholds must satisfy 0 <= ci_lo <= ci_hi <= 1."
)
coordinate_system = str(coordinate_system).lower()
if coordinate_system not in {"auto", "geographic", "projected"}:
raise ValueError(
"coordinate_system must be 'auto', 'geographic', or 'projected'."
)
mode = str(mode).lower()
if mode not in {"scatter", "contour"}:
raise ValueError("mode must be 'scatter' or 'contour'.")
map_aspect = str(map_aspect).lower()
if map_aspect not in {"auto", "equal", "geographic"}:
raise ValueError(
"map_aspect must be 'auto', 'equal', or 'geographic'."
)
table = station_confidence_table(
sites,
method=method,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
api=False,
).copy()
geographic = np.isfinite(table[["longitude", "latitude"]]).all(axis=1)
projected = np.isfinite(table[["easting", "northing"]]).all(axis=1)
if coordinate_system == "auto":
coordinate_system = "geographic" if geographic.any() else "projected"
valid = geographic if coordinate_system == "geographic" else projected
xkey, ykey = (
("longitude", "latitude")
if coordinate_system == "geographic"
else ("easting", "northing")
)
table = table.loc[valid & np.isfinite(table["confidence"])].copy()
table["risk"] = 1.0 - table["confidence"]
if ax is None:
_, ax = plt.subplots(figsize=figsize, constrained_layout=True)
ax._pycsamt_coordinate_system = coordinate_system
ax._pycsamt_risk_table = table
if table.empty:
ax.text(0.5, 0.5, "no mapped stations", ha="center", va="center")
return ax
if line_labels is None:
labels = pd.Series("survey", index=table.index, dtype=object)
elif isinstance(line_labels, str):
labels = pd.Series(line_labels, index=table.index, dtype=object)
elif hasattr(line_labels, "get"):
labels = table["station"].astype(str).map(line_labels).fillna("survey")
else:
values = list(line_labels)
if len(values) != len(valid):
raise ValueError(
"line_labels must contain one value per table row."
)
labels = pd.Series(values, index=valid.index).loc[table.index]
table["_line"] = labels.astype(str)
low_limit = 1.0 - ci_hi
high_limit = 1.0 - ci_lo
if risk_levels is None:
risk_levels = sorted(
{0.0, low_limit, 0.10, high_limit, 0.25, 0.50, 0.75, 1.0}
)
levels = np.unique(np.clip(np.asarray(risk_levels, dtype=float), 0.0, 1.0))
if len(levels) < 2 or levels[0] > 0.0 or levels[-1] < 1.0:
raise ValueError(
"risk_levels must span 0 to 1 with at least two values."
)
from matplotlib.colors import BoundaryNorm, Normalize
norm = (
BoundaryNorm(levels, plt.get_cmap(cmap).N, clip=True)
if segmented_colors
else Normalize(0.0, 1.0, clip=True)
)
triangulation = None
surface = None
if mode == "contour":
xy = table[[xkey, ykey]].to_numpy(dtype=float)
if len(xy) < 3 or np.linalg.matrix_rank(xy - xy.mean(axis=0)) < 2:
raise ValueError(
"contour risk maps require at least three non-collinear "
"station coordinates; use mode='scatter' for one line."
)
from matplotlib.tri import Triangulation
triangulation = Triangulation(xy[:, 0], xy[:, 1])
if max_triangle_edge is not None:
limit = float(max_triangle_edge)
if not np.isfinite(limit) or limit <= 0.0:
raise ValueError(
"max_triangle_edge must be positive and finite."
)
vertices = xy[triangulation.triangles]
edges = np.linalg.norm(
vertices - np.roll(vertices, -1, axis=1), axis=2
)
triangulation.set_mask((edges > limit).any(axis=1))
if np.all(triangulation.mask):
raise ValueError(
"max_triangle_edge masks every grid triangle."
)
surface = ax.tricontourf(
triangulation,
table["risk"],
levels=levels,
cmap=cmap,
norm=norm,
extend="neither",
zorder=0,
)
if show_threshold_contours:
observed = table["risk"].to_numpy(dtype=float)
boundaries = [
value
for value in (low_limit, high_limit)
if observed.min() < value < observed.max()
]
if boundaries:
colors = list(np.atleast_1d(threshold_colors))[
: len(boundaries)
]
widths = list(np.atleast_1d(threshold_linewidths))[
: len(boundaries)
]
styles = list(np.atleast_1d(threshold_linestyles))[
: len(boundaries)
]
contours = ax.tricontour(
triangulation,
table["risk"],
levels=boundaries,
colors=colors,
linewidths=widths,
linestyles=styles,
zorder=2,
)
if contour_labels:
fmt = {
low_limit: f"low/moderate R={low_limit:.2f}",
high_limit: f"moderate/high R={high_limit:.2f}",
}
ax.clabel(contours, fmt=fmt, fontsize=7, inline=True)
if connect:
for _, group in table.groupby("_line", sort=False):
group = _order_map_route(group, xkey, ykey)
ax.plot(
group[xkey],
group[ykey],
color="0.30",
lw=0.75,
alpha=0.72,
zorder=3,
)
points = None
if show_stations or mode == "scatter":
points = ax.scatter(
table[xkey],
table[ykey],
c=table["risk"],
cmap=cmap,
norm=norm,
s=marker_size,
edgecolors="white" if mode == "contour" else "0.15",
linewidths=0.55,
zorder=4,
)
if station_labels:
step = max(1, int(station_label_step or np.ceil(len(table) / 20)))
for index in range(0, len(table), step):
row = table.iloc[index]
ax.annotate(
str(row["station"]),
(row[xkey], row[ykey]),
xytext=(3, 3),
textcoords="offset points",
fontsize=6.2,
zorder=5,
)
if line_names and table["_line"].nunique() > 1:
for line, group in table.groupby("_line", sort=False):
row = group.loc[group[ykey].idxmax()]
ax.annotate(
str(line),
(row[xkey], row[ykey]),
xytext=(0, 5),
textcoords="offset points",
ha="center",
va="bottom",
fontsize=7,
fontweight="semibold",
zorder=6,
)
mappable = surface if surface is not None else points
colorbar = ax.figure.colorbar(mappable, ax=ax, pad=0.018, fraction=0.045)
colorbar.set_label("Confidence risk, R = 1 − CR", fontsize=9)
colorbar.set_ticks(sorted({0.0, low_limit, high_limit, 0.5, 1.0}))
colorbar.ax.tick_params(labelsize=7.5)
for boundary in (low_limit, high_limit):
colorbar.ax.axhline(boundary, color="0.15", lw=0.8)
if show_summary:
risk = table["risk"].to_numpy(dtype=float)
counts = (
int(np.sum(risk <= low_limit)),
int(np.sum((risk > low_limit) & (risk <= high_limit))),
int(np.sum(risk > high_limit)),
)
total = max(len(risk), 1)
summary = (
f"Low {counts[0]:3d} ({100 * counts[0] / total:4.1f}%)\n"
f"Moderate {counts[1]:3d} ({100 * counts[1] / total:4.1f}%)\n"
f"High {counts[2]:3d} ({100 * counts[2] / total:4.1f}%)"
)
ax.text(
0.018,
0.022,
summary,
transform=ax.transAxes,
ha="left",
va="bottom",
fontsize=7.2,
family="monospace",
bbox={
"boxstyle": "round,pad=0.35",
"facecolor": "white",
"edgecolor": "0.40",
"alpha": 0.90,
"linewidth": 0.65,
},
zorder=7,
)
ax.set_xlabel(
"Longitude (°)" if coordinate_system == "geographic" else "Easting (m)"
)
ax.set_ylabel(
"Latitude (°)" if coordinate_system == "geographic" else "Northing (m)"
)
ax.set_title(
f"Confidence-risk map ({method})",
loc="left",
fontsize=11,
fontweight="semibold",
)
xspan = float(table[xkey].max() - table[xkey].min())
yspan = float(table[ykey].max() - table[ykey].min())
xpad = max(0.025 * xspan, 1e-12)
ypad = max(0.035 * yspan, 1e-12)
ax.set_xlim(
float(table[xkey].min()) - xpad, float(table[xkey].max()) + xpad
)
ax.set_ylim(
float(table[ykey].min()) - ypad, float(table[ykey].max()) + ypad
)
ax.grid(True, ls=":", lw=0.5, color="0.72", alpha=0.65)
ax.tick_params(labelsize=8, direction="out", length=3)
if map_aspect == "geographic" and coordinate_system == "geographic":
mean_lat = float(table[ykey].mean())
ax.set_aspect(1.0 / max(np.cos(np.deg2rad(mean_lat)), 1e-6))
elif map_aspect == "equal":
ax.set_aspect("equal", adjustable="box")
else:
ax.set_aspect("auto")
return ax
[docs]
def plot_confidence_heatmap(
sites: Any,
*,
method: str = "composite",
components: Any = None,
line_labels: Any = None,
station_order: str = "route",
ci_hi: float = DEFAULT_CI_HI,
ci_lo: float = DEFAULT_CI_LO,
cmap: str = "RdYlGn",
segmented_colors: bool = True,
annotate: bool | str = "auto",
annotation_fmt: str = ".2f",
annotation_fontsize: float = 5.5,
station_label_step: int | None = None,
show_line_names: bool = True,
show_line_separators: bool = True,
missing_color: str = "#d9d9d9",
figsize: tuple[float, float] = (13.0, 4.8),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
"""Plot station confidence and its diagnostic components as a matrix.
Stations are columns and metrics are rows. With ``station_order='route'``
each survey line is ordered using coordinate-derived chainage and lines
remain contiguous. Missing component scores are shown explicitly rather
than being assigned a misleading confidence colour.
"""
if not (0.0 <= ci_lo <= ci_hi <= 1.0):
raise ValueError(
"confidence thresholds must satisfy 0 <= ci_lo <= ci_hi <= 1."
)
labels_by_component = {
"confidence": "Overall confidence",
"coverage": "Data coverage",
"uncertainty": "Tensor uncertainty",
"offdiag": "Off-diagonal consistency",
"diagonal": "Diagonal leakage",
"phase": "Phase smoothness",
"spatial": "Spatial coherence",
}
if components is None:
components = list(labels_by_component)
else:
components = [str(value).lower() for value in components]
unknown = sorted(set(components) - set(labels_by_component))
if unknown:
raise ValueError(f"unknown confidence components: {unknown}")
if not components:
raise ValueError("components must contain at least one metric.")
station_order = str(station_order).lower()
if station_order not in {"route", "input", "confidence"}:
raise ValueError(
"station_order must be 'route', 'input', or 'confidence'."
)
table = station_confidence_table(
sites,
method=method,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
api=False,
).copy()
if line_labels is None:
lines = pd.Series("survey", index=table.index, dtype=object)
elif isinstance(line_labels, str):
lines = pd.Series(line_labels, index=table.index, dtype=object)
elif hasattr(line_labels, "get"):
lines = table["station"].astype(str).map(line_labels).fillna("survey")
else:
values = list(line_labels)
if len(values) != len(table):
raise ValueError(
"line_labels must contain one value per table row."
)
lines = pd.Series(values, index=table.index)
table["_line"] = lines.astype(str)
table["_input_order"] = np.arange(len(table))
if station_order == "confidence":
table = table.sort_values("confidence", ascending=False, kind="stable")
elif station_order == "route":
ordered = []
for _, group in table.groupby("_line", sort=False):
geographic = np.isfinite(group[["longitude", "latitude"]]).all(
axis=1
)
projected = np.isfinite(group[["easting", "northing"]]).all(axis=1)
if geographic.all():
group = _order_map_route(group, "longitude", "latitude")
elif projected.all():
group = _order_map_route(group, "easting", "northing")
elif np.isfinite(group["distance_m"]).all():
group = group.sort_values("distance_m", kind="stable")
ordered.append(group)
if ordered:
table = pd.concat(ordered, axis=0)
if ax is None:
_, ax = plt.subplots(figsize=figsize, constrained_layout=True)
if table.empty:
ax.text(0.5, 0.5, "no confidence data", ha="center", va="center")
return ax
matrix = table[components].to_numpy(dtype=float).T
masked = np.ma.masked_invalid(matrix)
from matplotlib.colors import BoundaryNorm, Normalize
use_cmap = plt.get_cmap(cmap).copy()
use_cmap.set_bad(missing_color)
boundaries = sorted({0.0, 0.25, 0.50, 0.75, ci_lo, ci_hi, 1.0})
norm = (
BoundaryNorm(boundaries, use_cmap.N, clip=True)
if segmented_colors
else Normalize(0.0, 1.0, clip=True)
)
image = ax.imshow(
masked,
cmap=use_cmap,
norm=norm,
interpolation="nearest",
aspect="auto",
origin="upper",
)
ax._pycsamt_confidence_table = table
ax._pycsamt_heatmap_matrix = matrix
n_stations = len(table)
if annotate == "auto":
show_annotations = n_stations * len(components) <= 180
elif isinstance(annotate, (bool, np.bool_)):
show_annotations = bool(annotate)
else:
raise ValueError("annotate must be bool or 'auto'.")
if show_annotations:
for row in range(matrix.shape[0]):
for column in range(matrix.shape[1]):
value = matrix[row, column]
if not np.isfinite(value):
continue
color = "white" if value < 0.30 or value > 0.82 else "black"
ax.text(
column,
row,
format(value, annotation_fmt),
ha="center",
va="center",
fontsize=annotation_fontsize,
color=color,
)
step = max(1, int(station_label_step or np.ceil(n_stations / 28)))
ticks = list(range(0, n_stations, step))
if n_stations - 1 not in ticks:
ticks.append(n_stations - 1)
ax.set_xticks(ticks)
ax.set_xticklabels(
table.iloc[ticks]["station"].astype(str), rotation=60, ha="right"
)
ax.set_yticks(np.arange(len(components)))
ax.set_yticklabels([labels_by_component[key] for key in components])
ax.tick_params(axis="x", labelsize=6.5, length=2)
ax.tick_params(axis="y", labelsize=8, length=0)
ax.set_xlabel("Stations ordered along survey lines", fontsize=9)
if "confidence" in components and len(components) > 1:
confidence_row = components.index("confidence")
edge = confidence_row + 0.5
if confidence_row == len(components) - 1:
edge = confidence_row - 0.5
ax.axhline(edge, color="black", lw=1.5, zorder=4)
groups = []
start = 0
line_values = table["_line"].to_numpy(dtype=str)
for index in range(1, n_stations + 1):
if index == n_stations or line_values[index] != line_values[start]:
groups.append((line_values[start], start, index - 1))
start = index
for line, first, last in groups:
if show_line_separators and last < n_stations - 1:
ax.axvline(last + 0.5, color="white", lw=2.4, zorder=3)
ax.axvline(last + 0.5, color="0.20", lw=0.55, zorder=4)
if show_line_names and (len(groups) > 1 or line != "survey"):
ax.text(
(first + last) / 2,
1.015,
line,
transform=ax.get_xaxis_transform(),
ha="center",
va="bottom",
fontsize=7.5,
fontweight="semibold",
clip_on=False,
)
colorbar = ax.figure.colorbar(image, ax=ax, pad=0.015, fraction=0.025)
colorbar.set_label("Confidence score", fontsize=9)
colorbar.set_ticks(sorted({0.0, 0.5, ci_lo, ci_hi, 1.0}))
colorbar.ax.tick_params(labelsize=7.5)
ax.set_title(
f"Station confidence diagnostic matrix ({method})",
loc="left",
fontsize=11,
fontweight="semibold",
pad=18 if show_line_names else 8,
)
return ax
[docs]
def plot_confidence_coverage_curve(
sites: Any,
*,
method: str = "composite",
line_labels: Any = None,
thresholds: Any = None,
ci_hi: float = DEFAULT_CI_HI,
ci_lo: float = DEFAULT_CI_LO,
show_station_retention: bool = True,
show_data_retention: bool = True,
show_route_retention: bool = True,
show_line_curves: bool = True,
show_threshold_values: bool = True,
station_color: str = "#2166ac",
data_color: str = "#7b3294",
route_color: str = "#1b7837",
line_cmap: str = "tab10",
figsize: tuple[float, float] = (11.5, 4.6),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
axes: Any = None,
) -> plt.Figure:
"""Plot survey retention as the minimum confidence requirement rises.
Station retention is the fraction of stations meeting a threshold. Data
retention weights qualifying stations by their number of valid transfer-
function rows. Route retention is the fraction of connected survey length
whose two bounding stations both qualify; it therefore detects spatial
fragmentation that a station count alone can hide.
"""
if not (0.0 <= ci_lo <= ci_hi <= 1.0):
raise ValueError(
"confidence thresholds must satisfy 0 <= ci_lo <= ci_hi <= 1."
)
if not any(
(show_station_retention, show_data_retention, show_route_retention)
):
raise ValueError("enable at least one retention measure.")
if thresholds is None:
thresholds = np.linspace(0.0, 1.0, 201)
thresholds = np.unique(np.asarray(thresholds, dtype=float))
if (
len(thresholds) < 2
or not np.isfinite(thresholds).all()
or thresholds[0] < 0.0
or thresholds[-1] > 1.0
):
raise ValueError(
"thresholds must contain at least two finite values within 0 to 1."
)
table = station_confidence_table(
sites,
method=method,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
api=False,
).copy()
table = table.loc[np.isfinite(table["confidence"])].copy()
if line_labels is None:
table["_line"] = "Survey"
elif isinstance(line_labels, str):
table["_line"] = line_labels
elif hasattr(line_labels, "get"):
table["_line"] = (
table["station"].astype(str).map(line_labels).fillna("Survey")
)
else:
values = list(line_labels)
if len(values) != len(table):
raise ValueError("line_labels must contain one value per station.")
table["_line"] = values
if axes is None:
if show_line_curves:
fig, panel_axes = plt.subplots(
1,
2,
figsize=figsize,
gridspec_kw={"width_ratios": (1.35, 1.0)},
constrained_layout=True,
)
else:
fig, survey_ax = plt.subplots(
figsize=figsize, constrained_layout=True
)
panel_axes = [survey_ax]
else:
panel_axes = list(np.asarray(axes, dtype=object).ravel())
required = 2 if show_line_curves else 1
if len(panel_axes) < required:
raise ValueError(f"axes must provide at least {required} axes.")
panel_axes = panel_axes[:required]
fig = panel_axes[0].figure
survey_ax = panel_axes[0]
line_ax = panel_axes[1] if show_line_curves else None
if table.empty:
survey_ax.text(
0.5, 0.5, "no confidence data", ha="center", va="center"
)
return fig
def _ordered_with_segment_lengths(group: pd.DataFrame) -> pd.DataFrame:
geographic = np.isfinite(group[["longitude", "latitude"]]).all(axis=1)
projected = np.isfinite(group[["easting", "northing"]]).all(axis=1)
if geographic.all():
ordered = _order_map_route(group, "longitude", "latitude").copy()
xy = ordered[["longitude", "latitude"]].to_numpy(float)
mean_lat = float(np.nanmean(xy[:, 1]))
dx = np.diff(xy[:, 0]) * 111_320.0 * np.cos(np.deg2rad(mean_lat))
dy = np.diff(xy[:, 1]) * 110_540.0
lengths = np.hypot(dx, dy)
elif projected.all():
ordered = _order_map_route(group, "easting", "northing").copy()
xy = ordered[["easting", "northing"]].to_numpy(float)
lengths = np.linalg.norm(np.diff(xy, axis=0), axis=1)
elif np.isfinite(group["distance_m"]).all():
ordered = group.sort_values("distance_m", kind="stable").copy()
lengths = np.abs(np.diff(ordered["distance_m"].to_numpy(float)))
else:
ordered = group.copy()
lengths = np.ones(max(0, len(group) - 1), dtype=float)
ordered.attrs["segment_lengths"] = lengths
return ordered
ordered_lines = [
_ordered_with_segment_lengths(group)
for _, group in table.groupby("_line", sort=False)
]
confidence = table["confidence"].to_numpy(float)
n_ok = table["n_ok"].to_numpy(float)
station_retention = []
data_retention = []
route_retention = []
total_valid = max(float(np.nansum(n_ok)), 1.0)
total_route = sum(
float(np.nansum(group.attrs["segment_lengths"]))
for group in ordered_lines
)
for threshold in thresholds:
keep = confidence >= threshold
station_retention.append(float(np.mean(keep)))
data_retention.append(float(np.nansum(n_ok[keep]) / total_valid))
retained_route = 0.0
for group in ordered_lines:
line_keep = group["confidence"].to_numpy(float) >= threshold
lengths = group.attrs["segment_lengths"]
if len(lengths):
retained_route += float(
np.nansum(lengths[line_keep[:-1] & line_keep[1:]])
)
if total_route > 0.0:
route_retention.append(retained_route / total_route)
else:
route_retention.append(float(np.mean(keep)))
curve_table = pd.DataFrame(
{
"threshold": thresholds,
"station_retention": station_retention,
"data_retention": data_retention,
"route_retention": route_retention,
}
)
fig._pycsamt_coverage_curve_table = curve_table
specs = (
(
show_station_retention,
"station_retention",
"Stations",
station_color,
"-",
),
(
show_data_retention,
"data_retention",
"Valid TF samples",
data_color,
"--",
),
(
show_route_retention,
"route_retention",
"Connected route",
route_color,
"-.",
),
)
for enabled, column, label, color, linestyle in specs:
if not enabled:
continue
values = curve_table[column].to_numpy(float)
auc = float(_trapz(values, thresholds))
survey_ax.plot(
thresholds,
values,
color=color,
lw=2.0,
ls=linestyle,
label=f"{label} (AUC={auc:.2f})",
)
for threshold in (ci_lo, ci_hi):
survey_ax.axvline(threshold, color="0.25", lw=0.85, ls=":")
survey_ax.axvspan(0.0, ci_lo, color="#f4a6a6", alpha=0.12, lw=0)
survey_ax.axvspan(ci_lo, ci_hi, color="#f6d78b", alpha=0.16, lw=0)
survey_ax.axvspan(ci_hi, 1.0, color="#a9d8a0", alpha=0.16, lw=0)
survey_ax.set_title(
"(a) Survey retention",
loc="left",
fontsize=10,
fontweight="semibold",
)
survey_ax.set_xlabel("Minimum accepted confidence ratio")
survey_ax.set_ylabel("Retained survey fraction")
survey_ax.legend(frameon=False, fontsize=7.5, loc="lower left")
if line_ax is not None:
line_records = []
colors = plt.get_cmap(line_cmap)(
np.linspace(0.0, 1.0, max(len(ordered_lines), 2))
)
for color, group in zip(colors, ordered_lines):
values = group["confidence"].to_numpy(float)
retention = np.asarray(
[np.mean(values >= threshold) for threshold in thresholds]
)
line = str(group["_line"].iloc[0])
auc = float(_trapz(retention, thresholds))
line_ax.step(
thresholds,
retention,
where="post",
color=color,
lw=1.55,
label=f"{line} (AUC={auc:.2f})",
)
record = {"line": line, "auc": auc, "n_stations": len(values)}
for threshold in (ci_lo, ci_hi):
record[f"retained_at_{threshold:.2f}"] = float(
np.mean(values >= threshold)
)
if show_threshold_values:
line_ax.scatter(
[threshold],
[record[f"retained_at_{threshold:.2f}"]],
s=17,
color=color,
edgecolor="white",
linewidth=0.4,
zorder=4,
)
line_records.append(record)
fig._pycsamt_line_coverage_table = pd.DataFrame(line_records)
for threshold in (ci_lo, ci_hi):
line_ax.axvline(threshold, color="0.25", lw=0.85, ls=":")
line_ax.set_title(
"(b) Retention by survey line",
loc="left",
fontsize=10,
fontweight="semibold",
)
line_ax.set_xlabel("Minimum accepted confidence ratio")
line_ax.set_ylabel("Retained station fraction")
line_ax.legend(
frameon=False,
fontsize=7,
loc="lower left",
ncol=2 if len(ordered_lines) > 4 else 1,
)
for ax in panel_axes:
ax.set_xlim(0.0, 1.0)
ax.set_ylim(0.0, 1.02)
ax.grid(True, ls=":", lw=0.5, color="0.78", alpha=0.7)
ax.tick_params(labelsize=7.5, direction="out", length=3)
fig.suptitle(
f"Confidence coverage curve ({method})",
fontsize=12,
fontweight="semibold",
)
return fig
[docs]
def plot_confidence_distribution(
sites: Any,
*,
method: str = "composite",
metric: str = "confidence",
line_labels: Any = None,
ci_hi: float = DEFAULT_CI_HI,
ci_lo: float = DEFAULT_CI_LO,
bins: int | Any = 16,
density: bool = True,
show_rug: bool = True,
show_violin: bool = True,
show_points: bool = True,
presence_color: str = "#377eb8",
composite_color: str = "#e6550d",
metric_color: str = "#5e3c99",
class_colors: tuple[str, str, str] = (
"#f4a6a6",
"#f6d78b",
"#a9d8a0",
),
figsize: tuple[float, float] = (12.0, 4.3),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
axes: Any = None,
) -> plt.Figure:
"""Summarize confidence distributions, exceedance, and line spread.
The three panels show a histogram with a lightweight Gaussian density,
empirical cumulative distributions, and line-wise violin/box summaries.
``method='both'`` compares matched presence and composite scores. For a
diagnostic component, select one method and pass its column as ``metric``.
"""
if not (0.0 <= ci_lo <= ci_hi <= 1.0):
raise ValueError(
"confidence thresholds must satisfy 0 <= ci_lo <= ci_hi <= 1."
)
method = str(method).lower()
if method not in {"presence", "composite", "both"}:
raise ValueError("method must be 'presence', 'composite', or 'both'.")
valid_metrics = {
"confidence": "Confidence ratio",
"coverage": "Data coverage",
"uncertainty": "Tensor uncertainty",
"offdiag": "Off-diagonal consistency",
"diagonal": "Diagonal leakage",
"phase": "Phase smoothness",
"spatial": "Spatial coherence",
}
metric = str(metric).lower()
if metric not in valid_metrics:
raise ValueError(f"unknown confidence metric: {metric!r}")
if method == "both" and metric != "confidence":
raise ValueError("method='both' is available only for confidence.")
methods = ("presence", "composite") if method == "both" else (method,)
frames = []
for name in methods:
frame = station_confidence_table(
sites,
method=name,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
api=False,
).copy()
frame["_method"] = name
frames.append(frame)
table = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
if line_labels is None:
table["_line"] = "Survey"
elif isinstance(line_labels, str):
table["_line"] = line_labels
elif hasattr(line_labels, "get"):
table["_line"] = (
table["station"].astype(str).map(line_labels).fillna("Survey")
)
else:
values = list(line_labels)
expected = len(frames[0]) if frames else 0
if len(values) != expected:
raise ValueError("line_labels must contain one value per station.")
mapping = dict(zip(frames[0]["station"].astype(str), values))
table["_line"] = table["station"].astype(str).map(mapping)
table = table.loc[np.isfinite(table[metric])].copy()
if axes is None:
fig, panel_axes = plt.subplots(
1,
3,
figsize=figsize,
gridspec_kw={"width_ratios": (1.15, 1.0, 1.35)},
constrained_layout=True,
)
else:
panel_axes = list(np.asarray(axes, dtype=object).ravel())
if len(panel_axes) < 3:
raise ValueError("axes must provide three plotting axes.")
panel_axes = panel_axes[:3]
fig = panel_axes[0].figure
hist_ax, ecdf_ax, violin_ax = panel_axes
if table.empty:
hist_ax.text(0.5, 0.5, "no finite scores", ha="center", va="center")
return fig
palette = {
"presence": presence_color,
"composite": composite_color,
}
if method != "both":
palette[method] = (
metric_color if metric != "confidence" else composite_color
)
class_spans = (
(0.0, ci_lo, class_colors[0]),
(ci_lo, ci_hi, class_colors[1]),
(ci_hi, 1.0, class_colors[2]),
)
for ax in (hist_ax, ecdf_ax):
for lower, upper, color in class_spans:
ax.axvspan(lower, upper, color=color, alpha=0.20, lw=0, zorder=0)
for threshold in (ci_lo, ci_hi):
ax.axvline(threshold, color="0.25", lw=0.8, ls="--", zorder=1)
if np.isscalar(bins):
histogram_bins = np.linspace(0.0, 1.0, int(bins) + 1)
else:
histogram_bins = np.asarray(bins, dtype=float)
if len(histogram_bins) < 2:
raise ValueError("bins must define at least one interval.")
xgrid = np.linspace(0.0, 1.0, 300)
for name in methods:
values = table.loc[table["_method"] == name, metric].to_numpy(float)
color = palette[name]
hist_ax.hist(
values,
bins=histogram_bins,
density=density,
histtype="stepfilled",
color=color,
edgecolor=color,
linewidth=0.8,
alpha=0.28 if method == "both" else 0.55,
label=name.capitalize(),
zorder=2,
)
if density and len(values) > 1:
spread = float(np.nanstd(values, ddof=1))
bandwidth = max(0.025, 1.06 * spread * len(values) ** (-0.2))
kernels = np.exp(
-0.5 * ((xgrid[:, None] - values[None, :]) / bandwidth) ** 2
)
kde = kernels.mean(axis=1) / (bandwidth * np.sqrt(2.0 * np.pi))
hist_ax.plot(xgrid, kde, color=color, lw=1.8, zorder=3)
if show_rug:
hist_ax.plot(
values,
np.full(len(values), -0.015),
"|",
color=color,
ms=4,
alpha=0.55,
transform=hist_ax.get_xaxis_transform(),
clip_on=False,
)
ordered = np.sort(values)
probability = np.arange(1, len(ordered) + 1) / len(ordered)
ecdf_ax.step(
ordered,
probability,
where="post",
color=color,
lw=1.8,
label=name.capitalize(),
)
median = float(np.nanmedian(values))
hist_ax.axvline(median, color=color, lw=1.2, ls=":", zorder=3)
hist_ax.set_title(
"(a) Score distribution", loc="left", fontweight="semibold"
)
hist_ax.set_ylabel("Probability density" if density else "Station count")
ecdf_ax.set_title(
"(b) Cumulative fraction", loc="left", fontweight="semibold"
)
ecdf_ax.set_ylabel("Fraction of stations ≤ score")
ecdf_ax.set_ylim(0.0, 1.02)
ecdf_ax.set_yticks(np.linspace(0.0, 1.0, 6))
if method == "both":
hist_ax.legend(frameon=False, fontsize=8, loc="upper left")
ecdf_ax.legend(frameon=False, fontsize=8, loc="lower right")
line_order = list(dict.fromkeys(table["_line"].astype(str)))
offsets = (
np.linspace(-0.18, 0.18, len(methods))
if len(methods) > 1
else np.asarray([0.0])
)
rng = np.random.default_rng(0)
for method_index, name in enumerate(methods):
for line_index, line in enumerate(line_order):
values = table.loc[
(table["_method"] == name)
& (table["_line"].astype(str) == line),
metric,
].to_numpy(float)
if not len(values):
continue
position = line_index + 1 + offsets[method_index]
if show_violin and len(values) > 1:
violin = violin_ax.violinplot(
[values],
positions=[position],
widths=0.30 if len(methods) > 1 else 0.58,
showextrema=False,
)
for body in violin["bodies"]:
body.set_facecolor(palette[name])
body.set_edgecolor("0.20")
body.set_linewidth(0.6)
body.set_alpha(0.55)
q1, median, q3 = np.nanpercentile(values, [25, 50, 75])
violin_ax.vlines(position, q1, q3, color="0.15", lw=2.0, zorder=4)
violin_ax.scatter(
[position],
[median],
s=18,
color="white",
edgecolor="0.15",
zorder=5,
)
if show_points:
jitter = rng.uniform(-0.035, 0.035, len(values))
violin_ax.scatter(
np.full(len(values), position) + jitter,
values,
s=6,
color=palette[name],
alpha=0.35,
linewidths=0,
zorder=3,
)
violin_ax.axhspan(0.0, ci_lo, color=class_colors[0], alpha=0.12, lw=0)
violin_ax.axhspan(ci_lo, ci_hi, color=class_colors[1], alpha=0.16, lw=0)
violin_ax.axhspan(ci_hi, 1.0, color=class_colors[2], alpha=0.16, lw=0)
for threshold in (ci_lo, ci_hi):
violin_ax.axhline(threshold, color="0.25", lw=0.8, ls="--")
violin_ax.set_xticks(np.arange(1, len(line_order) + 1))
violin_ax.set_xticklabels(line_order, rotation=35, ha="right")
violin_ax.set_ylim(0.0, 1.02)
violin_ax.set_ylabel(valid_metrics[metric])
violin_ax.set_title(
"(c) Survey-line spread", loc="left", fontweight="semibold"
)
if method == "both":
from matplotlib.lines import Line2D
handles = [
Line2D(
[0],
[0],
marker="o",
linestyle="none",
markerfacecolor=palette[name],
markeredgecolor="0.20",
markersize=5,
label=name.capitalize(),
)
for name in methods
]
violin_ax.legend(
handles=handles,
frameon=False,
fontsize=7.5,
loc="lower right",
)
for ax in panel_axes:
ax.grid(True, ls=":", lw=0.5, color="0.78", alpha=0.7)
ax.tick_params(labelsize=7.5, direction="out", length=3)
for ax in (hist_ax, ecdf_ax):
ax.set_xlim(0.0, 1.0)
ax.set_xlabel(valid_metrics[metric])
method_title = "presence vs composite" if method == "both" else method
fig.suptitle(
f"Confidence distribution ({method_title})",
fontsize=12,
fontweight="semibold",
)
return fig
[docs]
def plot_confidence_rank(
sites: Any,
*,
method: str = "composite",
metric: str = "confidence",
line_labels: Any = None,
order: str = "worst",
max_stations: int | None = None,
ci_hi: float = DEFAULT_CI_HI,
ci_lo: float = DEFAULT_CI_LO,
cmap: str = "RdYlGn",
connect: bool = True,
annotate_stations: bool | str = "auto",
station_label_step: int | None = None,
show_values: bool = False,
show_line_panel: bool = True,
line_statistic: str = "median",
show_iqr: bool = True,
marker_size: float = 36.0,
figsize: tuple[float, float] = (11.5, 4.8),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
axes: Any = None,
) -> plt.Figure:
"""Rank station scores and survey-line performance.
Station ranks use the exact selected metric. The line panel ranks either
the median (default, robust to isolated failures) or mean, and optionally
shows the interquartile range. Rank 1 follows ``order``: the lowest score
for ``'worst'`` and the highest score for ``'best'``.
"""
if not (0.0 <= ci_lo <= ci_hi <= 1.0):
raise ValueError(
"confidence thresholds must satisfy 0 <= ci_lo <= ci_hi <= 1."
)
metrics = {
"confidence": "Confidence ratio",
"coverage": "Data coverage",
"uncertainty": "Tensor uncertainty",
"offdiag": "Off-diagonal consistency",
"diagonal": "Diagonal leakage",
"phase": "Phase smoothness",
"spatial": "Spatial coherence",
}
metric = str(metric).lower()
if metric not in metrics:
raise ValueError(f"unknown confidence metric: {metric!r}")
order = str(order).lower()
if order not in {"worst", "best"}:
raise ValueError("order must be 'worst' or 'best'.")
line_statistic = str(line_statistic).lower()
if line_statistic not in {"median", "mean"}:
raise ValueError("line_statistic must be 'median' or 'mean'.")
table = station_confidence_table(
sites,
method=method,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
api=False,
).copy()
if line_labels is None:
table["_line"] = "Survey"
elif isinstance(line_labels, str):
table["_line"] = line_labels
elif hasattr(line_labels, "get"):
table["_line"] = (
table["station"].astype(str).map(line_labels).fillna("Survey")
)
else:
values = list(line_labels)
if len(values) != len(table):
raise ValueError("line_labels must contain one value per station.")
table["_line"] = values
table = table.loc[np.isfinite(table[metric])].copy()
ascending = order == "worst"
ranked = table.sort_values(
metric, ascending=ascending, kind="stable"
).copy()
if max_stations is not None:
max_stations = int(max_stations)
if max_stations < 1:
raise ValueError("max_stations must be at least 1.")
ranked = ranked.iloc[:max_stations].copy()
ranked["rank"] = np.arange(1, len(ranked) + 1)
if axes is None:
if show_line_panel:
fig, panel_axes = plt.subplots(
1,
2,
figsize=figsize,
gridspec_kw={"width_ratios": (2.25, 1.0)},
constrained_layout=True,
)
else:
fig, station_ax = plt.subplots(
figsize=figsize, constrained_layout=True
)
panel_axes = [station_ax]
else:
panel_axes = list(np.asarray(axes, dtype=object).ravel())
required = 2 if show_line_panel else 1
if len(panel_axes) < required:
raise ValueError(f"axes must provide at least {required} axes.")
panel_axes = panel_axes[:required]
fig = panel_axes[0].figure
station_ax = panel_axes[0]
line_ax = panel_axes[1] if show_line_panel else None
fig._pycsamt_rank_table = ranked
if ranked.empty:
station_ax.text(0.5, 0.5, "no finite scores", ha="center", va="center")
return fig
from matplotlib.colors import BoundaryNorm
boundaries = sorted({0.0, 0.25, 0.50, 0.75, ci_lo, ci_hi, 1.0})
norm = BoundaryNorm(boundaries, plt.get_cmap(cmap).N, clip=True)
if connect:
station_ax.plot(
ranked["rank"],
ranked[metric],
color="0.48",
lw=0.8,
zorder=1,
)
points = station_ax.scatter(
ranked["rank"],
ranked[metric],
c=ranked[metric],
cmap=cmap,
norm=norm,
s=marker_size,
edgecolor="0.15",
linewidth=0.5,
zorder=3,
)
for lower, upper, color in (
(0.0, ci_lo, "#f4a6a6"),
(ci_lo, ci_hi, "#f6d78b"),
(ci_hi, 1.0, "#a9d8a0"),
):
station_ax.axhspan(
lower, upper, color=color, alpha=0.16, lw=0, zorder=0
)
for threshold, label in (
(ci_lo, f"CR = {ci_lo:.2f}"),
(ci_hi, f"CR = {ci_hi:.2f}"),
):
station_ax.axhline(threshold, color="0.25", lw=0.85, ls="--", zorder=2)
station_ax.text(
0.995,
threshold,
label,
transform=station_ax.get_yaxis_transform(),
ha="right",
va="bottom",
fontsize=6.5,
color="0.25",
)
if annotate_stations == "auto":
annotate = len(ranked) <= 35
elif isinstance(annotate_stations, (bool, np.bool_)):
annotate = bool(annotate_stations)
else:
raise ValueError("annotate_stations must be bool or 'auto'.")
if annotate:
step = max(1, int(station_label_step or 1))
indices = list(range(0, len(ranked), step))
if len(ranked) - 1 not in indices:
indices.append(len(ranked) - 1)
for index in indices:
row = ranked.iloc[index]
label = str(row["station"])
if show_values:
label += f" {row[metric]:.2f}"
station_ax.annotate(
label,
(row["rank"], row[metric]),
xytext=(0, 5),
textcoords="offset points",
ha="center",
va="bottom",
rotation=70 if len(ranked) > 15 else 45,
fontsize=6,
clip_on=True,
)
station_ax.set_xlim(0.25, len(ranked) + 0.75)
station_ax.set_ylim(0.0, 1.025)
station_ax.set_xlabel(f"Station rank (1 = {order})")
station_ax.set_ylabel(metrics[metric])
station_ax.set_title(
"(a) Station ranking", loc="left", fontsize=10, fontweight="semibold"
)
colorbar = fig.colorbar(points, ax=station_ax, pad=0.012, fraction=0.035)
colorbar.set_label(metrics[metric], fontsize=8)
colorbar.set_ticks(sorted({0.0, 0.5, ci_lo, ci_hi, 1.0}))
colorbar.ax.tick_params(labelsize=7)
if line_ax is not None:
records = []
for line, group in table.groupby("_line", sort=False):
values = group[metric].to_numpy(float)
q1, median, q3 = np.nanpercentile(values, [25, 50, 75])
statistic = (
median if line_statistic == "median" else np.nanmean(values)
)
records.append(
{
"line": str(line),
"score": float(statistic),
"q1": float(q1),
"q3": float(q3),
"n": len(values),
}
)
line_table = pd.DataFrame(records).sort_values(
"score", ascending=ascending, kind="stable"
)
fig._pycsamt_line_rank_table = line_table
positions = np.arange(len(line_table))
colors = plt.get_cmap(cmap)(norm(line_table["score"].to_numpy(float)))
line_ax.barh(
positions,
line_table["score"],
color=colors,
edgecolor="0.20",
linewidth=0.55,
height=0.62,
zorder=2,
)
if show_iqr:
lower = line_table["score"] - line_table["q1"]
upper = line_table["q3"] - line_table["score"]
lower = np.maximum(lower, 0.0)
upper = np.maximum(upper, 0.0)
line_ax.errorbar(
line_table["score"],
positions,
xerr=np.vstack([lower, upper]),
fmt="none",
ecolor="0.12",
elinewidth=1.1,
capsize=2.5,
zorder=3,
)
for position, row in zip(positions, line_table.to_dict("records")):
line_ax.text(
min(row["score"] + 0.018, 0.98),
position,
f"{row['score']:.2f} (n={row['n']})",
va="center",
ha="left" if row["score"] < 0.90 else "right",
fontsize=6.8,
color="0.15",
zorder=4,
)
for threshold in (ci_lo, ci_hi):
line_ax.axvline(threshold, color="0.25", lw=0.8, ls="--", zorder=1)
line_ax.set_yticks(positions)
line_ax.set_yticklabels(line_table["line"])
line_ax.invert_yaxis()
line_ax.set_xlim(0.0, 1.0)
line_ax.set_xlabel(f"Line {line_statistic}")
line_ax.set_title(
"(b) Survey-line ranking",
loc="left",
fontsize=10,
fontweight="semibold",
)
for ax in panel_axes:
ax.grid(True, ls=":", lw=0.5, color="0.78", alpha=0.7, zorder=0)
ax.tick_params(labelsize=7.5, direction="out", length=3)
fig.suptitle(
f"Confidence ranking ({method}; {metrics[metric].lower()})",
fontsize=12,
fontweight="semibold",
)
return fig
# -------------------- 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,
annotate_low_step: int | None = None,
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,
force_spacing: bool = False,
lines: dict[str, str] | None = None,
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.
annotate_low : bool
If ``True``, draw a rotated station-name label above each point
below ``ci_lo``. Set ``False`` to turn these off entirely -- e.g.
when ``station_labels`` (the top-axis station ticks) already
identifies every station and the per-point labels would just
duplicate it.
annotate_low_step : int or None
Gap between labeled low-confidence points, analogous to
``station_label_step`` but applied only to the (typically much
smaller) subset of points below ``ci_lo``. ``None`` auto-thins
once there are more than 18 low points, the same threshold used
for the top axis, so a survey where most stations are flagged
doesn't end up with every single one labeled. ``1`` forces every
low point to be labeled regardless of count.
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
The x-axis is real inter-station distance projected along the
survey line (from EDI east/north, or lat/lon as a fallback)
whenever at least two stations carry usable coordinates.
``spacing_m`` is only used as a uniform fallback for stations
without coordinates, or for the whole line when none have any.
force_spacing : bool
If ``True``, skip coordinate lookup entirely and lay every
station out at uniform ``spacing_m`` steps -- e.g. when the
available coordinates are known to be unreliable and a
user-supplied spacing should be trusted instead.
lines : dict or None
``{station: line}`` for a survey of several profiles. Each line
gets its own distance axis (from its first station) and its own
coloured connector; without it every station is projected onto
one first-to-last bearing, which for several parallel lines
stacks unrelated stations on top of each other. The top station
axis is drawn only for a single line.
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)
table_kw = dict(
method=method,
weights=weights,
spacing_m=spacing_m,
force_spacing=force_spacing,
on_dup=on_dup,
strict=strict,
verbose=verbose,
api=False,
)
if lines:
S = ensure_sites(
sites, recursive=recursive, on_dup=on_dup, strict=strict,
verbose=verbose,
)
groups: dict[str, list] = {}
for i, ed in enumerate(_iter_items(S)):
key = str(lines.get(_name(ed, i), "") or "unassigned")
groups.setdefault(key, []).append(ed)
parts = []
for key, eds in groups.items():
part = station_confidence_table(eds, recursive=False, **table_kw)
part["line"] = key
parts.append(part)
tb = (
pd.concat(parts, ignore_index=True)
if parts
else pd.DataFrame(columns=["station", "distance_m", "confidence"])
)
else:
tb = station_confidence_table(sites, recursive=recursive, **table_kw)
tb["line"] = ""
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()
line_of = tb["line"].astype(str).to_numpy()
line_names = list(dict.fromkeys(line_of))
multi_line = len(line_names) > 1
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,
)
line_handles = []
if len(xs):
# connect stations in distance order, one connector per line (the
# table order is the file order: joining it zig-zagged)
palette = plt.get_cmap("tab10")
for k, key in enumerate(line_names):
sel = np.flatnonzero(line_of == key)
sel = sel[np.argsort(xs[sel], kind="stable")]
color = palette(k % 10) if multi_line else "black"
ax.plot(xs[sel], ys[sel], color=color, lw=1.3 if multi_line
else 1.5, zorder=2)
if multi_line:
line_handles.append(
plt.Line2D([], [], color=color, lw=1.6, label=key)
)
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,
)
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="#d62728",
mec="black",
label=f"Conf. < {ci_lo:.2f}",
),
]
if station_labels and not multi_line:
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)
ylo_, yhi_ = ax.get_ylim()
ticks = [
tick for tick in sorted({0.0, ci_lo, ci_hi, 1.0})
if ylo_ <= tick <= yhi_
]
# drop a tick that would print on top of its neighbour (1.00 / 0.95 on
# a full 0-1 axis); the thresholds win over the round numbers
min_gap = 0.06 * (yhi_ - ylo_)
kept: list[float] = []
for tick in sorted(ticks, key=lambda t: t not in (ci_lo, ci_hi)):
if all(abs(tick - other) >= min_gap for other in kept):
kept.append(tick)
if kept:
ax.set_yticks(sorted(kept))
if annotate_low:
low_idx = np.flatnonzero(ys < ci_lo)
if low_idx.size:
if annotate_low_step is None:
low_step = (
max(1, int(np.ceil(low_idx.size / 12)))
if low_idx.size > 18
else 1
)
else:
low_step = max(1, int(annotate_low_step))
keep = low_idx[::low_step]
if low_idx[-1] not in keep:
keep = np.r_[keep, low_idx[-1]]
for i in keep:
ytop = ax.get_ylim()[1]
ypos = min(max(ys[i] + 0.04, 0.04), ytop - 0.02)
ax.text(
xs[i],
ypos,
names[i],
ha="center",
va="bottom" if ypos < ytop - 0.1 else "top",
rotation=90,
fontsize=7,
clip_on=True,
)
ax.set_xlabel("Distance along profile (m)")
ax.set_ylabel("Confidence ratio")
ax.legend(handles=handles + line_handles, fontsize=8, loc="lower left",
ncol=2 if line_handles else 1)
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 ----------------------------- #
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 ----------------------------- #
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\n"
"error 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 -------------------------------- #
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 ---------------------- #
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 ------------------------- #
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 -------------------------- #
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 ------------------------ #
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."
)