from __future__ import annotations
from typing import Any
import matplotlib.pyplot as plt
import numpy as np
from ..api.labels import LOG10_PERIOD_LABEL
from ..api.station import PYCSAMT_STATION_RENDERING
from ._core import (
_axes_list,
_get_z_block,
_iter_items,
_name,
ensure_sites,
hide_polar_radius_labels,
)
# default colors consistent with plot.py
_COL = {
"xy": "#1f77b4", # blue
"yx": "#d62728", # red
"xx": "#2ca02c", # green
"yy": "#9467bd", # purple
}
# --------------------------- local helpers ------------------------------ #
def _zblk_flex(ed: Any, need_err: bool = False):
try:
return _get_z_block(ed, with_errors=need_err)
except TypeError:
return _get_z_block(ed)
def _comp(z: np.ndarray, comp: str) -> np.ndarray:
i = 0 if comp[0] == "x" else 1
j = 0 if comp[1] == "x" else 1
return z[:, i, j]
def _logper(fr: np.ndarray) -> np.ndarray:
return np.log10(np.maximum(1.0 / fr, 1e-24))
# ---------------------- 7) Impedance phasor wheel ----------------------- #
[docs]
def plot_phasor_wheel(
sites: Any,
*,
station: str | None = None,
components: tuple[str, ...] = ("xy", "yx"),
pband: tuple[float, float] | None = None,
radius: str = "abs", # abs|norm
cmap: str = "viridis",
colors: dict[str, str] | None = None,
marker: str = "o",
ms: float = 3.0,
lw: float = 1.0,
connect: bool = True,
figsize: tuple[float, float] = (4.8, 4.8),
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,
)
cmap_cols = {**_COL, **(colors or {})}
sel = {}
for i, ed in enumerate(_iter_items(S)):
sel[_name(ed, i)] = ed
if not sel:
if ax is None:
_, ax = plt.subplots(subplot_kw={"polar": True})
hide_polar_radius_labels(ax)
ax.text(0.5, 0.5, "no sites", ha="center", va="center")
return ax
if station is None:
station = sorted(sel.keys())[0]
ed = sel.get(station, None)
if ed is None:
if ax is None:
_, ax = plt.subplots(subplot_kw={"polar": True})
hide_polar_radius_labels(ax)
ax.text(0.5, 0.5, "station not found", ha="center", va="center")
return ax
Z, z, fr = _zblk_flex(ed)[:3]
if Z is None:
if ax is None:
_, ax = plt.subplots(subplot_kw={"polar": True})
hide_polar_radius_labels(ax)
ax.text(0.5, 0.5, "no Z", ha="center", va="center")
return ax
per = 1.0 / fr
m = np.ones(fr.size, dtype=bool)
if pband is not None:
lo, hi = pband
m &= (per >= lo) & (per <= hi)
angN = {}
radN = {}
for c in components:
zz = _comp(z, c)
ang = np.angle(zz) # radians
rad = np.abs(zz)
if radius == "norm":
s = np.nanpercentile(rad[m], 95) + 1e-24
rad = rad / s
angN[c] = ang[m]
radN[c] = rad[m]
th = _logper(fr[m])
th = (th - th.min()) / (th.max() - th.min() + 1e-12)
th = 2.0 * np.pi * th # angle ~ log-period
if ax is None:
_, ax = plt.subplots(figsize=figsize, subplot_kw={"polar": True})
ax.set_theta_zero_location("E")
ax.set_theta_direction(-1)
hide_polar_radius_labels(ax)
for c in components:
col = cmap_cols.get(c, "k")
ax.scatter(
angN[c],
radN[c],
c=th,
cmap=cmap,
s=10 * ms,
alpha=0.9,
edgecolors="none",
label=f"Z{c.upper()}",
)
if connect:
# sort by theta for a gentle path
k = np.argsort(th)
ax.plot(
angN[c][k],
radN[c][k],
"-",
lw=lw,
color=col,
alpha=0.6,
)
ax.grid(True, alpha=0.25)
hide_polar_radius_labels(ax)
ax.set_title(str(station), pad=8)
ax.legend(
loc="lower left",
bbox_to_anchor=(0.02, 0.02),
frameon=False,
fontsize=8,
)
return ax
# --- 8) Off-diagonal antisymmetry residual pseudosection --------------- #
[docs]
def plot_offdiag_antisym_residual(
sites: Any,
*,
vlim: float | None = None,
cmap: str = "magma",
figsize: tuple[float, float] = (9.0, 4.8),
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,
)
labs: list[str] = []
Gs: list[np.ndarray] = []
Vs: list[np.ndarray] = []
for i, ed in enumerate(_iter_items(S)):
Z, z, fr = _zblk_flex(ed)[:3]
if Z is None:
continue
xy = np.abs(z[:, 0, 1])
yx = np.abs(z[:, 1, 0])
num = np.abs(z[:, 0, 1] + z[:, 1, 0])
den = xy + yx + 1e-24
r = np.clip(num / den, 0.0, 1.0)
labs.append(_name(ed, i))
Gs.append(fr)
Vs.append(r)
if not labs:
if ax is None:
_, ax = plt.subplots(figsize=figsize)
ax.text(0.5, 0.5, "no data", ha="center", va="center")
return ax
G = np.unique(np.concatenate(Gs))
M = np.full((len(labs), G.size), np.nan, dtype=float)
for i, (fr, v) in enumerate(zip(Gs, Vs)):
idx = np.searchsorted(G, fr)
idx = np.clip(idx, 0, G.size - 1)
M[i, idx] = v
# fill small gaps
r = M[i]
g = np.isfinite(r)
if g.sum() >= 2:
xi = np.where(g)[0]
for j in np.where(~g)[0]:
k = np.searchsorted(xi, j)
k0 = max(0, min(k - 1, xi.size - 1))
k1 = max(0, min(k, xi.size - 1))
a, b = xi[k0], xi[k1]
t = 0.0 if a == b else (j - a) / (b - a)
r[j] = (1 - t) * r[a] + t * r[b]
M[i] = r
ZI = M.T # (freq, station)
if ax is None:
_, ax = plt.subplots(figsize=figsize)
lp = _logper(G)
order = np.argsort(lp)
lp = lp[order]
ZI = ZI[order]
v = ZI[np.isfinite(ZI)]
if vlim is None and v.size:
vlim = float(max(0.2, np.nanpercentile(v, 95)))
im = ax.imshow(
ZI,
aspect="auto",
origin="lower",
interpolation="nearest",
cmap=cmap,
vmin=0.0,
vmax=(vlim or 1.0),
)
ax.set_ylabel(LOG10_PERIOD_LABEL)
PYCSAMT_STATION_RENDERING.apply(
ax,
np.arange(len(labs), dtype=float),
labs,
preset="pseudosection",
xlim=(-0.5, len(labs) - 0.5),
)
yt = np.linspace(0, len(lp) - 1, num=min(8, len(lp)))
yv = np.linspace(lp.min(), lp.max(), num=yt.size)
ax.set_yticks(yt)
ax.set_yticklabels([f"{v:.2g}" for v in yv])
if not ax.yaxis_inverted():
ax.invert_yaxis()
cb = plt.colorbar(im, ax=ax)
cb.set_label("|Zxy+Zyx|/(|Zxy|+|Zyx|)")
return ax
# ----------------------- 9) Determinant track (per site) ---------------- #
def _det_ci(
z: np.ndarray,
fr: np.ndarray,
ze: np.ndarray | None,
*,
pcts: tuple[float, float, float] = (10.0, 50.0, 90.0),
n_draws: int = 200,
seed: int | None = 0,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
a = z[:, 0, 0]
b = z[:, 0, 1]
c = z[:, 1, 0]
d = z[:, 1, 1]
det = a * d - b * c
if ze is None:
mag = np.abs(det)
ph = np.degrees(np.angle(det))
return mag, ph, np.vstack([mag, mag, mag]).T
ea = ze[:, 0, 0]
eb = ze[:, 0, 1]
ec = ze[:, 1, 0]
ed = ze[:, 1, 1]
rng = np.random.default_rng(seed)
n = int(max(16, n_draws))
nf = det.size
def E(s):
return (
(rng.standard_normal((n, nf)) + 1j * rng.standard_normal((n, nf)))
/ np.sqrt(2.0)
) * s[None, :]
Ad = a[None, :] + E(ea)
Bd = b[None, :] + E(eb)
Cd = c[None, :] + E(ec)
Dd = d[None, :] + E(ed)
Dt = Ad * Dd - Bd * Cd
mag = np.nanmedian(np.abs(Dt), axis=0)
ph = np.nanmedian(np.degrees(np.angle(Dt)), axis=0)
mag_lo = np.nanpercentile(np.abs(Dt), pcts[0], axis=0)
mag_hi = np.nanpercentile(np.abs(Dt), pcts[2], axis=0)
return mag, ph, np.vstack([mag_lo, mag_hi]).T
[docs]
def plot_determinant_track(
sites: Any,
*,
station: str | None = None,
pband: tuple[float, float] | None = None,
pcts: tuple[float, float, float] = (10.0, 50.0, 90.0),
n_draws: int = 200,
height_ratio: tuple[int, int] = (2, 1),
axes=None,
figsize: tuple[float, float] = (6.4, 3.8),
color_mag: str = "C0",
color_phase: str = "C3",
fill_alpha: float = 0.20,
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,
)
sel = {}
for i, ed in enumerate(_iter_items(S)):
sel[_name(ed, i)] = ed
axes_given = _axes_list(axes, 1) if axes is not None else None
if not sel:
if axes_given is None:
fig = plt.figure(figsize=figsize)
ax = fig.add_subplot(111)
else:
ax = axes_given[0]
fig = ax.figure
ax.text(0.5, 0.5, "no sites", ha="center", va="center")
return fig
if station is None:
station = sorted(sel.keys())[0]
ed = sel.get(station, None)
if ed is None:
if axes_given is None:
fig = plt.figure(figsize=figsize)
ax = fig.add_subplot(111)
else:
ax = axes_given[0]
fig = ax.figure
ax.text(0.5, 0.5, "station not found", ha="center", va="center")
return fig
out = _zblk_flex(ed, need_err=True)
if len(out) == 4:
_, z, fr, ze = out
else:
_, z, fr = out[:3]
ze = None
if z is None or fr is None:
if axes_given is None:
fig = plt.figure(figsize=figsize)
ax = fig.add_subplot(111)
else:
ax = axes_given[0]
fig = ax.figure
ax.text(0.5, 0.5, "no Z", ha="center", va="center")
return fig
per = 1.0 / fr
m = np.ones(fr.size, dtype=bool)
if pband is not None:
lo, hi = pband
m &= (per >= lo) & (per <= hi)
mag, ph, band = _det_ci(
z[m],
fr[m],
ze[m] if isinstance(ze, np.ndarray) else None,
pcts=pcts,
n_draws=n_draws,
)
x = per[m]
axes_given = _axes_list(axes, 2) if axes is not None else None
if axes_given is None:
fig = plt.figure(figsize=figsize)
gs = fig.add_gridspec(2, 1, height_ratios=height_ratio, hspace=0.06)
ax1 = fig.add_subplot(gs[0])
ax2 = fig.add_subplot(gs[1], sharex=ax1)
else:
ax1, ax2 = axes_given
fig = ax1.figure
ax1.set_xscale("log")
ax2.set_xscale("log")
ax1.fill_between(
x, band[:, 0], band[:, 1], color=color_mag, alpha=fill_alpha
)
ax1.plot(x, mag, "-", color=color_mag, lw=1.6)
ax2.plot(x, ph, "-", color=color_phase, lw=1.6)
ax1.grid(True, alpha=0.25, which="both")
ax2.grid(True, alpha=0.25, which="both")
ax2.set_xlabel("Period (s)")
ax1.set_ylabel("|det(Z)|")
ax2.set_ylabel("Phase det(Z) (°)")
ax1.set_title(str(station), pad=6)
return fig