from __future__ import annotations
from typing import Any
import matplotlib.colors as mcolors
import matplotlib.pyplot as plt
from matplotlib.transforms import Affine2D
import numpy as np
import pandas as pd
from matplotlib.cm import ScalarMappable
from matplotlib.colors import BoundaryNorm, ListedColormap, Normalize
from matplotlib.patches import Ellipse
from mpl_toolkits.axes_grid1 import make_axes_locatable
from ..api._rose_style import (
_UNSET,
RoseStyle,
resolve_rose_style,
)
from ..api.labels import LOG10_PERIOD_LABEL, PERIOD_LABEL
from ..api.station import PYCSAMT_STATION_RENDERING
from ..api.style import PYCSAMT_STYLE
from ..site import compute as _compute
from ..site import edit as _edit
from ..site.base import Sites
from ..z import utils as zutils
from ._core import (
_apply_each,
_axes_list,
_get_t_block,
_get_z_block,
_iter_items,
_name,
ensure_sites,
hide_polar_radius_labels,
)
_BACKWARD_SINCE = "2.0.0"
_BACKWARD_REMOVE = "2.17.0"
#
[docs]
def rotate_to_strike(
sites,
*,
method: str = "swift",
inplace: bool = False,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> Sites:
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
def _one(Si: Sites) -> Sites:
# _compute.strike_estimate only recognises objects exposing a
# ``.Z`` section (its own is_edi_file() duck-type check); a
# Site wrapper only exposes ``.z`` and is silently skipped,
# which made this always fall through to ang=0.0 regardless of
# data. Passing the raw EDI item makes it return a real float
# (per its own documented single-site contract) instead of an
# empty per-station DataFrame with no ``.angle`` column.
ed = next(_iter_items(Si))
edi = getattr(ed, "edi", None)
edi = edi if edi is not None else ed
ang = 0.0
try:
r = _compute.strike_estimate(edi, method=method)
if isinstance(r, (int, float)):
ang = float(r)
elif isinstance(r, dict):
ang = float(r.get("angle", 0.0))
else:
ang = float(getattr(r, "angle", 0.0))
except Exception:
ang = 0.0
if not np.isfinite(ang):
# No strike (e.g. every row masked upstream): rotating by NaN
# used to turn the whole tensor -- and its errors -- into NaN.
return Si
# _edit.rotate looks for a ``.Z`` section (the raw EDI layout);
# a Site wrapper only exposes ``.z`` directly, so calling it on
# `Si` (a Sites collection) is a silent no-op for every real
# station. Site.z reads through to edi.Z.z, so rotating the
# underlying EDI in place also updates the Site wrapper.
# _apply_each already handles inplace-vs-copy at the collection
# level, so this always mutates in place here regardless of
# the outer *inplace* flag.
_edit.rotate(edi, ang, inplace=True)
return Si
return _apply_each(S, _one, inplace=inplace, verbose=verbose)
# --- 2) rotate by angle or map ------------------------------------------- #
[docs]
def rotate(
sites,
angle: float,
*,
inplace: bool = False,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> Sites:
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
# _edit.rotate expects a single EDI-like object with a ``.Z``
# section; passing the whole Sites collection silently rotates
# nothing. rotate_all() is the broadcast variant that correctly
# unwraps each item to its raw EDI first.
return _edit.rotate_all(S, angle, inplace=inplace)
[docs]
def rotate_by_map(
sites,
angle_by_station: dict,
*,
inplace: bool = False,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> Sites:
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
def _one(Si: Sites) -> Sites:
ed = next(_iter_items(Si))
name = getattr(ed, "station", None) or getattr(ed, "name", None)
ang = float(angle_by_station.get(name, 0.0))
# See rotate_to_strike._one above: rotate the raw EDI item,
# not the Sites wrapper, and always in place (_apply_each
# handles the outer inplace-vs-copy semantics).
edi = getattr(ed, "edi", None)
_edit.rotate(edi if edi is not None else ed, ang, inplace=True)
return Si
return _apply_each(S, _one, inplace=inplace, verbose=verbose)
# --- 3) enforce off-diagonal antisymmetry -------------------------------- #
[docs]
def antisymmetrize(
sites,
*,
how: str = "rms",
inplace: bool = False,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> Sites:
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
def _one(Si: Sites) -> Sites:
ed = next(_iter_items(Si))
Z, z, _ = _get_z_block(ed)
if Z is None or z is None:
return Si
ze = getattr(Z, "z_err", None)
z2, ze2 = zutils.enforce_offdiag_antisymmetry(z, ze)
try:
Z.z = z2
if ze is not None and ze2 is not None:
Z.z_err = ze2
except Exception:
pass
return Si
return _apply_each(S, _one, inplace=inplace, verbose=verbose)
# --- 4) invert impedance -------------------------------------------------- #
[docs]
def invert(
sites,
*,
inplace: bool = False,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> Sites:
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
def _one(Si: Sites) -> Sites:
ed = next(_iter_items(Si))
Z, z, _ = _get_z_block(ed)
if Z is None or z is None:
return Si
ze = getattr(Z, "z_err", None)
z2, ze2 = zutils.invert_z(z, ze)
try:
Z.z = z2
if ze is not None and ze2 is not None:
Z.z_err = ze2
except Exception:
pass
return Si
return _apply_each(S, _one, inplace=inplace, verbose=verbose)
# --- 5) sensor-orientation correction ------------------------------------ #
[docs]
def orient_from_sensors(
sites,
ex: float,
ey: float,
bx: float,
by: float,
*,
degrees: bool = True,
inplace: bool = False,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> Sites:
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
# zutils.correct_for_sensor_orientation always expects degrees and
# has no ``degrees=``/``z_err=`` keywords (it takes ``z_prime_err``);
# calling it with those names raised TypeError unconditionally, so
# this function could never succeed. Convert here when the caller
# passed radians instead.
if not degrees:
ex_, ey_, bx_, by_ = (float(np.degrees(v)) for v in (ex, ey, bx, by))
else:
ex_, ey_, bx_, by_ = float(ex), float(ey), float(bx), float(by)
def _one(Si: Sites) -> Sites:
ed = next(_iter_items(Si))
Z, z, _ = _get_z_block(ed)
if Z is None or z is None:
return Si
ze = getattr(Z, "z_err", None)
z2, ze2 = zutils.correct_for_sensor_orientation(
z,
ex=ex_,
ey=ey_,
bx=bx_,
by=by_,
z_prime_err=ze,
)
try:
Z.z = z2
if ze is not None and ze2 is not None:
Z.z_err = ze2
except Exception:
pass
return Si
return _apply_each(S, _one, inplace=inplace, verbose=verbose)
# --- 6) sigma-clip outliers in Z ----------------------------------------- #
[docs]
def sigma_clip_z(
sites,
*,
sigma: float = 3.0,
inplace: bool = False,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> Sites:
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
def _one(Si: Sites) -> Sites:
ed = next(_iter_items(Si))
Z, z, _ = _get_z_block(ed)
if Z is None or z is None:
return Si
mask = zutils.sigma_clip_mask(z, nsigma=sigma)
z2 = z.copy()
z2[~mask] = np.nan
try:
Z.z = z2
except Exception:
pass
return Si
return _apply_each(S, _one, inplace=inplace, verbose=verbose)
# --- 7) balance off-diagonal magnitudes ---------------------------------- #
[docs]
def balance_offdiag(
sites,
*,
mode: str = "avgabs",
inplace: bool = False,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> Sites:
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
def _one(Si: Sites) -> Sites:
ed = next(_iter_items(Si))
Z, z, _ = _get_z_block(ed)
if Z is None or z is None:
return Si
zx = z[:, 0, 1]
zy = z[:, 1, 0]
if mode == "avgabs":
m = 0.5 * (np.abs(zx) + np.abs(zy)) + 1e-12
sx = m / (np.abs(zx) + 1e-12)
sy = m / (np.abs(zy) + 1e-12)
z2 = z.copy()
z2[:, 0, 1] = zx * sx
z2[:, 1, 0] = zy * sy
else:
z2 = z
try:
Z.z = z2
except Exception:
pass
return Si
return _apply_each(S, _one, inplace=inplace, verbose=verbose)
def _is_z_tensor_shaped(z: np.ndarray) -> bool:
"""Return whether *z* looks like a proper (n, 2, 2) impedance tensor."""
return (z.ndim == 3 and z.shape[1:3] == (2, 2)) or (
z.ndim == 2 and z.shape == (2, 2)
)
def _get_z(ed: Any) -> tuple[np.ndarray | None, np.ndarray | None]:
# ``getattr(ed, "Z", None) or getattr(ed, "z", None)`` silently drops
# valid data for Site-wrapped objects that only expose a lowercase
# ``.z`` (raw array, no ``.freq`` sibling) with the real ``Z`` wrapper
# living at ``ed.edi.Z`` -- the same pattern documented in
# rotate_to_strike(). That path returned (None, None) for such sites
# even though `_get_z_block` (this module's sibling in `_core.py`)
# resolves them fine, making build_phase_tensor_table silently empty
# for real EDI batches that construct Site wrappers this way.
#
# The `ed.edi.Z` fallback below reaches past `Site.z`'s own shape
# validation straight to the raw EDI wrapper, so a malformed
# ``Z.z`` (not (n,2,2)) that `Site.z` would normally reject as None
# must be re-validated here explicitly with `_is_z_tensor_shaped`,
# or callers like `_finite_z_period_rows` crash on it instead of
# gracefully treating the station as "no impedance data".
Zobj = getattr(ed, "Z", None)
if Zobj is None:
edi = getattr(ed, "edi", None)
if edi is not None:
Zobj = getattr(edi, "Z", None)
if Zobj is None:
Zobj = getattr(ed, "z", None)
if Zobj is None:
return None, None
z = getattr(Zobj, "z", None)
fr = getattr(Zobj, "freq", None)
if (
isinstance(z, np.ndarray)
and isinstance(fr, np.ndarray)
and _is_z_tensor_shaped(z)
):
return z, fr
if isinstance(Zobj, np.ndarray) and _is_z_tensor_shaped(Zobj):
fr2 = getattr(ed, "freq", None)
if isinstance(fr2, np.ndarray):
return Zobj, fr2
return None, None
def _period(fr: np.ndarray) -> np.ndarray:
with np.errstate(divide="ignore", invalid="ignore"):
p = 1.0 / fr
return p
def _angles_deg(
a: np.ndarray, b: np.ndarray, c: np.ndarray, d: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
"""Phase-tensor orientation angle alpha and skew angle beta (degrees).
Caldwell, Bibby & Brown (2004) convention, Phi = [[a, b], [c, d]]:
alpha = 0.5 * atan2(b + c, a - d) -- rotation-variant orientation
beta = 0.5 * atan2(b - c, a + d) -- rotation-invariant skew
alpha shifts with coordinate rotation (like the strike angle); beta
does not, which is what makes it usable as a distortion/dimensionality
diagnostic. A previous version of this function swapped the two
formulas (and additionally sign-flipped the skew one), so the
dataframe's "beta" column tracked coordinate rotation while "alpha"
stayed invariant -- exactly backwards. Confirmed by rotating a
synthetic 2-D tensor: only the corrected "beta" below stays fixed.
"""
alpha = 0.5 * np.degrees(np.arctan2(b + c, a - d))
beta = 0.5 * np.degrees(np.arctan2(b - c, a + d))
return alpha, beta
def _phi_from_z(z: np.ndarray) -> np.ndarray:
# z: (n, 2, 2) complex
X = z.real
Y = z.imag
n = z.shape[0]
phi = np.empty((n, 2, 2), dtype=float)
for i in range(n):
Xi = X[i]
Yi = Y[i]
try:
invX = np.linalg.inv(Xi)
except np.linalg.LinAlgError:
invX = np.linalg.pinv(Xi)
phi[i] = invX @ Yi
return phi
def _finite_z_period_rows(
z: np.ndarray,
fr: np.ndarray,
) -> tuple[np.ndarray, np.ndarray]:
"""Return rows with finite impedance tensors and positive frequency."""
z = np.asarray(z, dtype=complex)
fr = np.asarray(fr, dtype=float)
n = min(z.shape[0], fr.size)
if n == 0:
return z[:0], fr[:0]
z = z[:n]
fr = fr[:n]
finite_z = np.isfinite(z.real) & np.isfinite(z.imag)
keep = np.isfinite(fr) & (fr > 0.0) & np.all(finite_z, axis=(1, 2))
return z[keep], fr[keep]
def _svd_axes(phi: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
# returns s (n,2) and angle of major axis (deg), shape (n,)
n = phi.shape[0]
s = np.empty((n, 2), dtype=float)
ang = np.empty(n, dtype=float)
for i in range(n):
U, S, VT = np.linalg.svd(phi[i])
s[i] = S
vx = U[0, 0]
vy = U[1, 0]
ang[i] = np.degrees(np.arctan2(vy, vx))
return s, ang
def _ellipticity(s: np.ndarray) -> np.ndarray:
num = s[:, 0] - s[:, 1]
den = s[:, 0] + s[:, 1] + 1e-12
return num / den
def _df_phase_tensor_single(
st: str,
fr: np.ndarray,
phi: np.ndarray,
s: np.ndarray,
theta: np.ndarray,
alpha: np.ndarray,
beta: np.ndarray,
) -> pd.DataFrame:
p = _period(fr)
e = _ellipticity(s)
rec = dict(
station=[st] * len(fr),
freq=fr,
period=p,
s1=s[:, 0],
s2=s[:, 1],
theta=theta,
alpha=alpha,
beta=beta,
skew=beta,
ellipt=e,
)
return pd.DataFrame(rec)
# ------------------------ public computation ----------------------------- #
[docs]
def build_phase_tensor_table(
sites: Any,
*,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> pd.DataFrame:
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
dfs: list[pd.DataFrame] = []
for i, ed in enumerate(_iter_items(S)):
nm = _name(ed, i)
z, fr = _get_z(ed)
if z is None or fr is None:
continue
z, fr = _finite_z_period_rows(z, fr)
if z.size == 0 or fr.size == 0:
continue
phi = _phi_from_z(z)
finite_phi = np.all(np.isfinite(phi), axis=(1, 2))
if not np.any(finite_phi):
continue
phi = phi[finite_phi]
fr = fr[finite_phi]
a = phi[:, 0, 0]
b = phi[:, 0, 1]
c = phi[:, 1, 0]
d = phi[:, 1, 1]
alpha, beta = _angles_deg(a, b, c, d)
s, theta = _svd_axes(phi)
df = _df_phase_tensor_single(nm, fr, phi, s, theta, alpha, beta)
dfs.append(df)
if not dfs:
return pd.DataFrame(
columns=[
"station",
"freq",
"period",
"s1",
"s2",
"theta",
"alpha",
"beta",
"skew",
"ellipt",
]
)
return pd.concat(dfs, ignore_index=True)
# ─── Plotting helpers ─────────────────────────────────────────────────────────
# Human-readable labels for each c_by specifier
_C_LABEL: dict[str, str] = {
"skew": "Skew β (°)",
"beta": "Skew β (°)",
"alpha": "α (°)",
"theta": "Strike θ (°)",
"ellipt": "Ellipticity",
"s1": "φ_max (s.v.)",
"s2": "φ_min (s.v.)",
"|skew|": "|β| (°)",
"|beta|": "|β| (°)",
"|theta|": "|θ| (°)",
"|alpha|": "|α| (°)",
"phi_mean": "Φ̄ = (φ_max+φ_min)/2",
"phi_max": "φ_max (tan units)",
"phi_min": "φ_min (tan units)",
"phimax_deg": r"$\phi_{max}$ (°)",
"phimin_deg": r"$\phi_{min}$ (°)",
}
# Symmetric c_by quantities (vmin = -vmax enforced by default).
# Absolute-value variants ("|skew|", "|beta|") are deliberately excluded:
# they are >= 0 by construction, so forcing vmin = -vmax would burn half
# the colour range on values that can never occur and desaturate the
# colours actually used for the 0..vmax data (see also
# PhaseTensorEllipseStyle's per-c_by natural_sym flag in api/style.py,
# which already gets this right and is the source of truth when
# symmetric_clim is left at its _UNSET default).
_SYMMETRIC_C: frozenset = frozenset(("skew", "beta", "alpha"))
def _resolve_cvals(df: pd.DataFrame, c_by: str) -> tuple[np.ndarray, str]:
"""Return ``(colour_array, colorbar_label)`` for a *c_by* specifier."""
if c_by.startswith("|") and c_by.endswith("|"):
inner = c_by[1:-1]
col = (df[inner] if inner in df.columns else df["skew"]).to_numpy(
float
)
return np.abs(col), _C_LABEL.get(c_by, f"|{inner}|")
if c_by == "phi_mean":
return (
0.5 * (df["s1"].to_numpy(float) + df["s2"].to_numpy(float)),
_C_LABEL["phi_mean"],
)
if c_by == "phi_max":
return df["s1"].to_numpy(float), _C_LABEL["phi_max"]
if c_by == "phi_min":
return df["s2"].to_numpy(float), _C_LABEL["phi_min"]
if c_by == "phimax_deg":
# s1/s2 are the phase-tensor singular values (tan units); the
# classic MTpy-style 0-90 deg phimin/phimax colouring needs the
# arctan of those, not the raw tan-unit values "phi_max"/"phi_min"
# above return (those saturate a 0-90 colour scale near 0).
return (
np.degrees(np.arctan(df["s1"].to_numpy(float))),
_C_LABEL["phimax_deg"],
)
if c_by == "phimin_deg":
return (
np.degrees(np.arctan(df["s2"].to_numpy(float))),
_C_LABEL["phimin_deg"],
)
col_name = c_by if c_by in df.columns else "skew"
return df[col_name].to_numpy(float), _C_LABEL.get(c_by, c_by)
def _attach_cbar(
ax: plt.Axes,
mappable,
label: str,
*,
size: str = "3%",
pad: float = 0.06,
labelsize: int = 9,
ticksize: int = 8,
**colorbar_kws,
):
"""Attach a right-side colorbar to *ax* via make_axes_locatable."""
div = make_axes_locatable(ax)
cax = div.append_axes("right", size=size, pad=pad)
cb = ax.get_figure().colorbar(mappable, cax=cax, **colorbar_kws)
cb.set_label(label, fontsize=labelsize)
cb.ax.tick_params(labelsize=ticksize)
return cb
# ------------------------------ plotting --------------------------------- #
[docs]
def plot_phase_tensor_psection(
sites: Any,
*,
# ── data selection ────────────────────────────────────────────────────
stations: list[str] | None = None,
period_range: tuple[float, float] | None = None,
# ── y-axis ────────────────────────────────────────────────────────────
axis_y: str = "logperiod",
period_up: bool = False,
frame_pct: tuple[float, float] | None = (1.0, 99.0),
# ── ellipse sizing ────────────────────────────────────────────────────
scale=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.scale
normalise_by=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.normalise_by
s1_ref: float | None = None,
min_aspect=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.min_aspect
# ── colour ────────────────────────────────────────────────────────────
c_by=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.c_by
cmap=_UNSET, # default: auto from c_by via resolve_cmap()
clim: tuple[float, float] | None = None,
clim_pct=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.clim_pct
symmetric_clim=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.resolve_symmetric_clim()
color_mode: str = "continuous",
segment_bounds: tuple[float, float] | None = None,
segment_colors: tuple[str, str, str] = ("#2166ac", "#f7f7f7", "#b2182b"),
# ── aesthetics ────────────────────────────────────────────────────────
edgecolor=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.edgecolor
linewidth=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.linewidth
alpha=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.alpha
ellipse_kws: dict[str, Any] | None = None,
cb_kws: dict[str, Any] | None = None,
colorbar: bool = True,
# ── overlays ─────────────────────────────────────────────────────────
skew_threshold=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.skew_threshold
mark_3d=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.mark_3d
ref_ellipse=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.show_ref
legend_fontsize: float = 8.0,
annotations: bool = True,
# ── labels & layout ───────────────────────────────────────────────────
title: str = "",
xlabel: str = "",
ylabel: str = "",
tick_label_rotation: float = 45.0,
figsize: tuple[float, float] = (10.0, 5.5),
# ── standard emtools ──────────────────────────────────────────────────
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
"""
Phase-tensor ellipse pseudo-section (Caldwell et al. 2004 style).
Each cell ``(station × period)`` is drawn as an ellipse whose shape,
orientation, and fill colour encode phase-tensor invariants:
* **major axis** ∝ φ_max (s1 eigenvalue — mean phase level)
* **minor axis** ∝ φ_min (s2 eigenvalue — minimum phase)
* **aspect ratio** = φ_min / φ_max (1 = isotropic / 1-D)
* **rotation** = θ (geoelectric strike, CCW from E)
* **fill colour** controlled by *c_by* (default: skew angle β)
Parameters
----------
sites : any
EDI paths, glob pattern, :class:`~pycsamt.site.base.Sites`, or any
input accepted by :func:`~pycsamt.emtools.ensure_sites`.
stations : list of str or None
Restrict to a subset of station names.
period_range : (T_min, T_max) in seconds or None
Restrict the period range plotted.
axis_y : ``"logperiod"`` | ``"logfreq"``
Y-axis quantity. ``"logperiod"`` is the MT convention.
period_up : bool, default ``False``
When ``False`` (default; standard pseudo-section convention, as
used elsewhere in :mod:`pycsamt.emtools`, e.g.
:func:`~pycsamt.emtools.tensor.plot_dimensionality_psection`),
short period (high frequency, shallow sensitivity) is at the top
of the figure and long period (low frequency, deep sensitivity)
is at the bottom -- matching a geological cross-section with
depth increasing downward. ``True`` flips the y-axis.
frame_pct : (lo, hi) or None, default ``(1, 99)``
Robust percentiles of finite log-period/log-frequency values used for
the visible y-frame. This prevents a few isolated frequency rows from
creating large empty bands. Pass ``None`` to show the absolute range,
or use *period_range* for explicit scientific limits.
scale : float, default ``0.85``
Fraction of each cell occupied by the *reference* ellipse
(the one with s1 = s1_ref). Values above 1 cause overlap.
normalise_by : ``"shape"`` | ``"cell"`` | ``"unity"`` | ``"abs"``
Sizing strategy:
``"shape"``
MTpy-style display: every major axis has the same physical size
and the minor/major ratio is ``s2 / s1``. This emphasizes tensor
shape without allowing absolute phase magnitude or unequal plot
axes to turn ellipses into misleading needles.
``"cell"``
Sizes are normalised so the 90th-percentile s1 fills *scale*
of its cell. Preserves relative size information.
``"unity"``
s1_ref = 1.0 (tan 45° = 1-D half-space reference). A 45°
phase tensor is drawn as a circle filling *scale* of its cell.
``"abs"``
Raw: width = scale × s1, height = scale × s2 in data units
(legacy behaviour).
s1_ref : float or None
Override the reference s1 for ``"cell"`` and ``"unity"`` modes.
min_aspect : float, default ``0.18``
Minimum height/width ratio enforced on every ellipse so that
near-degenerate (φ_min ≈ 0) cells stay visible as ovals instead
of collapsing into an invisible hairline. Set to ``0`` to plot
raw ellipticity.
c_by : str, default ``"skew"``
Column name or derived quantity to map to fill colour. Supported
values: ``"skew"``, ``"beta"``, ``"alpha"``, ``"theta"``,
``"ellipt"``, ``"s1"``, ``"s2"``, ``"|skew|"``, ``"|beta|"``,
``"|theta|"``, ``"phi_mean"``, ``"phi_max"``, ``"phi_min"``.
cmap : str, default ``"RdBu_r"``
Matplotlib colormap name.
clim : (vmin, vmax) or None
Explicit colour limits. When ``None``, derived from *clim_pct*.
clim_pct : (lo, hi), default ``(5.0, 95.0)``
Percentile limits used when *clim* is ``None``.
color_mode : ``"continuous"`` | ``"segmented"``
Continuous colour interpolation or three discrete classes. Segmented
mode follows the usual MTpy-style skew presentation.
segment_bounds : (lower, upper) or None
Class boundaries for segmented colour. By default these are
``(-skew_threshold, skew_threshold)``.
segment_colors : sequence of three colours
Colours below, inside, and above *segment_bounds*, respectively.
symmetric_clim : bool, default ``True``
Enforce vmin = −vmax for skew-like quantities.
edgecolor : str, default ``"k"``
Ellipse border colour. Set to ``"none"`` to suppress borders.
linewidth : float, default ``0.2``
Ellipse border width (pts). 3-D cells receive 3 × this width when
*mark_3d* is ``True``.
alpha : float, default ``0.92``
Ellipse fill opacity.
ellipse_kws : dict or None
Additional ellipse styling, such as ``edgecolor`` and ``linewidth``.
Geometry and transform keys remain controlled by this function.
cb_kws : dict or None
Colourbar customization. ``size``, ``pad``, ``labelsize`` and
``ticksize`` control layout; other entries go to ``Figure.colorbar``.
colorbar : bool, default ``True``
Draw the per-axes colourbar. Set ``False`` when composing several
calls onto a shared grid (e.g. one panel per line) and drawing a
single shared colourbar separately, using the same *cmap*/*clim*
passed to every call so the mapping is identical across panels.
skew_threshold : float or None, default ``3.0``
|β| threshold (degrees) separating 1-D/2-D from 3-D structure.
It does not set the color limits; *clim_pct* remains data-driven.
The threshold only highlights 3-D cells with a thicker border when
*mark_3d* is ``True``.
mark_3d : bool, default ``True``
Draw a thicker border on ellipses where |β| > *skew_threshold*.
ref_ellipse : bool, default ``True``
Draw a labelled reference circle (φ_max = φ_min = s1_ref, β = 0°)
inside the axes, without expanding the data frame.
legend_fontsize : float, default ``8.0``
Font size for the reference-circle label and the 1-D/2-D vs 3-D
annotation shown when *ref_ellipse* / *skew_threshold* are active.
annotations : bool, default ``True``
Draw the in-figure legend strip (the |β| 1-D/2-D vs 3-D labels and
the size-reference ellipse). ``False`` hides it and gives the
ellipses the full height.
title, xlabel, ylabel : str
Axes title and axis labels. Sensible defaults are used when empty.
tick_label_rotation : float, default ``45.0``
Station-name tick rotation in degrees.
figsize : (width, height), default ``(10.0, 5.5)``
Figure size (ignored when *ax* is provided).
recursive, on_dup, strict, verbose : see :func:`ensure_sites`.
ax : :class:`~matplotlib.axes.Axes` or None
If provided, draw into this axes and return it; otherwise create a
new figure.
Returns
-------
ax : :class:`~matplotlib.axes.Axes`
See Also
--------
build_phase_tensor_table : Underlying data computation.
plot_phase_tensor_summary : 3-panel figure combining ellipses,
dimensionality, and skew distribution.
plot_phase_tensor_map : Geographic map view.
"""
if isinstance(sites, pd.DataFrame):
df = sites
else:
df = build_phase_tensor_table(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
# ── resolve visual style from PYCSAMT_STYLE.pt_ellipse ──────────────
_es = PYCSAMT_STYLE.pt_ellipse
if scale is _UNSET:
scale = _es.scale
if normalise_by is _UNSET:
normalise_by = _es.normalise_by
if min_aspect is _UNSET:
min_aspect = _es.min_aspect
if c_by is _UNSET:
c_by = _es.c_by
if cmap is _UNSET:
cmap = _es.copy(c_by=c_by).resolve_cmap()
if clim_pct is _UNSET:
clim_pct = _es.clim_pct
if symmetric_clim is _UNSET:
symmetric_clim = _es.copy(c_by=c_by).resolve_symmetric_clim()
if edgecolor is _UNSET:
edgecolor = _es.edgecolor
if linewidth is _UNSET:
linewidth = _es.linewidth
if alpha is _UNSET:
alpha = _es.alpha
if skew_threshold is _UNSET:
skew_threshold = _es.skew_threshold
if mark_3d is _UNSET:
mark_3d = _es.mark_3d
if ref_ellipse is _UNSET:
ref_ellipse = _es.show_ref
# ── data selection ────────────────────────────────────────────────────
if not df.empty and stations is not None:
df = df[df["station"].isin(stations)]
if not df.empty and period_range is not None:
lo, hi = float(period_range[0]), float(period_range[1])
df = df[(df["period"] >= lo) & (df["period"] <= hi)]
if ax is None:
_, ax = plt.subplots(figsize=figsize)
if df.empty:
ax.text(
0.5,
0.5,
"no phase tensor data",
ha="center",
va="center",
transform=ax.transAxes,
)
return ax
# ── station ordering & y-axis ─────────────────────────────────────────
st_list = list(dict.fromkeys(df["station"].tolist())) # preserve order
st_map: dict[str, int] = {s: i for i, s in enumerate(st_list)}
n_st = len(st_list)
if axis_y == "logperiod":
df = df.copy()
df["_y"] = np.log10(df["period"].to_numpy(float))
ylab = ylabel or r"$\log_{10}(T)$ (s)"
else:
df = df.copy()
df["_y"] = np.log10(np.maximum(df["freq"].to_numpy(float), 1e-30))
ylab = ylabel or r"$\log_{10}(f)$ (Hz)"
# ── cell dimensions in data coordinates ───────────────────────────────
y_unique = np.unique(df["_y"].to_numpy(float))
cell_y = (
float(np.nanmedian(np.abs(np.diff(y_unique))))
if len(y_unique) > 1
else 1.0
)
cell_x = 1.0 # one station spacing
# ── ellipse-size normalisation reference ──────────────────────────────
s1_vals = df["s1"].to_numpy(float)
if normalise_by == "shape":
ref = None
elif normalise_by == "cell":
if s1_ref is not None:
ref = float(s1_ref)
else:
finite_s1 = s1_vals[np.isfinite(s1_vals)]
ref = (
float(np.nanpercentile(finite_s1, 90))
if len(finite_s1)
else 1.0
)
ref = max(ref, 1e-9)
elif normalise_by == "unity":
ref = float(s1_ref) if s1_ref is not None else 1.0
ref = max(ref, 1e-9)
else: # "abs" — legacy: w = scale × s1 in raw data units
ref = None
# ── colour values & limits ────────────────────────────────────────────
cvals, cbar_label = _resolve_cvals(df, c_by)
finite_c = cvals[np.isfinite(cvals)]
if clim is not None:
vmin, vmax = float(clim[0]), float(clim[1])
else:
lo_pct, hi_pct = clim_pct
vmin = (
float(np.nanpercentile(finite_c, lo_pct))
if len(finite_c)
else -1.0
)
vmax = (
float(np.nanpercentile(finite_c, hi_pct)) if len(finite_c) else 1.0
)
if symmetric_clim and c_by in _SYMMETRIC_C:
vlim = max(abs(vmin), abs(vmax))
vmin, vmax = -vlim, vlim
if not np.isfinite(vmin) or not np.isfinite(vmax):
vmin, vmax = -1.0, 1.0
elif np.isclose(vmin, vmax):
pad = max(abs(vmin) * 0.05, 1e-6)
vmin, vmax = vmin - pad, vmax + pad
color_mode = str(color_mode).lower()
if color_mode not in {"continuous", "segmented"}:
raise ValueError("color_mode must be 'continuous' or 'segmented'")
segment_ticks = segment_ticklabels = None
if color_mode == "segmented":
if len(segment_colors) != 3:
raise ValueError("segment_colors must contain exactly three colours")
if segment_bounds is None:
if skew_threshold is not None:
segment_bounds = (-float(skew_threshold), float(skew_threshold))
else:
step = (vmax - vmin) / 3.0
segment_bounds = (vmin + step, vmax - step)
lower, upper = map(float, segment_bounds)
if not lower < upper:
raise ValueError("segment_bounds must satisfy lower < upper")
outer_lo = min(vmin, lower - max(abs(lower) * 0.05, 1e-6))
outer_hi = max(vmax, upper + max(abs(upper) * 0.05, 1e-6))
cm = ListedColormap(segment_colors, name="phase_tensor_segments")
norm = BoundaryNorm([outer_lo, lower, upper, outer_hi], cm.N, clip=True)
segment_ticks = [0.5 * (outer_lo + lower), 0.5 * (lower + upper),
0.5 * (upper + outer_hi)]
segment_ticklabels = [f"< {lower:g}", f"{lower:g} to {upper:g}",
f"> {upper:g}"]
else:
norm = Normalize(vmin=vmin, vmax=vmax)
cm = plt.get_cmap(cmap)
# ── extract arrays for the drawing loop ───────────────────────────────
x_arr = df["station"].map(st_map).to_numpy(float)
y_arr = df["_y"].to_numpy(float)
s1_arr = df["s1"].to_numpy(float)
s2_arr = df["s2"].to_numpy(float)
th_arr = df["theta"].to_numpy(float)
# skew values for mark_3d (always needed even if c_by != "skew")
skew_arr = (
df["skew"].to_numpy(float)
if "skew" in df.columns
else np.zeros(len(df))
)
# Resolve the finite-data frame before drawing so ``shape`` mode can use
# physical display units, independent of station/period axis aspect.
y_all = y_arr[np.isfinite(y_arr)]
if len(y_all) and frame_pct is not None:
frame_lo, frame_hi = map(float, frame_pct)
if not (0.0 <= frame_lo < frame_hi <= 100.0):
raise ValueError("frame_pct must satisfy 0 <= lo < hi <= 100")
y_lo, y_hi = map(float, np.nanpercentile(y_all, [frame_lo, frame_hi]))
else:
y_lo = float(np.nanmin(y_all)) if len(y_all) else 0.0
y_hi = float(np.nanmax(y_all)) if len(y_all) else 1.0
margin = max(0.5 * cell_y, 0.02 * (y_hi - y_lo + 1e-9))
target_ylim = (
(y_lo - margin, y_hi + margin)
if period_up
else (y_hi + margin, y_lo - margin)
)
ax.set_xlim(-0.6, n_st - 0.4)
ax.set_ylim(*target_ylim)
PYCSAMT_STATION_RENDERING.apply(
ax,
np.arange(n_st, dtype=float),
st_list,
preset="pseudosection",
xlim=(-0.6, n_st - 0.4),
)
sm = ScalarMappable(cmap=cm, norm=norm)
sm.set_array([])
if colorbar:
cb = _attach_cbar(ax, sm, cbar_label, **dict(cb_kws or {}))
if segment_ticks is not None:
cb.set_ticks(segment_ticks)
cb.set_ticklabels(segment_ticklabels)
ax.figure.canvas.draw()
station_px = abs(
ax.transData.transform((1.0, y_lo))[0]
- ax.transData.transform((0.0, y_lo))[0]
)
# ── draw ellipses ─────────────────────────────────────────────────────
ellipse_options = dict(ellipse_kws or {})
forbidden = {"xy", "width", "height", "angle", "transform"}
bad_keys = forbidden.intersection(ellipse_options)
if bad_keys:
raise ValueError(
"ellipse_kws cannot override geometry keys: "
+ ", ".join(sorted(bad_keys))
)
ellipse_options.setdefault("edgecolor", edgecolor)
ellipse_options.setdefault("alpha", alpha)
ellipse_options.setdefault("zorder", 3)
for xi, yi, s1i, s2i, thi, ci, beti in zip(
x_arr, y_arr, s1_arr, s2_arr, th_arr, cvals, skew_arr
):
if not np.all(np.isfinite([xi, yi, s1i, s2i, thi, ci])):
continue
is_3d = (
skew_threshold is not None
and mark_3d
and np.isfinite(beti)
and abs(beti) > skew_threshold
)
if normalise_by == "shape":
ratio = abs(s2i) / max(abs(s1i), 1e-12)
ratio = min(1.0, max(float(min_aspect), ratio))
x0, x1 = ax.get_xlim()
y0, y1 = ax.get_ylim()
x_frac = (xi - x0) / (x1 - x0)
y_frac = (yi - y0) / (y1 - y0)
width_px = scale * station_px
shape_transform = (
Affine2D()
.scale(0.5 * width_px, 0.5 * width_px * ratio)
.rotate_deg(thi)
.scale(1.0 / ax.bbox.width, 1.0 / ax.bbox.height)
.translate(x_frac, y_frac)
+ ax.transAxes
)
patch_kws = dict(ellipse_options)
patch_kws.setdefault("facecolor", cm(norm(ci)))
base_lw = float(patch_kws.get("linewidth", linewidth))
patch_kws["linewidth"] = base_lw * 3.0 if is_3d else base_lw
ellipse = Ellipse(
(0.0, 0.0),
width=2.0,
height=2.0,
angle=0.0,
transform=shape_transform,
**patch_kws,
)
ellipse.set_clip_path(ax.patch)
ax.add_patch(ellipse)
continue
if ref is not None:
w = min(scale * cell_x * s1i / ref, 0.99 * cell_x)
h = min(scale * cell_y * s2i / ref, 0.99 * cell_y)
else:
w = scale * s1i
h = scale * s2i
if min_aspect > 0:
h = max(h, min_aspect * w)
patch_kws = dict(ellipse_options)
patch_kws.setdefault("facecolor", cm(norm(ci)))
base_lw = float(patch_kws.get("linewidth", linewidth))
patch_kws["linewidth"] = base_lw * 3.0 if is_3d else base_lw
ax.add_patch(
Ellipse(
(xi, yi),
width=w,
height=h,
angle=thi,
**patch_kws,
)
)
# ── axes limits and ticks ─────────────────────────────────────────────
ax.set_xlim(-0.6, n_st - 0.4)
# Reserve a dedicated legend strip beyond the data so the reference
# ellipse and 1-D/2-D vs 3-D annotation never overlap real ellipses.
want_legend = bool(annotations) and (bool(ref_ellipse) or (
skew_threshold is not None and c_by in ("skew", "beta")
))
legend_depth = 0.0
if period_up:
top_edge, top_sign = y_hi, 1.0
else:
top_edge, top_sign = y_lo, -1.0
ax.set_ylim(*target_ylim)
# y-ticks: integer log10 values
y_int = np.arange(int(np.floor(y_lo)), int(np.ceil(y_hi)) + 1)
ax.set_yticks(y_int)
ax.set_yticklabels([str(int(v)) for v in y_int], fontsize=8)
ax.set_ylabel(ylab, fontsize=9)
if title:
ax.set_title(title, fontsize=10)
ax.grid(True, ls=":", lw=0.4, color="#cccccc", alpha=0.7, zorder=0)
# ── colorbar ─────────────────────────────────────────────────────────
# ── optional overlays: dedicated legend strip beyond the data ─────────
if want_legend:
# boundary rule separating data from the legend strip
bound = top_edge + top_sign * margin
ax.plot(
[-0.6, n_st - 0.4], [bound, bound], color="0.82", lw=0.7, zorder=1
)
va_out = "bottom" if top_sign > 0 else "top"
if skew_threshold is not None and c_by in ("skew", "beta"):
y_near = 0.91
y_far = 0.975
ax.text(
0.01,
y_far,
f"|β| < {skew_threshold:.0f}° → 1-D/2-D",
fontsize=legend_fontsize,
va="center",
ha="left",
color="#2166ac",
transform=ax.transAxes,
bbox=dict(fc="white", ec="0.75", lw=0.5, alpha=0.9, pad=2.0),
)
ax.text(
0.01,
y_near,
f"|β| ≥ {skew_threshold:.0f}° → 3-D",
fontsize=legend_fontsize,
va="center",
ha="left",
color="#b2182b",
transform=ax.transAxes,
bbox=dict(fc="white", ec="0.75", lw=0.5, alpha=0.9, pad=2.0),
)
# Reference ellipse: the "1-D" size/shape used to normalise every
# data ellipse (φ_max = φ_min = ref), drawn at full data size so
# it is directly comparable to the ellipses in the plot.
if ref_ellipse and ref is not None:
rx, ry = 0.92, 0.94
rw, rh = 0.055, 0.03
ax.add_patch(
Ellipse(
(rx, ry),
width=rw,
height=rh,
angle=0.0,
facecolor="0.9",
edgecolor="k",
linewidth=0.9,
zorder=5,
transform=ax.transAxes,
)
)
ref_label = {
"unity": "1-D reference\n(Φ=45°)",
"cell": "size reference\n(90th-pct Φ_max)",
}.get(normalise_by, "size reference")
ax.text(
rx,
0.895,
ref_label,
ha="center",
va="top",
fontsize=legend_fontsize,
linespacing=1.3,
transform=ax.transAxes,
bbox=dict(fc="white", ec="0.75", lw=0.5, alpha=0.9, pad=2.0),
)
# Artists using ``ax.transAxes`` can still update Matplotlib's data limits
# when they are added as patches. Re-apply the finite-data frame last so
# annotations never create empty scientific-axis ranges.
ax.set_ylim(*target_ylim)
tick_lo = int(np.ceil(y_lo))
tick_hi = int(np.floor(y_hi))
if tick_lo <= tick_hi:
y_ticks = np.arange(tick_lo, tick_hi + 1)
else:
y_ticks = np.array([0.5 * (y_lo + y_hi)])
ax.set_yticks(y_ticks)
ax.set_yticklabels([f"{v:g}" for v in y_ticks], fontsize=8)
return ax
[docs]
def plot_phase_tensor_skewmap(
sites: Any,
*,
axis_y: str = "logperiod",
agg: str = "median",
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:
df = build_phase_tensor_table(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
if df.empty:
if ax is None:
_, ax = plt.subplots(figsize=figsize)
ax.text(0.5, 0.5, "no phase tensor", ha="center", va="center")
return ax
if axis_y == "logperiod":
df = df.copy()
df["y"] = np.log10(df["period"].to_numpy())
ylab = LOG10_PERIOD_LABEL
else:
df = df.copy()
df["y"] = df["period"].to_numpy()
ylab = PERIOD_LABEL
piv = df.pivot_table(
index="y",
columns="station",
values="skew",
aggfunc=agg,
)
piv = piv.sort_index()
Z = piv.to_numpy(dtype=float) # (n_logp, n_st) — no transpose
if ax is None:
_, ax = plt.subplots(figsize=figsize)
im = ax.imshow(
Z,
aspect="auto",
origin="lower",
interpolation="nearest",
)
ax.set_ylabel(ylab)
PYCSAMT_STATION_RENDERING.apply(
ax,
np.arange(len(piv.columns), dtype=float),
list(piv.columns),
preset="pseudosection",
xlim=(-0.5, len(piv.columns) - 0.5),
)
yt = np.linspace(0, Z.shape[0] - 1, num=min(8, Z.shape[0]))
yvals = np.linspace(
piv.index.min(), piv.index.max(), num=min(8, len(piv.index))
)
ax.set_yticks(yt)
ax.set_yticklabels([f"{v:.3g}" for v in yvals])
if not ax.yaxis_inverted():
ax.invert_yaxis()
cb = plt.colorbar(im, ax=ax)
cb.set_label("skew")
return ax
# --- more plots ----------------------------------------------------------- #
def _coords_of_sites(sites) -> dict:
m = {}
for i, ed in enumerate(_iter_items(sites)):
nm = _name(ed, i)
lat = getattr(ed, "lat", None) or getattr(ed, "latitude", None)
lon = getattr(ed, "lon", None) or getattr(ed, "longitude", None)
try:
if lat is None or lon is None:
continue
m[nm] = (float(lat), float(lon))
except Exception:
continue
return m
[docs]
def plot_theta_vs_period(
sites: Any,
*,
figsize: tuple[float, float] = (8.0, 4.0),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
df = build_phase_tensor_table(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
if ax is None:
_, ax = plt.subplots(figsize=figsize)
if df.empty:
ax.text(0.5, 0.5, "no phase tensor", ha="center", va="center")
return ax
for st, sdf in df.groupby("station"):
x = sdf["period"].to_numpy()
y = sdf["theta"].to_numpy()
ax.plot(x, y, ".", ms=3, label=st)
ax.set_xscale("log")
ax.set_xlabel("Period (s)")
ax.set_ylabel("theta (deg)")
ax.legend(ncol=2, fontsize=8)
return ax
def _strike_streamlines(ax, x, y, th, w, n_stations):
"""Overlay smoothed geoelectric-strike streamlines.
Strike is axial (mod 180 deg), so it is averaged through the
double-angle ``(cos 2theta, sin 2theta)`` (weighted by ``w``, the 2-D
strength) onto a regular grid, then halved back before integrating the
flow. Silently degrades to a no-op when SciPy is unavailable or the
grid is too sparse to integrate.
"""
if n_stations < 3 or y.size < 12:
return
try:
from scipy.interpolate import griddata
except Exception:
return
# Restrict the flow to the *populated* period band so it never
# extrapolates into empty short/long-period regions (e.g. long-period
# MT lines with no high-frequency data).
y_lo, y_hi = np.nanpercentile(y, [2.0, 98.0])
if not np.isfinite(y_lo) or y_hi - y_lo < 1e-6:
return
gx = np.arange(n_stations, dtype=float)
gy = np.linspace(y_lo, y_hi, 44)
GX, GY = np.meshgrid(gx, gy)
def _fill(val):
g = griddata((x, y), val, (GX, GY), method="linear")
nn = griddata((x, y), val, (GX, GY), method="nearest")
bad = ~np.isfinite(g)
g[bad] = nn[bad]
return g
c2 = _fill(w * np.cos(2.0 * th))
s2 = _fill(w * np.sin(2.0 * th))
if not (np.isfinite(c2).any() and np.isfinite(s2).any()):
return
thg = 0.5 * np.arctan2(s2, c2)
try:
ax.streamplot(
GX,
GY,
np.sin(thg),
np.cos(thg),
density=1.1,
color="0.2",
linewidth=0.7,
arrowstyle="-",
)
except Exception:
pass
[docs]
def plot_strike_director_field(
sites: Any,
*,
color_by: str = "skew",
length_by: str | None = "ellipt",
streamlines: bool = True,
skew_max: float = 6.0,
cmap: str | None = None,
period_subsample: int | None = None,
bar_scale: float = 26.0,
show_legend: bool = True,
title: str | None = None,
figsize: tuple[float, float] = (12.0, 5.2),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
r"""Geoelectric-strike **director field** over station and period.
A supplement to :func:`plot_theta_vs_period`. Because the phase-tensor
strike ``theta`` is an *axial* angle (defined mod 180 deg), the correct
glyph is not a point on a linear axis but a **head-less bar** pointing
along the strike. This draws one director per ``(station, period)`` cell
on a station x log-period grid, encoding three more channels at once:
* **orientation** -- the strike ``theta``;
* **length** -- ``length_by`` (default ellipticity), the 2-D strength:
near-1-D cells get a short bar because there strike is ill-defined;
* **colour** -- ``color_by`` (default ``|skew|``), the departure from
2-D: green = low-skew / reliable, red = high-skew / 3-D or galvanic
distortion where the strike should not be trusted.
An optional smoothed **streamline** overlay (``streamlines=True``)
integrates the director field into a strike "flow", so lateral and
vertical coherence read at a glance.
Interpretation
--------------
* long, aligned, green bars flowing in a laminar bundle -> robust,
depth-consistent 2-D strike; read the azimuth with confidence;
* bars rotating smoothly with depth -> strike varies with depth
(dipping structure or layered anisotropy);
* short and/or red, swirling bars -> 1-D, 3-D, or noise: do not
over-interpret the direction there.
Parameters
----------
sites : path, EDI object, APISurvey, Sites, or iterable of sites
Anything accepted by :func:`build_phase_tensor_table`.
color_by : {'skew', 'ellipt', 's1', 's2', ...}, default 'skew'
Table column mapped to bar colour (its absolute value is used).
length_by : str or None, default 'ellipt'
Table column mapped to bar length (absolute value, 95th-percentile
normalised). ``None`` draws uniform-length bars.
streamlines : bool, default True
Overlay smoothed strike streamlines (needs SciPy).
skew_max : float, default 6.0
Upper clip of the ``|skew|`` colour scale, in degrees (only used
when ``color_by='skew'``). Skew above a few degrees already flags
3-D behaviour.
cmap : str, optional
Override the colour map (default ``'RdYlGn_r'`` for skew, else
``'viridis'``).
period_subsample : int, optional
Keep at most this many periods (evenly along the log axis) to thin
a very dense grid.
bar_scale : float, default 26.0
Matplotlib quiver ``scale`` -- larger makes shorter bars.
show_legend : bool, default True
Draw the director / streamline legend.
title : str, optional
Axes title.
figsize, recursive, on_dup, strict, verbose, ax
Standard ``emtools`` plotting arguments.
Returns
-------
matplotlib.axes.Axes
See Also
--------
plot_theta_vs_period : the linear scatter this supplements.
plot_phase_tensor_psection : per-cell ellipses (magnitudes as well).
"""
df = build_phase_tensor_table(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
if ax is None:
_, ax = plt.subplots(figsize=figsize)
fig = ax.get_figure()
if df.empty:
ax.text(0.5, 0.5, "no phase tensor", ha="center", va="center")
return ax
df = df.copy()
df["logp"] = np.log10(df["period"].to_numpy())
stations = list(dict.fromkeys(df["station"]))
sidx = {s: i for i, s in enumerate(stations)}
df["xi"] = df["station"].map(sidx).astype(float)
if period_subsample and period_subsample > 0:
periods = np.sort(df["period"].unique())
step = max(1, len(periods) // period_subsample)
keep = set(periods[::step])
df = df[df["period"].isin(keep)]
x = df["xi"].to_numpy(float)
y = df["logp"].to_numpy(float)
th = np.radians(df["theta"].to_numpy(float))
if length_by and length_by in df.columns:
lv = np.abs(df[length_by].to_numpy(float))
denom = np.nanpercentile(lv, 95)
denom = denom if np.isfinite(denom) and denom > 0 else 1.0
length = np.clip(lv / denom, 0.12, 1.0)
else:
length = np.ones(len(df))
if color_by == "skew":
cval = np.abs(df["skew"].to_numpy(float))
vmin, vmax = 0.0, float(skew_max)
cmap_name = cmap or "RdYlGn_r"
clabel = "|skew| (deg) - green: 2-D / reliable, red: 3-D / distorted"
elif color_by in df.columns:
cval = np.abs(df[color_by].to_numpy(float))
vmin = float(np.nanmin(cval))
vmax = float(np.nanpercentile(cval, 95))
cmap_name = cmap or "viridis"
clabel = str(color_by)
else:
cval = np.zeros(len(df))
vmin, vmax, cmap_name, clabel = 0.0, 1.0, cmap or "viridis", ""
ok = (
np.isfinite(x) & np.isfinite(y) & np.isfinite(th) & np.isfinite(length)
)
x, y, th, length, cval = x[ok], y[ok], th[ok], length[ok], cval[ok]
if x.size == 0:
ax.text(0.5, 0.5, "no finite strike", ha="center", va="center")
return ax
# north-up director components (east = sin, north = cos); axial, so
# (theta) and (theta+180) give the same head-less bar under pivot='mid'.
U = np.sin(th) * length
V = np.cos(th) * length
q = ax.quiver(
x,
y,
U,
V,
np.clip(cval, vmin, vmax),
cmap=cmap_name,
angles="uv",
pivot="mid",
headlength=0,
headaxislength=0,
headwidth=1,
scale=bar_scale,
width=0.0032,
alpha=0.9,
clim=(vmin, vmax),
)
if clabel:
cb = fig.colorbar(q, ax=ax, pad=0.01, shrink=0.9)
cb.set_label(clabel, fontsize=8)
if streamlines:
_strike_streamlines(ax, x, y, th, length, len(stations))
ax.set_ylabel(LOG10_PERIOD_LABEL)
PYCSAMT_STATION_RENDERING.apply(
ax,
np.arange(len(stations)),
stations,
preset="pseudosection",
xlim=(-0.8, len(stations) - 0.2),
)
if not ax.yaxis_inverted():
ax.invert_yaxis()
ax.set_title(
title if title is not None else "Geoelectric strike director field",
fontsize=10,
)
if show_legend:
from matplotlib.lines import Line2D
handles = [
Line2D(
[0],
[0],
color="#4a7a1f",
lw=3,
label="strike director (length = 2-D strength)",
),
]
if streamlines:
handles.append(
Line2D(
[0],
[0],
color="0.2",
lw=1.2,
label="smoothed strike flow",
),
)
ax.legend(
handles=handles, loc="upper right", fontsize=7, framealpha=0.85
)
return ax
[docs]
def plot_ellipticity_psection(
sites: Any,
*,
figsize: tuple[float, float] = (8.5, 4.0),
agg: str = "median",
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
df = build_phase_tensor_table(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
if ax is None:
_, ax = plt.subplots(figsize=figsize)
if df.empty:
ax.text(0.5, 0.5, "no phase tensor", ha="center", va="center")
return ax
df = df.copy()
df["logp"] = np.log10(df["period"].to_numpy())
piv = df.pivot_table(
index="logp",
columns="station",
values="ellipt",
aggfunc=agg,
)
piv = piv.sort_index()
Z = piv.to_numpy(dtype=float) # (n_logp, n_st) — no transpose
im = ax.imshow(
Z,
aspect="auto",
origin="lower",
interpolation="nearest",
)
ax.set_ylabel(LOG10_PERIOD_LABEL)
PYCSAMT_STATION_RENDERING.apply(
ax,
np.arange(Z.shape[1], dtype=float),
list(piv.columns),
preset="pseudosection",
xlim=(-0.5, Z.shape[1] - 0.5),
)
yt = np.linspace(
0, Z.shape[0] - 1, num=min(8, Z.shape[0])
) # shape[0] = n_logp
yvals = np.linspace(
piv.index.min(), piv.index.max(), num=min(8, len(piv.index))
)
ax.set_yticks(yt)
ax.set_yticklabels([f"{v:.2g}" for v in yvals])
if not ax.yaxis_inverted():
ax.invert_yaxis()
cb = plt.colorbar(im, ax=ax)
cb.set_label("ellipticity")
return ax
#: Traffic-light dimensionality palette (1D=safe green, 2D=caution amber,
#: 3D=warning red), matching :func:`pycsamt.emtools.skew.plot_skew_traffic_psection`.
DIMENSIONALITY_TRAFFIC_COLORS = (
(0.20, 0.60, 0.20), # 1D — green
(0.95, 0.70, 0.20), # 2D — amber
(0.85, 0.25, 0.20), # 3D — red
)
[docs]
def plot_dimensionality_psection(
sites: Any,
*,
skew_th: float = 3.0,
ellipt_th: float = 0.2,
segmented_colors: bool = True,
cmap: str | Any = "viridis",
colorbar: bool = True,
figsize: tuple[float, float] = (8.5, 4.0),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
df = build_phase_tensor_table(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
if ax is None:
_, ax = plt.subplots(figsize=figsize)
if df.empty:
ax.text(0.5, 0.5, "no phase tensor", ha="center", va="center")
return ax
df = df.copy()
df["logp"] = np.log10(df["period"].to_numpy())
# 0=1D, 1=2D, 2=3D (simple rule)
lab = np.zeros(len(df), dtype=int)
lab[
(np.abs(df["skew"]) <= skew_th) & (df["ellipt"].abs() <= ellipt_th)
] = 0
lab[(np.abs(df["skew"]) <= skew_th) & (df["ellipt"].abs() > ellipt_th)] = 1
lab[(np.abs(df["skew"]) > skew_th)] = 2
df["dim"] = lab
piv = df.pivot_table(
index="logp",
columns="station",
values="dim",
aggfunc="median",
)
piv = piv.sort_index()
Z = piv.to_numpy(dtype=float) # (n_logp, n_st) — no transpose
if segmented_colors:
plot_cmap = ListedColormap(DIMENSIONALITY_TRAFFIC_COLORS)
plot_norm = BoundaryNorm([-0.5, 0.5, 1.5, 2.5], plot_cmap.N)
else:
plot_cmap = cmap
plot_norm = None
im = ax.imshow(
Z,
aspect="auto",
origin="lower",
interpolation="nearest",
cmap=plot_cmap,
norm=plot_norm,
)
ax.set_ylabel(LOG10_PERIOD_LABEL)
PYCSAMT_STATION_RENDERING.apply(
ax,
np.arange(Z.shape[1]),
list(piv.columns),
preset="pseudosection",
xlim=(-0.5, Z.shape[1] - 0.5),
)
if not ax.yaxis_inverted():
ax.invert_yaxis()
yt = np.linspace(
0, Z.shape[0] - 1, num=min(8, Z.shape[0])
) # shape[0] = n_logp
yvals = np.linspace(
piv.index.min(), piv.index.max(), num=min(8, len(piv.index))
)
ax.set_yticks(yt)
ax.set_yticklabels([f"{v:.2g}" for v in yvals])
if colorbar:
cb = plt.colorbar(im, ax=ax)
if segmented_colors:
cb.set_ticks([0, 1, 2])
cb.set_ticklabels(["1D", "2D", "3D"])
cb.set_label("Dimensionality")
else:
cb.set_label("dim (0=1D,1=2D,2=3D)")
return ax
[docs]
def plot_phase_tensor_rose(
sites: Any,
*,
# ── visual style ─────────────────────────────────────────────────────
style: str | RoseStyle | None = "pycsamt",
# ── data selection ───────────────────────────────────────────────────
band: tuple[float, float] | None = None,
freq_bands: list[tuple[float, float]] | None = None,
band_labels: list[str] | None = None,
band_colors: list | None = None,
# ── histogram ────────────────────────────────────────────────────────
bins: int = 36,
# ── visual overrides (sentinel → taken from *style*) ─────────────────
bar_style=_UNSET,
bar_color=_UNSET,
bar_alpha=_UNSET,
bar_edgecolor=_UNSET,
bar_edgelw=_UNSET,
cmap=_UNSET,
outer_ring_lw=_UNSET,
outer_ring_color=_UNSET,
n_rings=_UNSET,
ring_color=_UNSET,
ring_ls=_UNSET,
ring_lw=_UNSET,
ring_labels=_UNSET,
ring_label_angle=_UNSET,
ring_label_fontsize=_UNSET,
ring_label_color=_UNSET,
ring_label_fmt=_UNSET,
spoke_every=_UNSET,
spoke_color=_UNSET,
spoke_ls=_UNSET,
spoke_lw=_UNSET,
compass_labels=_UNSET,
compass_fontsize=_UNSET,
compass_color=_UNSET,
compass_fontweight=_UNSET,
show_mean=_UNSET,
mean_color=_UNSET,
mean_lw=_UNSET,
mean_ls=_UNSET,
show_secondary=_UNSET,
secondary_ls=_UNSET,
secondary_lw=_UNSET,
secondary_color=_UNSET,
show_annotation=_UNSET,
annotation_pos=_UNSET,
annotation_fontsize=_UNSET,
annotation_bg=_UNSET,
annotation_ec=_UNSET,
show_n=_UNSET,
# ── layout ───────────────────────────────────────────────────────────
figsize: tuple[float, float] = (5.5, 5.5),
title: str = "",
title_fontsize: float = 10.0,
# ── core ─────────────────────────────────────────────────────────────
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
"""Publication-quality phase-tensor θ rose diagram.
Bars are drawn with axial symmetry — 0–180° mirrored to 180–360° —
reflecting the inherent 180° ambiguity of the phase-tensor principal
axis direction.
Parameters
----------
sites : any
EDI paths, objects, or collection accepted by
:func:`~pycsamt.emtools._core.ensure_sites`.
band : (lo_s, hi_s) or None
Period window in seconds. ``None`` uses all available periods.
freq_bands : list of (lo_s, hi_s), optional
Sub-bands for ``bar_style="bands"`` — one stacked bar colour per
band.
band_labels : list[str], optional
Legend labels for each entry in *freq_bands*.
band_colors : list, optional
Colours for each *freq_bands* entry. Defaults to ``tab10``.
bins : int
Number of bins over 0–180°, mirrored to 360°. Default ``36``
gives 5° bins.
bar_style : {"gradient", "solid", "bands"}
``"gradient"`` colours bars by height via *cmap*;
``"solid"`` uses *bar_color* uniformly;
``"bands"`` stacks one colour per period sub-band.
bar_color : str
Bar fill colour for ``bar_style="solid"``.
bar_edgecolor : str
Bar edge colour (``"none"`` → no edge).
bar_edgelw : float
Bar edge line-width.
bar_alpha : float
Bar opacity (0–1).
cmap : str
Colormap for ``bar_style="gradient"``.
outer_ring_lw : float
Line-width of the bold outer bounding circle.
outer_ring_color : str
Colour of the outer circle.
n_rings : int
Number of concentric reference rings.
ring_color : str
Colour of concentric rings and spokes.
ring_ls : str
Line-style of concentric rings.
ring_lw : float
Line-width of concentric rings.
ring_labels : list[float], optional
Explicit count values to annotate on the rings (e.g.
``[25, 50, 75, 100]``). ``None`` → evenly spaced from
``rmax / n_rings`` to ``rmax``.
ring_label_angle : float
Clockwise angle from North (degrees) at which ring count labels
are placed. Default ``22.5``.
ring_label_fontsize : float
Font size for ring count labels.
ring_label_color : str
Colour for ring count labels.
ring_label_fmt : str
Format string for ring labels, e.g. ``"{:.0f}"``.
spoke_every : float
Angular spacing (degrees) between radial spokes.
spoke_color : str
Colour of radial spokes.
spoke_ls : str
Line-style of radial spokes.
spoke_lw : float
Line-width of radial spokes.
compass_labels : {"NESW", "degrees", "none"}
Perimeter labels. ``"NESW"`` shows cardinal directions;
``"degrees"`` shows degree values; ``"none"`` hides all.
compass_fontsize : float
Font size for perimeter labels.
compass_color : str
Colour for perimeter labels.
compass_fontweight : str
Font weight for perimeter labels.
show_mean : bool
Draw the axial mean direction as a line through the centre.
mean_color : str
Colour of the mean-direction line.
mean_lw : float
Line-width of the mean-direction line.
mean_ls : str
Line-style of the mean-direction line.
show_secondary : bool
Draw the 180°-conjugate mean line.
secondary_ls : str
Line-style of the conjugate line.
secondary_lw : float, optional
Line-width of the conjugate line; defaults to *mean_lw*.
secondary_color : str, optional
Colour of the conjugate line; defaults to *mean_color*.
show_annotation : bool
Show a text box with the mean θ and station/pair count.
annotation_pos : (float, float)
Axes-fraction ``(x, y)`` for the annotation box.
annotation_fontsize : float
Font size of the annotation text.
annotation_bg : str
Background colour of the annotation box.
annotation_ec : str
Edge colour of the annotation box.
show_n : bool
Append ``n = N`` to the annotation text.
figsize : (float, float)
Figure size in inches.
title : str
Axes title (set via ``ax.set_title``).
title_fontsize : float
Font size for *title*.
recursive, on_dup, strict, verbose
Passed to :func:`~pycsamt.emtools._core.ensure_sites`.
ax : matplotlib.axes.Axes, optional
Pre-existing polar axes to draw into. Created when ``None``.
Returns
-------
matplotlib.axes.Axes
The polar axes containing the rose diagram.
Examples
--------
Default gradient style, all periods:
>>> from pycsamt.emtools import plot_phase_tensor_rose
>>> ax = plot_phase_tensor_rose("path/to/edis/", figsize=(6, 6))
Frequency-band decomposition (stacked):
>>> ax = plot_phase_tensor_rose(
... sites,
... bar_style="bands",
... freq_bands=[(1e-4, 1e-2), (1e-2, 1e0)],
... band_labels=["Short period", "Long period"],
... )
Custom ring count labels:
>>> ax = plot_phase_tensor_rose(
... sites,
... ring_labels=[25, 50, 75, 100],
... ring_label_angle=15.0,
... )
"""
# ── resolve style → fill _UNSET visual params ─────────────────────────
rs = resolve_rose_style(style)
def _v(val, attr):
return getattr(rs, attr) if val is _UNSET else val
bar_style = _v(bar_style, "bar_style")
bar_color = _v(bar_color, "bar_color")
bar_alpha = _v(bar_alpha, "bar_alpha")
bar_edgecolor = _v(bar_edgecolor, "bar_edgecolor")
bar_edgelw = _v(bar_edgelw, "bar_edgelw")
cmap = _v(cmap, "cmap")
outer_ring_lw = _v(outer_ring_lw, "outer_ring_lw")
outer_ring_color = _v(outer_ring_color, "outer_ring_color")
n_rings = _v(n_rings, "n_rings")
ring_color = _v(ring_color, "ring_color")
ring_ls = _v(ring_ls, "ring_ls")
ring_lw = _v(ring_lw, "ring_lw")
ring_labels = _v(ring_labels, "ring_labels")
ring_label_angle = _v(ring_label_angle, "ring_label_angle")
ring_label_fontsize = _v(ring_label_fontsize, "ring_label_fontsize")
ring_label_color = _v(ring_label_color, "ring_label_color")
ring_label_fmt = _v(ring_label_fmt, "ring_label_fmt")
spoke_every = _v(spoke_every, "spoke_every")
spoke_color = _v(spoke_color, "spoke_color")
spoke_ls = _v(spoke_ls, "spoke_ls")
spoke_lw = _v(spoke_lw, "spoke_lw")
compass_labels = _v(compass_labels, "compass_labels")
compass_fontsize = _v(compass_fontsize, "compass_fontsize")
compass_color = _v(compass_color, "compass_color")
compass_fontweight = _v(compass_fontweight, "compass_fontweight")
show_mean = _v(show_mean, "show_mean")
mean_color = _v(mean_color, "mean_color")
mean_lw = _v(mean_lw, "mean_lw")
mean_ls = _v(mean_ls, "mean_ls")
show_secondary = _v(show_secondary, "show_secondary")
secondary_color = _v(secondary_color, "secondary_color")
secondary_ls = _v(secondary_ls, "secondary_ls")
secondary_lw = _v(secondary_lw, "secondary_lw")
show_annotation = _v(show_annotation, "show_annotation")
annotation_pos = _v(annotation_pos, "annotation_pos")
annotation_fontsize = _v(annotation_fontsize, "annotation_fontsize")
annotation_bg = _v(annotation_bg, "annotation_bg")
annotation_ec = _v(annotation_ec, "annotation_ec")
show_n = _v(show_n, "show_n")
# ── axial mean helper (local, avoids import cycle with strike.py) ──────
def _axial_mean(a_deg: np.ndarray) -> float:
"""Circular mean for axial (0–180°) data, returned in [0,180°)."""
rad = np.radians(2.0 * a_deg)
mu = (
np.degrees(
np.arctan2(np.nanmean(np.sin(rad)), np.nanmean(np.cos(rad)))
)
/ 2.0
)
return float(mu % 180.0)
# ── build phase-tensor table ───────────────────────────────────────────
df = build_phase_tensor_table(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
if ax is None:
_, ax = plt.subplots(subplot_kw=dict(polar=True), figsize=figsize)
if df.empty:
ax.text(
0.5,
0.5,
"no phase tensor data",
ha="center",
va="center",
transform=ax.transAxes,
)
return ax
# ── period filtering ───────────────────────────────────────────────────
if band is not None:
lo, hi = float(band[0]), float(band[1])
sel = (df["period"] >= lo) & (df["period"] <= hi)
band_label = f"{lo:.4g}–{hi:.4g} s"
else:
sel = np.ones(len(df), dtype=bool)
p = df["period"]
band_label = f"{p.min():.4g}–{p.max():.4g} s"
# ── histogram over 0–180° ─────────────────────────────────────────────
bins_ = int(max(12, bins))
edges_deg = np.linspace(0.0, 180.0, bins_ + 1)
dw = np.radians(180.0 / bins_)
use_bands = bar_style == "bands" and bool(freq_bands)
if use_bands:
n_fb = len(freq_bands) # type: ignore[arg-type]
_bc: list = (
list(band_colors)
if band_colors is not None
else list(plt.get_cmap("tab10")(np.linspace(0, 0.8, n_fb)))
)
_bl: list[str] = (
list(band_labels)
if band_labels is not None
else [f"{lo_:.4g}–{hi_:.4g} s" for lo_, hi_ in freq_bands]
) # type: ignore
hists: list[np.ndarray] = []
for fb in freq_bands: # type: ignore[union-attr]
lo_, hi_ = float(fb[0]), float(fb[1])
sel_fb = (df["period"] >= lo_) & (df["period"] <= hi_)
th_fb = df.loc[sel_fb, "theta"].to_numpy(float) % 180.0
h, _ = np.histogram(th_fb, bins=edges_deg)
hists.append(h)
hist_total = np.sum(hists, axis=0)
else:
th_raw = df.loc[sel, "theta"].to_numpy(float) % 180.0
hist_total, _ = np.histogram(th_raw, bins=edges_deg)
rmax = float(hist_total.max()) if hist_total.max() > 0 else 1.0
ang_mid = np.radians(0.5 * (edges_deg[1:] + edges_deg[:-1]))
# ── polar setup ───────────────────────────────────────────────────────
ax.set_theta_zero_location("N")
ax.set_theta_direction(-1)
# hide radial tick labels; keep custom rings without numeric radius text
ax.set_yticklabels([])
ax.set_yticks([])
hide_polar_radius_labels(ax)
ax.yaxis.grid(False)
ax.xaxis.grid(False)
ax.set_frame_on(False)
# ── draw bars (0–180° and mirror 180–360°) ────────────────────────────
def _draw_bars(
ang_c: np.ndarray, heights: np.ndarray, color, bottom: float = 0.0
) -> None:
for a, h in zip(ang_c, heights):
for a_plot in (a, a + np.pi): # axial symmetry
ax.bar(
a_plot,
h,
width=dw,
bottom=bottom,
color=color,
edgecolor=bar_edgecolor,
linewidth=bar_edgelw,
alpha=bar_alpha,
)
if use_bands:
bottoms = np.zeros(bins_)
for h, col in zip(hists, _bc):
_draw_bars(ang_mid, h, col, bottom=0.0)
# stacked: accumulate actual bottom for next band
bottoms += h
# legend
from matplotlib.patches import Patch as _Patch
ax.legend(
handles=[
_Patch(fc=c, label=l, alpha=bar_alpha)
for c, l in zip(_bc, _bl)
],
loc="lower left",
bbox_to_anchor=(1.05, 0.0),
fontsize=max(6, annotation_fontsize - 1),
framealpha=0.9,
)
elif bar_style == "gradient":
cm_ = plt.get_cmap(cmap)
for a, h in zip(ang_mid, hist_total):
col = cm_(h / rmax) if rmax > 0 else cm_(0.5)
for a_plot in (a, a + np.pi):
ax.bar(
a_plot,
h,
width=dw,
color=col,
edgecolor=bar_edgecolor,
linewidth=bar_edgelw,
alpha=bar_alpha,
)
else: # solid
_draw_bars(ang_mid, hist_total, bar_color)
# ── concentric rings ──────────────────────────────────────────────────
if ring_labels is not None:
r_levels = [float(v) for v in ring_labels]
else:
step = rmax / n_rings
r_levels = [step * k for k in range(1, n_rings + 1)]
theta_full = np.linspace(0, 2 * np.pi, 360)
for rv in r_levels:
ax.plot(
theta_full,
np.full_like(theta_full, rv),
color=ring_color,
ls=ring_ls,
lw=ring_lw,
zorder=0,
)
# ── radial spokes ─────────────────────────────────────────────────────
n_spokes = int(round(360.0 / spoke_every))
spoke_angles = np.radians(np.arange(n_spokes) * spoke_every)
for sa in spoke_angles:
ax.plot(
[sa, sa],
[0, rmax],
color=spoke_color,
ls=spoke_ls,
lw=spoke_lw,
zorder=0,
)
# ── outer ring ────────────────────────────────────────────────────────
ax.spines["polar"].set_linewidth(outer_ring_lw)
ax.spines["polar"].set_edgecolor(outer_ring_color)
ax.set_ylim(0, rmax * 1.08)
# ── compass labels ────────────────────────────────────────────────────
n_spk = int(round(360.0 / spoke_every))
spoke_degs = np.arange(n_spk) * spoke_every
if compass_labels == "NESW":
_card = {0: "N", 90: "E", 180: "S", 270: "W"}
lbl_list = [_card.get(int(d) % 360, "") for d in spoke_degs]
elif compass_labels == "degrees":
lbl_list = [f"{int(d)}°" for d in spoke_degs]
else:
lbl_list = [""] * n_spk
ax.set_thetagrids(
spoke_degs,
labels=lbl_list,
fontsize=compass_fontsize,
)
for lbl in ax.get_xticklabels():
lbl.set_color(compass_color)
lbl.set_fontweight(compass_fontweight)
# ── mean direction ────────────────────────────────────────────────────
th_all = df.loc[sel, "theta"].to_numpy(float) % 180.0
mu = _axial_mean(th_all) if len(th_all) > 0 else 0.0
sec_lw = secondary_lw if secondary_lw is not None else mean_lw
sec_col = secondary_color if secondary_color is not None else mean_color
if show_mean and len(th_all) > 0:
mu_r = np.radians(mu)
# draw full diameter: two radii (center→rim) in opposite directions
for ang_pt in (mu_r, mu_r + np.pi):
ax.plot(
[ang_pt, ang_pt],
[0, rmax],
color=mean_color,
lw=mean_lw,
ls=mean_ls,
solid_capstyle="round",
zorder=10,
)
if show_secondary:
mu90 = np.radians(mu + 90.0)
for ang_pt in (mu90, mu90 + np.pi):
ax.plot(
[ang_pt, ang_pt],
[0, rmax],
color=sec_col,
lw=sec_lw,
ls=secondary_ls,
solid_capstyle="round",
zorder=9,
)
# ── annotation box ────────────────────────────────────────────────────
if show_annotation and len(th_all) > 0:
txt = f"θ̄ = {mu:.1f}°"
if show_n:
txt += f"\nn = {len(th_all)}"
ax.text(
annotation_pos[0],
annotation_pos[1],
txt,
transform=ax.transAxes,
fontsize=annotation_fontsize,
va="top",
ha="left",
bbox=dict(
boxstyle="round,pad=0.3",
fc=annotation_bg,
ec=annotation_ec,
lw=0.8,
),
zorder=20,
)
# ── title ─────────────────────────────────────────────────────────────
if title:
ax.set_title(title, fontsize=title_fontsize, pad=14)
else:
ax.set_title(
f"Phase-tensor θ rose ({band_label})",
fontsize=title_fontsize,
pad=14,
)
return ax
def _topography_bg_grid(
topography: Any,
sites: Any,
coords: dict[str, tuple[float, float]],
*,
cmap: str = "terrain",
alpha: float = 0.55,
label: str = "Elevation (m)",
n: int = 90,
) -> dict[str, Any] | None:
"""Build a ``bg_grid`` dict from an elevation source.
Parameters
----------
topography : dict | bool | str | pathlib.Path
``dict`` — returned unchanged (already a ``bg_grid`` spec).
``True`` — station elevations read from the EDI headers
(``ed.coords[2]`` / ``ed.elev`` / ``ed.meta``).
path to a CSV — either ``lon,lat,elev`` rows (a scattered DEM)
or ``station,elev`` rows (joined to *coords*).
sites : any
The already-loaded sites/collection.
coords : dict[str, (lat, lon)]
Station coordinates in the map frame.
cmap, alpha, label, n :
Background colormap, opacity, colorbar label, and grid density.
Returns
-------
dict or None
``{"lons", "lats", "values", "cmap", "alpha", "label"}`` ready
for ``plot_phase_tensor_map(bg_grid=...)``, or ``None`` when
fewer than 3 finite elevation points are available.
"""
if isinstance(topography, dict):
return topography
tri: list[tuple[float, float, float]] = []
if topography is True:
for i, ed in enumerate(_iter_items(sites)):
st = _name(ed, i)
if st not in coords:
continue
z = None
c = getattr(ed, "coords", None)
if (
c is not None
and len(c) >= 3
and c[2] is not None
and np.isfinite(c[2])
):
z = float(c[2])
else:
e = getattr(ed, "elev", None)
if e is None:
e = (getattr(ed, "meta", {}) or {}).get("elev")
try:
if e is not None and np.isfinite(float(e)):
z = float(e)
except (TypeError, ValueError):
z = None
latlon = coords[st]
if z is None and len(latlon) >= 3 and np.isfinite(latlon[2]):
z = float(latlon[2]) # elevation carried in coords
if z is not None:
tri.append((float(latlon[1]), float(latlon[0]), z))
else:
try:
df = pd.read_csv(topography)
except Exception: # noqa: BLE001 — best effort
return None
low = {str(c).lower(): c for c in df.columns}
def _col(*names: str) -> Any:
return next((low[k] for k in names if k in low), None)
z_c = _col(
"elev", "elevation", "elevation_m", "z", "alt", "altitude",
"height",
)
lon_c = _col("lon", "longitude", "long", "x", "easting")
lat_c = _col("lat", "latitude", "y", "northing")
st_c = _col("station", "site", "id", "name", "sta")
if z_c is None:
return None
if lon_c is not None and lat_c is not None:
for _, r in df.iterrows():
try:
tri.append(
(float(r[lon_c]), float(r[lat_c]), float(r[z_c]))
)
except (TypeError, ValueError):
pass
elif st_c is not None:
emap: dict[str, float] = {}
for _, r in df.iterrows():
try:
emap[str(r[st_c])] = float(r[z_c])
except (TypeError, ValueError):
pass
for st, (lat, lon) in coords.items():
if st in emap:
tri.append((lon, lat, emap[st]))
pts = np.asarray(tri, dtype=float)
if pts.ndim != 2 or pts.shape[0] < 3:
return None
pts = pts[np.isfinite(pts).all(axis=1)]
if pts.shape[0] < 3:
return None
lon = np.linspace(pts[:, 0].min(), pts[:, 0].max(), n)
lat = np.linspace(pts[:, 1].min(), pts[:, 1].max(), n)
grid_lon, grid_lat = np.meshgrid(lon, lat)
try:
from scipy.interpolate import griddata
# linear interpolation only — leaves NaN (→ transparent) outside
# the data hull so the background does not extrapolate into
# unsurveyed ground
vals = griddata(
pts[:, :2], pts[:, 2], (grid_lon, grid_lat), method="linear"
)
except Exception: # noqa: BLE001 — SciPy missing / degenerate points
return None
if not np.isfinite(vals).any():
return None
return {
"lons": lon,
"lats": lat,
"values": vals,
"cmap": cmap,
"alpha": float(alpha),
"label": label,
"cbar_side": "bottom",
}
[docs]
def plot_phase_tensor_map(
sites: Any,
*,
period: float = 10.0,
# ── ellipse style (honours PhaseTensorEllipseStyle) ───────────────
scale=_UNSET,
ellipse_scale: float = 1.0,
normalise_by=_UNSET,
s1_ref=_UNSET,
min_aspect=_UNSET,
c_by=_UNSET,
cmap=_UNSET,
clim: tuple[float, float] | None = None,
clim_pct=_UNSET,
symmetric_clim=_UNSET,
alpha=_UNSET,
edgecolor=_UNSET,
linewidth=_UNSET,
skew_threshold=_UNSET,
mark_3d=_UNSET,
lw_3d_factor=_UNSET,
ref_ellipse=_UNSET,
# ── tipper arrows ─────────────────────────────────────────────────
show_tipper: bool = True,
tipper_convention: str = "parkinson",
tipper_component: str = "real",
tipper_scale: float | None = None,
tipper_color: str = "k",
tipper_lw: float = 1.4,
# ── optional background grid ──────────────────────────────────────
bg_grid: dict[str, Any] | None = None,
topography: Any = None,
topography_cmap: str = "terrain",
topography_alpha: float = 0.55,
# ── map decoration ────────────────────────────────────────────────
station_labels: bool = True,
station_marker: str = "v",
station_ms: float = 5.0,
station_color: str = "k",
label_fontsize: float = 7.0,
title: str = "",
colorbar_label: str | None = None,
show_colorbar: bool = True,
# ── coordinate override (station → (lat, lon)) ────────────────────
coords: dict[str, tuple[float, float]] | None = None,
# ── layout ────────────────────────────────────────────────────────
figsize: tuple[float, float] = (9.0, 7.0),
# ── core ──────────────────────────────────────────────────────────
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
"""Publication-quality phase-tensor map at a single period.
Renders each station as a phase-tensor ellipse positioned at its
geographic coordinates. The ellipse shape encodes the phase-tensor
principal values (φ_max, φ_min) and orientation (θ); fill colour
encodes an additional scalar (default: skewness β). Induction arrows
can be overlaid when tipper data are available, and an optional
background field (gravity, resistivity, …) may be drawn behind the
ellipses.
Parameters
----------
sites : any
EDI paths, objects, or collection accepted by
:func:`~pycsamt.emtools._core.ensure_sites`.
period : float
Target period (s). The nearest available period in the data is
used for each station independently.
scale : float or _UNSET
Maximum ellipse semi-axis in geographic units (degrees).
``_UNSET`` → auto-derived from the median inter-station spacing.
ellipse_scale : float, default 1.0
Multiplier applied on top of *scale* — a quick "make the
ellipses bigger/smaller" knob (``1.5`` = 50 % larger).
normalise_by : ``"cell"`` | ``"unity"`` | ``"abs"``
Size normalisation strategy, see
:class:`~pycsamt.api.style.PhaseTensorEllipseStyle`.
s1_ref : float or None
Manual reference s1 used with ``normalise_by="cell"``/``"unity"``.
min_aspect : float or _UNSET
Minimum height/width ratio enforced on every ellipse; see
:func:`plot_phase_tensor_psection`. Default:
``PYCSAMT_STYLE.pt_ellipse.min_aspect``.
c_by : str
Scalar quantity to map to fill colour. Recognised values:
``"skew"``, ``"beta"``, ``"|skew|"``, ``"theta"``,
``"ellipt"``, ``"phi_mean"``, ``"phi_max"``, ``"phi_min"``,
``"s1"``, ``"s2"``, ``"alpha"``. Defaults to
``PYCSAMT_STYLE.pt_ellipse.c_by``.
cmap : str or None
Matplotlib colormap. Auto-selected from *c_by* when ``None``
(same logic as :class:`~pycsamt.api.style.PhaseTensorEllipseStyle`).
clim : (vmin, vmax) or None
Explicit colour limits. ``None`` → derived from *clim_pct*.
clim_pct : (lo, hi)
Percentile window for automatic colour limits.
symmetric_clim : bool
Force vmin = −vmax (useful for diverging quantities).
alpha : float
Ellipse fill opacity.
edgecolor : str
Ellipse border colour.
linewidth : float
Normal ellipse border width (pts).
skew_threshold : float or None
|β| above which a cell is flagged as 3-D.
mark_3d : bool
Draw thicker borders on 3-D flagged ellipses.
lw_3d_factor : float
Line-width multiplier for 3-D flagged borders.
ref_ellipse : bool
Draw a reference circle in the lower-left corner as a scale bar.
show_tipper : bool
Overlay induction arrows when tipper data are found.
tipper_convention : ``"parkinson"`` | ``"wiese"``
Parkinson: arrow toward anomaly (negated real T);
Wiese: arrow along real T.
tipper_component : ``"real"`` | ``"imag"`` | ``"both"``
Which tipper component to draw.
tipper_scale : float or None
Arrow length = tipper_scale × *scale*. Auto-derived when ``None``.
tipper_color : str
Arrow colour (real component). Imaginary component uses
``mcolors.to_rgba(tipper_color, 0.55)``.
tipper_lw : float
Arrow line-width.
bg_grid : dict or None
Optional background field drawn behind ellipses::
bg_grid = dict(
lons = 1D or 2D longitude array,
lats = 1D or 2D latitude array,
values = 2D array (shape matches meshgrid of lons × lats),
cmap = "RdYlGn", # colormap
clim = (vmin, vmax), # or None → auto
alpha = 0.55, # opacity
label = "Gravity (gu)",
)
topography : bool | str | dict | None, default None
Convenience elevation background (ignored when *bg_grid* is
given). ``True`` interpolates the station elevations from the
EDI headers; a CSV path supplies either ``lon,lat,elev`` rows
(a scattered DEM) or ``station,elev`` rows; a dict is used
directly as *bg_grid*.
topography_cmap : str, default ``"terrain"``
Colormap for the *topography* background.
topography_alpha : float, default 0.55
Opacity for the *topography* background.
station_labels : bool
Annotate each station position with its name.
station_marker : str
Marker style for station positions (default ``"v"``).
station_ms : float
Marker size.
station_color : str
Marker and label colour.
label_fontsize : float
Font size for station labels.
title : str
Axes title.
colorbar_label : str or None
Override the automatic colorbar label derived from *c_by*.
show_colorbar : bool, default True
Draw the fill-colour colorbar. Set ``False`` when tiling several
maps that share one figure-level colorbar (see
:func:`plot_phase_tensor_map_grid`).
coords : dict[str, (lat, lon)] or None
Explicit station coordinates. When ``None`` the function reads
``ed.coords`` (a ``(lat, lon, elev)`` tuple) from each Site object.
figsize : (float, float)
Figure size in inches.
recursive, on_dup, strict, verbose
Passed to :func:`~pycsamt.emtools._core.ensure_sites`.
ax : matplotlib.axes.Axes or None
Pre-existing axes to draw into.
Returns
-------
matplotlib.axes.Axes
Examples
--------
Default skew-coloured map:
>>> from pycsamt.emtools import plot_phase_tensor_map
>>> ax = plot_phase_tensor_map(sites, period=10.0)
Ellipticity coloured, with tipper arrows:
>>> ax = plot_phase_tensor_map(
... sites,
... period=10.0,
... c_by="ellipt",
... cmap="viridis",
... show_tipper=True,
... tipper_convention="parkinson",
... )
With a gravity background:
>>> ax = plot_phase_tensor_map(
... sites,
... period=10.0,
... bg_grid=dict(
... lons=g_lon,
... lats=g_lat,
... values=g_bouguer,
... cmap="RdYlGn",
... alpha=0.45,
... label="Gravity (gu)",
... ),
... )
"""
import matplotlib.colors as _mc
from ..api.plot import add_colorbar
# ── 1. resolve style ──────────────────────────────────────────────────
_es = PYCSAMT_STYLE.pt_ellipse
def _sv(v, attr):
return getattr(_es, attr) if v is _UNSET else v
normalise_by = _sv(normalise_by, "normalise_by")
s1_ref_ = _sv(s1_ref, "s1_ref")
min_aspect_ = _sv(min_aspect, "min_aspect")
c_by = _sv(c_by, "c_by")
cmap_ = _sv(cmap, "cmap")
clim_pct = _sv(clim_pct, "clim_pct")
symmetric_clim = _sv(symmetric_clim, "symmetric_clim")
alpha_ = _sv(alpha, "alpha")
edgecolor_ = _sv(edgecolor, "edgecolor")
linewidth_ = _sv(linewidth, "linewidth")
skew_threshold_ = _sv(skew_threshold, "skew_threshold")
mark_3d_ = _sv(mark_3d, "mark_3d")
lw_3d_factor_ = _sv(lw_3d_factor, "lw_3d_factor")
ref_ellipse_ = _sv(ref_ellipse, "show_ref")
# auto-resolve cmap from c_by when None
if cmap_ is None:
cmap_ = _es.copy(c_by=c_by).resolve_cmap()
# honour natural symmetric_clim for diverging quantities
sym = symmetric_clim and _es.copy(c_by=c_by).resolve_symmetric_clim()
# ── 2. load data ──────────────────────────────────────────────────────
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
df = build_phase_tensor_table(
S,
recursive=False,
on_dup=on_dup,
strict=False,
verbose=verbose,
)
if ax is None:
fig, ax = plt.subplots(figsize=figsize, constrained_layout=True)
if df.empty:
ax.text(
0.5,
0.5,
"no phase tensor data",
ha="center",
va="center",
transform=ax.transAxes,
)
return ax
# ── 3. station coordinates ────────────────────────────────────────────
if coords is None:
coords = {}
for i, ed in enumerate(_iter_items(S)):
st = _name(ed, i)
# preferred: ed.coords → (lat, lon, elev)
# A missing EDI ``>HEAD`` LAT/LONG (e.g. KAP03, which only
# carries REFLAT/REFLONG in >=DEFINEMEAS) surfaces as
# ``.coords`` returning NaN, not None; the "is not None"
# checks alone let NaN through into `coords`, and NaN then
# crashes ax.set_xlim() below instead of reaching the
# intended "no geographic coordinates" message.
c = getattr(ed, "coords", None)
if (
c is not None
and len(c) >= 2
and c[0] is not None
and c[1] is not None
and np.isfinite(c[0])
and np.isfinite(c[1])
):
coords[st] = (float(c[0]), float(c[1]))
continue
# fallback: ed.meta
meta = getattr(ed, "meta", {}) or {}
lat = meta.get("lat") or meta.get("latitude")
lon = meta.get("lon") or meta.get("longitude") or meta.get("long")
if (
lat is not None
and lon is not None
and np.isfinite(float(lat))
and np.isfinite(float(lon))
):
coords[st] = (float(lat), float(lon))
if not coords:
ax.text(
0.5,
0.5,
"no geographic coordinates in EDI data\n"
"pass coords={station: (lat, lon)} to override",
ha="center",
va="center",
transform=ax.transAxes,
fontsize=9,
style="italic",
color="0.45",
)
return ax
# ── 4. select nearest period per station ─────────────────────────────
rows: list[Any] = []
for st, sdf in df.groupby("station"):
if st not in coords:
continue
p = sdf["period"].to_numpy()
idx = int(np.nanargmin(np.abs(p - period)))
rows.append(sdf.iloc[idx])
if not rows:
ax.text(
0.5,
0.5,
f"no data near T = {period:g} s",
ha="center",
va="center",
transform=ax.transAxes,
)
return ax
st_names = [r["station"] for r in rows]
lats = np.array([coords[s][0] for s in st_names], float)
lons = np.array([coords[s][1] for s in st_names], float)
s1s = np.array([float(r["s1"]) for r in rows], float)
s2s = np.array([float(r["s2"]) for r in rows], float)
ths = np.array([float(r["theta"]) for r in rows], float)
np.array(
[float(r.get("skew", r.get("beta", np.nan))) for r in rows], float
)
# ── 5. auto scale from grid spacing ──────────────────────────────────
if scale is _UNSET:
if len(lons) > 1:
# Use unique grid spacings (cross-profile > within-profile)
u_lons = np.unique(np.round(lons, 3))
u_lats = np.unique(np.round(lats, 3))
d_lon = (
float(np.median(np.diff(u_lons)))
if len(u_lons) > 1
else float(np.ptp(lons) or 0.01)
)
d_lat = (
float(np.median(np.diff(u_lats)))
if len(u_lats) > 1
else float(np.ptp(lats) or 0.01)
)
scale_ = 0.55 * max(abs(d_lon) + 1e-9, abs(d_lat) + 1e-9)
else:
scale_ = 0.01
else:
scale_ = float(scale)
scale_ = scale_ * max(float(ellipse_scale), 1e-6)
# ── 6. ellipse size normalisation ────────────────────────────────────
# Clip extreme s1 outliers (robust 95th pct) before normalising
s1s_clip = np.clip(s1s, 0.0, float(np.nanpercentile(s1s, 95)))
if normalise_by == "unity":
_s1ref = (
s1_ref_ if s1_ref_ is not None else float(np.tan(np.radians(45.0)))
)
elif normalise_by == "cell":
_s1ref = (
s1_ref_
if s1_ref_ is not None
else float(np.nanpercentile(s1s_clip, 90))
)
else: # "abs"
_s1ref = s1_ref_ if s1_ref_ is not None else 1.0
_s1ref = max(_s1ref, 1e-9)
# ── 7. colour mapping ─────────────────────────────────────────────────
c_vals = np.array([float(r.get(c_by, np.nan)) for r in rows], float)
if clim is not None:
v0, v1 = float(clim[0]), float(clim[1])
else:
lo_pct, hi_pct = float(clim_pct[0]), float(clim_pct[1])
v0 = float(np.nanpercentile(c_vals, lo_pct))
v1 = float(np.nanpercentile(c_vals, hi_pct))
if sym and v0 != v1:
vmax = max(abs(v0), abs(v1))
v0, v1 = -vmax, vmax
v0 = v0 if v0 != v1 else v0 - 1.0
cm_obj = plt.get_cmap(cmap_)
norm_c = plt.Normalize(vmin=v0, vmax=v1)
# ── 8. optional background grid ───────────────────────────────────────
if bg_grid is None and topography is not None and topography is not False:
bg_grid = _topography_bg_grid(
topography,
S,
coords,
cmap=topography_cmap,
alpha=topography_alpha,
)
bg_mappable = None
if bg_grid is not None:
g_lon = np.asarray(bg_grid.get("lons", []), float)
g_lat = np.asarray(bg_grid.get("lats", []), float)
g_val = np.asarray(bg_grid.get("values", []), float)
g_cm = bg_grid.get("cmap", "RdYlGn")
g_cl = bg_grid.get("clim", None)
g_al = float(bg_grid.get("alpha", 0.5))
g_lbl = bg_grid.get("label", "")
g_side = bg_grid.get("cbar_side", "bottom")
if g_val.size > 0:
if g_val.ndim == 1:
g_val = g_val.reshape(len(g_lat), len(g_lon))
gv0 = (
float(np.nanpercentile(g_val, 5))
if g_cl is None
else float(g_cl[0])
)
gv1 = (
float(np.nanpercentile(g_val, 95))
if g_cl is None
else float(g_cl[1])
)
bg_mappable = ax.pcolormesh(
g_lon,
g_lat,
g_val,
cmap=g_cm,
vmin=gv0,
vmax=gv1,
alpha=g_al,
shading="auto",
zorder=0,
)
if g_lbl and g_side == "bottom":
cbg = ax.figure.colorbar(
bg_mappable,
ax=ax,
location="bottom",
fraction=0.045,
shrink=0.7,
pad=0.12,
aspect=40,
)
cbg.set_label(g_lbl, fontsize=8)
cbg.ax.tick_params(labelsize=7)
elif g_lbl:
add_colorbar(
bg_mappable,
ax,
label=g_lbl,
side=g_side,
size="3.5%",
pad=0.08,
)
# ── 9. draw ellipses ──────────────────────────────────────────────────
for row, lat, lon, s1, s2, th in zip(rows, lats, lons, s1s_clip, s2s, ths):
# clamp to 2×scale_ so outlier sites don't swamp the map
w = min(2.0 * scale_ * (s1 / _s1ref), 2.0 * scale_)
h = min(2.0 * scale_ * (s2 / _s1ref), 2.0 * scale_)
if min_aspect_ > 0:
h = max(h, min_aspect_ * w)
cv = float(row.get(c_by, np.nan))
fc = cm_obj(norm_c(cv)) if np.isfinite(cv) else (0.8, 0.8, 0.8, 0.8)
# 3-D flagging
beta = float(row.get("skew", row.get("beta", 0.0)))
is_3d = (
skew_threshold_ is not None
and mark_3d_
and abs(beta) > skew_threshold_
)
lw = linewidth_ * lw_3d_factor_ if is_3d else linewidth_
ec = edgecolor_
e = Ellipse(
xy=(lon, lat),
width=w,
height=h,
angle=th,
facecolor=fc,
edgecolor=ec,
linewidth=lw,
alpha=alpha_,
zorder=2,
)
ax.add_patch(e)
# ── 10. tipper arrows ─────────────────────────────────────────────────
if show_tipper:
_tip_data: dict[str, tuple[float, float]] = {}
for i, ed in enumerate(_iter_items(S)):
st = _name(ed, i)
if st not in coords:
continue
_T, t, fr = _get_t_block(ed)
if t is None or fr is None:
continue
per_arr = 1.0 / np.where(fr == 0, np.nan, fr)
j = int(np.nanargmin(np.abs(per_arr - period)))
tx, ty = t[j, 0], t[j, 1]
if tipper_component == "real":
u, v = float(np.real(ty)), float(np.real(tx)) # E, N
elif tipper_component == "imag":
u, v = float(np.imag(ty)), float(np.imag(tx))
else:
u, v = float(np.real(ty)), float(np.real(tx))
if tipper_convention == "parkinson":
u, v = -u, -v
_tip_data[st] = (u, v)
if _tip_data:
# normalise arrow lengths
magnitudes = np.array(
[np.hypot(u, v) for u, v in _tip_data.values()], float
)
mag_max = float(np.nanpercentile(magnitudes, 95)) + 1e-12
t_scale = (
tipper_scale if tipper_scale is not None else scale_ * 1.6
)
t_lons, t_lats, t_u, t_v = [], [], [], []
for st_t, (u, v) in _tip_data.items():
lat_t, lon_t = coords[st_t]
t_lons.append(lon_t)
t_lats.append(lat_t)
t_u.append(u / mag_max * t_scale)
t_v.append(v / mag_max * t_scale)
ax.quiver(
np.array(t_lons),
np.array(t_lats),
np.array(t_u),
np.array(t_v),
color=tipper_color,
scale=1.0,
scale_units="xy",
angles="xy",
width=0.003,
headwidth=4,
headlength=5,
linewidth=tipper_lw,
zorder=4,
)
# imaginary component if "both"
if tipper_component == "both":
_tip_im: dict[str, tuple[float, float]] = {}
for i, ed in enumerate(_iter_items(S)):
st = _name(ed, i)
if st not in coords:
continue
_T, t, fr = _get_t_block(ed)
if t is None or fr is None:
continue
per_arr = 1.0 / np.where(fr == 0, np.nan, fr)
j = int(np.nanargmin(np.abs(per_arr - period)))
tx, ty = t[j, 0], t[j, 1]
u_i, v_i = float(np.imag(ty)), float(np.imag(tx))
if tipper_convention == "parkinson":
u_i, v_i = -u_i, -v_i
_tip_im[st] = (u_i, v_i)
if _tip_im:
im_lons, im_lats, im_u, im_v = [], [], [], []
for st_t, (u, v) in _tip_im.items():
lat_t, lon_t = coords[st_t]
im_lons.append(lon_t)
im_lats.append(lat_t)
im_u.append(u / mag_max * t_scale)
im_v.append(v / mag_max * t_scale)
ax.quiver(
np.array(im_lons),
np.array(im_lats),
np.array(im_u),
np.array(im_v),
color=_mc.to_rgba(tipper_color, 0.55),
scale=1.0,
scale_units="xy",
angles="xy",
width=0.002,
headwidth=3,
headlength=4,
linestyle="dashed",
zorder=3,
)
# ── 11. station markers + labels ──────────────────────────────────────
ax.scatter(
lons,
lats,
marker=station_marker,
c=station_color,
s=station_ms**2,
zorder=5,
linewidths=0,
)
if station_labels:
offset_y = scale_ * 0.8
for st, lat, lon in zip(st_names, lats, lons):
ax.annotate(
st,
(lon, lat),
xytext=(0, offset_y * 111000 * 0.00001),
textcoords="offset points",
fontsize=label_fontsize,
color=station_color,
ha="center",
va="bottom",
zorder=6,
)
# ── 12. reference ellipse ─────────────────────────────────────────────
if ref_ellipse_:
pad = scale_ * 0.7
ref_lon = float(lons.min()) - pad
ref_lat = float(lats.min()) - pad
ref_w = 2.0 * scale_
ref_h = 2.0 * scale_
ref_e = Ellipse(
(ref_lon, ref_lat),
width=ref_w,
height=ref_h,
facecolor="none",
edgecolor=_es.ref_edgecolor,
linewidth=_es.ref_lw,
zorder=5,
)
ax.add_patch(ref_e)
ax.text(
ref_lon,
ref_lat - scale_ * 1.3,
"ref",
ha="center",
va="top",
fontsize=_es.ref_fontsize,
color=_es.ref_edgecolor,
)
# ── 13. colorbar ──────────────────────────────────────────────────────
if show_colorbar:
sm = plt.cm.ScalarMappable(cmap=cm_obj, norm=norm_c)
sm.set_array([])
cb_label = colorbar_label if colorbar_label is not None else c_by
add_colorbar(
sm, ax, label=cb_label, side="right", size="3.5%", pad=0.06
)
# ── 14. axes decoration ───────────────────────────────────────────────
from matplotlib.ticker import ScalarFormatter
pad_xy = scale_ * 2.2
ax.set_xlim(lons.min() - pad_xy, lons.max() + pad_xy)
ax.set_ylim(lats.min() - pad_xy, lats.max() + pad_xy)
# geographic aspect ratio: 1° lon = cos(lat) × 1° lat in physical distance
lat_mid = float(np.mean(lats))
ax.set_aspect(1.0 / max(np.cos(np.radians(lat_mid)), 1e-6))
ax.set_xlabel("Longitude", fontsize=9)
ax.set_ylabel("Latitude", fontsize=9)
ax.tick_params(labelsize=8)
# suppress matplotlib offset notation (e.g. "+1.1912e2")
for _axis in (ax.xaxis, ax.yaxis):
_fmt = ScalarFormatter(useOffset=False)
_fmt.set_scientific(False)
_axis.set_major_formatter(_fmt)
ax.grid(True, alpha=0.18, linewidth=0.5)
period_txt = (
f"{period * 1e3:.4g} ms" if period < 1.0 else f"{period:.4g} s"
)
ax.set_title(
title or f"Phase Tensor Map for {period_txt}",
fontsize=10,
pad=8,
fontweight="bold",
)
return ax
def _grid_station_lonlat(sites, coords):
"""Return ``{station: (lat, lon)}`` for a phase-tensor map grid.
Mirrors the coordinate resolution of :func:`plot_phase_tensor_map`
(``ed.coords`` then ``ed.meta``) but only for the shared ellipse-size
estimate; the per-panel call resolves coordinates itself.
"""
if coords is not None:
return {k: (float(v[0]), float(v[1])) for k, v in coords.items()}
out: dict[str, tuple[float, float]] = {}
for i, ed in enumerate(_iter_items(sites)):
st = _name(ed, i)
c = getattr(ed, "coords", None)
if (
c is not None
and len(c) >= 2
and c[0] is not None
and c[1] is not None
and np.isfinite(c[0])
and np.isfinite(c[1])
):
out[st] = (float(c[0]), float(c[1]))
continue
meta = getattr(ed, "meta", {}) or {}
lat = meta.get("lat") or meta.get("latitude")
lon = meta.get("lon") or meta.get("longitude") or meta.get("long")
if lat is not None and lon is not None:
try:
if np.isfinite(float(lat)) and np.isfinite(float(lon)):
out[st] = (float(lat), float(lon))
except (TypeError, ValueError):
pass
return out
_MAP_GRID_NCOLS = {1: 1, 2: 2, 3: 3, 4: 2, 5: 3, 6: 3}
[docs]
def plot_phase_tensor_map_grid(
sites: Any,
*,
frequencies: list[float] | None = None,
periods: list[float] | None = None,
n_cols: int | None = None,
panel_labels: bool | list[str] = True,
panel_titles: list[str] | None = None,
tidy_labels: bool = True,
label_by: str = "frequency",
share_scale: bool = True,
share_color: bool = True,
shared_colorbar: bool = True,
station_labels: str | bool = "first",
ref_ellipse: str | bool = "first",
bg_grid: dict[str, Any] | None = None,
topography: Any = None,
topography_cmap: str = "terrain",
topography_alpha: float = 0.55,
suptitle: str = "",
figsize: tuple[float, float] | None = None,
panel_size: tuple[float, float] = (4.6, 4.0),
# ── shared ellipse / colour style ────────────────────────────────
scale=_UNSET,
ellipse_scale: float = 1.0,
s1_ref=_UNSET,
normalise_by=_UNSET,
c_by=_UNSET,
abs_skew: bool = False,
cmap=_UNSET,
clim: tuple[float, float] | None = None,
clim_pct=_UNSET,
symmetric_clim=_UNSET,
skew_threshold=_UNSET,
colorbar_label: str | None = None,
# ── core ────────────────────────────────────────────────────────
coords: dict[str, tuple[float, float]] | None = None,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
axes: Any | None = None,
**map_kwargs: Any,
) -> plt.Figure:
"""Multi-frequency phase-tensor map — one panel per frequency.
Tiles :func:`plot_phase_tensor_map` across a list of frequencies (or
periods) in a subplot grid, with a **single shared colorbar** and,
by default, a shared ellipse-size reference and colour scale so the
panels are directly comparable. This reproduces the classic
"phase-tensor ellipses filled with skew, induction arrows in
Parkinson convention, at 30 / 3 / 0.3 / 0.03 Hz" figure.
Any number of panels is accepted (1, 2, 3, 4, …); the layout adapts.
Parameters
----------
sites : any
EDI paths, objects, or a collection accepted by
:func:`~pycsamt.emtools._core.ensure_sites`.
frequencies : list of float, optional
Panel frequencies in **hertz**, e.g. ``[30, 3, 0.3, 0.03]``.
Exactly one of *frequencies* / *periods* must be given.
periods : list of float, optional
Panel periods in **seconds**. Alternative to *frequencies*.
n_cols : int, optional
Columns in the panel grid. Default: 1→1, 2→2, 3→3, 4→2×2,
then 3 per row.
panel_labels : bool or list of str, default True
``True`` stamps ``"(a)"``, ``"(b)"`` … in each panel corner; a
list sets the text explicitly; ``False`` disables it.
panel_titles : list of str, optional
Per-panel titles. Default: ``"30 Hz"`` … (or ``"0.033 s"`` …
when ``label_by="period"``).
tidy_labels : bool, default True
Drop the x-axis label/ticks on non-bottom panels and the y-axis
label/ticks on non-left panels (every panel shares the same
extent), which tightens the grid.
label_by : {"frequency", "period"}, default "frequency"
Units for the default panel titles.
share_scale : bool, default True
Use one ellipse-size reference for every panel (ellipse areas
become comparable across frequencies).
share_color : bool, default True
Use one colour scale for every panel. Ignored when *clim* is
given (that is always shared).
shared_colorbar : bool, default True
Draw a single figure-level colorbar instead of one per panel.
station_labels : {"first", "all", "none"} or bool, default "first"
Which panels annotate station names.
ref_ellipse : {"first", "all", "none"} or bool, default "first"
Which panels draw the reference-circle scale bar.
bg_grid : dict, optional
Explicit background field (gravity, resistivity, …) drawn behind
every panel; see :func:`plot_phase_tensor_map`. Takes priority
over *topography*.
topography : bool | str | dict | None, default None
Convenience elevation background shared by all panels. ``True``
interpolates the station elevations from the EDI headers; a CSV
path supplies ``lon,lat,elev`` or ``station,elev`` rows; a dict
is used as *bg_grid*. A single horizontal "Elevation (m)"
colorbar is added.
topography_cmap : str, default ``"terrain"``
topography_alpha : float, default 0.55
Colormap / opacity for the *topography* background.
suptitle : str, optional
Figure-level title.
figsize : tuple of float, optional
Overrides ``panel_size``-derived sizing.
panel_size : tuple of float, default (4.6, 4.0)
Per-panel ``(width, height)`` in inches.
ellipse_scale : float, default 1.0
Multiplier on the ellipse size for every panel — a quick
"bigger/smaller ellipses" knob (``1.5`` = 50 % larger).
abs_skew : bool, default False
Colour by the **absolute** skew ``|β|`` (sequential) instead of
signed ``β`` (diverging). Shorthand for ``c_by="|beta|"``.
scale, s1_ref, normalise_by, c_by, cmap, clim, clim_pct, \
symmetric_clim, skew_threshold, colorbar_label :
Ellipse-style controls forwarded to every panel; see
:func:`plot_phase_tensor_map`. When *share_scale* /
*share_color* is on and the value is left unset, a single shared
value is derived from the pooled data of every panel.
coords : dict[str, (lat, lon)], optional
Explicit station coordinates, forwarded to every panel.
recursive, on_dup, strict, verbose
Forwarded to :func:`~pycsamt.emtools._core.ensure_sites`.
axes : array of matplotlib.axes.Axes, optional
Pre-existing axes grid (flattened length ≥ number of panels).
**map_kwargs
Any other keyword (``show_tipper``, ``tipper_convention``,
``tipper_component``, ``tipper_scale``, ``tipper_color``,
``tipper_lw``, ``station_marker``, ``alpha``, ``edgecolor`` …)
is passed straight through to every
:func:`plot_phase_tensor_map` call.
Returns
-------
matplotlib.figure.Figure
Raises
------
ValueError
If neither or both of *frequencies* / *periods* are given, or the
list is empty.
Examples
--------
Broken Hill Fig. 3 style — signed skew β on the MTPy-style
``pt_skew`` colormap, Parkinson arrows:
>>> from pycsamt.emtools import plot_phase_tensor_map_grid
>>> fig = plot_phase_tensor_map_grid(
... "data/MT/broken-hill/edis",
... frequencies=[30, 3, 0.3, 0.03],
... c_by="skew", # signed β; auto "pt_skew" cmap
... tipper_convention="parkinson",
... suptitle="Broken Hill — phase tensor + induction arrows",
... )
Two panels only:
>>> fig = plot_phase_tensor_map_grid(sites, frequencies=[10, 0.1])
See Also
--------
plot_phase_tensor_map : Single-frequency phase-tensor map.
plot_phase_tensor_strip_grid : Ellipse strips tiled by profile.
"""
if (frequencies is None) == (periods is None):
raise ValueError(
"pass exactly one of frequencies= (Hz) or periods= (s)."
)
if periods is not None:
target_p = [float(p) for p in periods]
freqs = [1.0 / p if p else np.inf for p in target_p]
else:
freqs = [float(f) for f in frequencies]
target_p = [1.0 / f if f else np.inf for f in freqs]
n = len(target_p)
if n == 0:
raise ValueError("frequencies/periods list is empty.")
_es = PYCSAMT_STYLE.pt_ellipse
def _sv(v, attr):
return getattr(_es, attr) if v is _UNSET else v
c_by_ = _sv(c_by, "c_by")
if abs_skew and c_by_ in ("skew", "beta"):
c_by_ = "|beta|"
cmap_ = cmap if cmap is not _UNSET else None
if cmap_ is None:
cmap_ = _es.copy(c_by=c_by_).resolve_cmap()
clim_pct_ = _sv(clim_pct, "clim_pct")
symmetric_clim_ = symmetric_clim
if symmetric_clim_ is _UNSET:
symmetric_clim_ = _es.copy(c_by=c_by_).resolve_symmetric_clim()
skew_threshold_ = _sv(skew_threshold, "skew_threshold")
normalise_by_ = _sv(normalise_by, "normalise_by")
# ── load once ───────────────────────────────────────────────────
S = ensure_sites(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
df = build_phase_tensor_table(
S, recursive=False, on_dup=on_dup, strict=False, verbose=verbose
)
# ── rows nearest to each target period, per station ─────────────
sub_rows: list[Any] = []
if not df.empty:
for _, sdf in df.groupby("station"):
p = sdf["period"].to_numpy(float)
for tp in target_p:
sub_rows.append(sdf.iloc[int(np.nanargmin(np.abs(p - tp)))])
df_sub = pd.DataFrame(sub_rows) if sub_rows else df
# ── shared colour limits ────────────────────────────────────────
if clim is not None:
clim_shared: tuple[float, float] = (
float(clim[0]),
float(clim[1]),
)
cbar_label = colorbar_label or _resolve_cvals(df_sub, c_by_)[1]
elif share_color and not df_sub.empty:
cvals, cbar_label = _resolve_cvals(df_sub, c_by_)
finite = cvals[np.isfinite(cvals)]
if finite.size:
lo = float(np.nanpercentile(finite, clim_pct_[0]))
hi = float(np.nanpercentile(finite, clim_pct_[1]))
if symmetric_clim_ and c_by_ in _SYMMETRIC_C:
vlim = max(abs(lo), abs(hi))
# keep at least ±skew_threshold so a near-1-D survey does
# not saturate the whole diverging map at one hue
if skew_threshold_ and c_by_ in ("skew", "beta"):
vlim = max(vlim, float(skew_threshold_))
lo, hi = -vlim, vlim
clim_shared = (lo, hi if hi != lo else lo + 1.0)
else:
clim_shared = (-1.0, 1.0)
if colorbar_label:
cbar_label = colorbar_label
else:
clim_shared = None # each panel picks its own
cbar_label = colorbar_label or c_by_
# ── shared geographic scale ────────────────────────────────────
# Only the geographic ellipse/arrow *scale* is shared: each panel
# still normalises ellipse sizes to its own phase distribution
# (``s1_ref`` left unset), otherwise the small high-frequency phase
# tensors would collapse next to the large low-frequency ones. Pass
# an explicit ``s1_ref`` to compare magnitudes across frequency.
scale_arg = scale
s1_ref_arg = s1_ref
if share_scale and scale is _UNSET:
ll = _grid_station_lonlat(S, coords)
if len(ll) > 1:
lats = np.array([v[0] for v in ll.values()], float)
lons = np.array([v[1] for v in ll.values()], float)
u_lon = np.unique(np.round(lons, 3))
u_lat = np.unique(np.round(lats, 3))
d_lon = (
float(np.median(np.diff(u_lon)))
if len(u_lon) > 1
else float(np.ptp(lons) or 0.01)
)
d_lat = (
float(np.median(np.diff(u_lat)))
if len(u_lat) > 1
else float(np.ptp(lats) or 0.01)
)
scale_arg = 0.68 * max(abs(d_lon) + 1e-9, abs(d_lat) + 1e-9)
# ── shared topography background (built once, drawn on every panel)
topo_bg: dict[str, Any] | None = None
if (
bg_grid is None
and topography is not None
and topography is not False
):
topo_bg = _topography_bg_grid(
topography,
S,
_grid_station_lonlat(S, coords),
cmap=topography_cmap,
alpha=topography_alpha,
)
panel_bg = bg_grid
if panel_bg is None and topo_bg is not None:
panel_bg = dict(topo_bg)
panel_bg["label"] = "" # one shared elevation bar instead
# ── layout ─────────────────────────────────────────────────────
if n_cols is None:
n_cols = _MAP_GRID_NCOLS.get(n, 3)
n_cols = max(1, min(int(n_cols), n))
n_rows = int(np.ceil(n / n_cols))
if axes is None:
# Each panel is drawn with a latitude-corrected aspect, so match
# the subplot slot to that box (width/height) — otherwise the
# fixed-aspect axes leave wide gutters between columns.
box_wh = 1.0
_ll = _grid_station_lonlat(S, coords)
if len(_ll) > 1:
_la = np.array([v[0] for v in _ll.values()], float)
_lo = np.array([v[1] for v in _ll.values()], float)
_dlo = float(np.ptp(_lo)) or 1.0
_dla = float(np.ptp(_la)) or 1.0
box_wh = float(
np.clip(
_dlo * np.cos(np.radians(_la.mean())) / _dla,
0.45,
2.2,
)
)
panel_h = float(panel_size[1])
panel_w = panel_h * box_wh + 0.85 # + tick labels / y-axis title
fs = figsize or (
panel_w * n_cols + (0.95 if shared_colorbar else 0.15),
panel_h * n_rows + (0.55 if suptitle else 0.2),
)
fig, axgrid = plt.subplots(
n_rows, n_cols, figsize=fs, squeeze=False,
constrained_layout=True,
)
try:
fig.set_constrained_layout_pads(
w_pad=0.015, h_pad=0.02, wspace=0.015, hspace=0.03
)
except Exception: # noqa: BLE001 — older matplotlib
pass
else:
axgrid = np.atleast_2d(axes)
fig = axgrid.flat[0].figure
flat = list(np.asarray(axgrid).ravel())
def _panel_on(spec: str | bool, k: int) -> bool:
if isinstance(spec, bool):
return spec
spec = str(spec).lower()
if spec in ("all", "every"):
return True
if spec in ("none", "off", "never"):
return False
return k == 0 # "first"
for k, tp in enumerate(target_p):
ax = flat[k]
if panel_titles is not None and k < len(panel_titles):
ptitle = panel_titles[k]
elif label_by == "period":
ptitle = (
f"{tp * 1e3:.4g} ms" if tp < 1.0 else f"{tp:.4g} s"
)
else:
fk = freqs[k]
ptitle = f"{fk:g} Hz" if np.isfinite(fk) else "0 Hz"
plot_phase_tensor_map(
S,
period=tp,
ax=ax,
show_colorbar=False,
c_by=c_by_,
cmap=cmap_,
clim=clim_shared,
clim_pct=clim_pct_,
symmetric_clim=symmetric_clim_,
skew_threshold=skew_threshold_,
scale=scale_arg,
ellipse_scale=ellipse_scale,
s1_ref=s1_ref_arg,
normalise_by=normalise_by_,
bg_grid=panel_bg,
coords=coords,
station_labels=_panel_on(station_labels, k),
ref_ellipse=_panel_on(ref_ellipse, k),
title=ptitle,
recursive=False,
on_dup=on_dup,
strict=strict,
verbose=verbose,
**map_kwargs,
)
ax.locator_params(axis="x", nbins=4)
ax.locator_params(axis="y", nbins=5)
if tidy_labels and axes is None:
row, col = divmod(k, n_cols)
last_row_start = (n_rows - 1) * n_cols
if k < last_row_start and (k + n_cols) < n:
ax.set_xlabel("")
ax.tick_params(labelbottom=False)
if col != 0:
ax.set_ylabel("")
ax.tick_params(labelleft=False)
if panel_labels is not False:
lbl = (
panel_labels[k]
if isinstance(panel_labels, (list, tuple))
and k < len(panel_labels)
else f"({chr(97 + k)})"
)
ax.text(
0.02,
0.97,
lbl,
transform=ax.transAxes,
ha="left",
va="top",
fontsize=11,
fontweight="bold",
bbox={
"boxstyle": "square,pad=0.15",
"fc": "white",
"ec": "none",
"alpha": 0.75,
},
)
for k in range(n, len(flat)):
flat[k].set_visible(False)
if shared_colorbar and clim_shared is not None:
sm = ScalarMappable(
cmap=plt.get_cmap(cmap_),
norm=Normalize(vmin=clim_shared[0], vmax=clim_shared[1]),
)
sm.set_array([])
cb = fig.colorbar(
sm,
ax=flat[:n],
fraction=0.018,
pad=0.006,
shrink=0.92,
aspect=32,
)
cb.set_label(cbar_label, fontsize=9)
cb.ax.tick_params(labelsize=8)
if shared_colorbar and topo_bg is not None:
tv = np.asarray(topo_bg["values"], float)
tv = tv[np.isfinite(tv)]
if tv.size:
sm2 = ScalarMappable(
cmap=plt.get_cmap(topo_bg.get("cmap", topography_cmap)),
norm=Normalize(vmin=float(tv.min()), vmax=float(tv.max())),
)
sm2.set_array([])
cb2 = fig.colorbar(
sm2,
ax=flat[:n],
orientation="horizontal",
fraction=0.03,
shrink=0.55,
pad=0.04,
aspect=45,
)
cb2.set_label(topo_bg.get("label") or "Elevation (m)", fontsize=9)
cb2.ax.tick_params(labelsize=8)
if suptitle:
fig.suptitle(suptitle, fontsize=12)
return fig
[docs]
def plot_phase_tensor_summary(
sites: Any,
*,
stations: list[str] | None = None,
period_range: tuple[float, float] | None = None,
scale=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.scale
c_by=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.c_by
cmap=_UNSET, # default: auto from c_by via resolve_cmap()
clim: tuple[float, float] | None = None,
skew_threshold=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.skew_threshold
ellipt_threshold: float = 0.2,
axes=None,
figsize: tuple[float, float] = (12.0, 9.0),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> plt.Figure:
"""
Three-panel phase-tensor summary figure.
Panels
------
**(a)** Ellipse pseudo-section — the full :func:`plot_phase_tensor_psection`
with all cell-size normalisations.
**(b)** Dimensionality classification grid — 1-D (white), 2-D (grey),
3-D (red), derived from *skew_threshold* and *ellipt_threshold*.
**(c)** Skew–ellipticity density hexbin — joint distribution over all
``(station, period)`` cells, with contours at 20 / 50 / 80 % of the
peak density.
Parameters
----------
sites : any
Passed directly to :func:`build_phase_tensor_table`.
stations : list of str or None
Restrict to a station subset (applied to all panels).
period_range : (T_min, T_max) or None
Period window in seconds (applied to all panels).
scale : float, default ``0.85``
Passed to panel (a) — fraction of each cell to fill.
c_by : str, default ``"skew"``
Colour quantity for panel (a); see :func:`plot_phase_tensor_psection`.
cmap : str, default ``"RdBu_r"``
Colourmap for panel (a).
clim : (vmin, vmax) or None
Explicit colour limits for panel (a).
skew_threshold : float, default ``3.0``
|β| threshold (°) for 1-D/2-D vs 3-D in panels (a) and (b).
ellipt_threshold : float, default ``0.2``
Ellipticity threshold for 1-D vs 2-D in panel (b).
figsize : (width, height), default ``(12.0, 9.0)``
recursive, on_dup, strict, verbose : see :func:`ensure_sites`.
Returns
-------
fig : :class:`~matplotlib.figure.Figure`
"""
# ── resolve visual style from PYCSAMT_STYLE.pt_ellipse ──────────────
_es = PYCSAMT_STYLE.pt_ellipse
if scale is _UNSET:
scale = _es.scale
if c_by is _UNSET:
c_by = _es.c_by
if cmap is _UNSET:
cmap = _es.copy(c_by=c_by).resolve_cmap()
if skew_threshold is _UNSET:
skew_threshold = _es.skew_threshold
df = build_phase_tensor_table(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
# Apply filters once; pass the already-filtered data to sub-functions
if not df.empty and stations is not None:
df = df[df["station"].isin(stations)]
if not df.empty and period_range is not None:
lo, hi = float(period_range[0]), float(period_range[1])
df = df[(df["period"] >= lo) & (df["period"] <= hi)]
# ── layout: 2 rows × 2 cols; row-0 spans both columns ────────────────
axes_given = _axes_list(axes, 3) if axes is not None else None
if axes_given is None:
import matplotlib.gridspec as gridspec
fig = plt.figure(figsize=figsize)
gs = gridspec.GridSpec(
2,
2,
figure=fig,
height_ratios=[1.5, 1.0],
hspace=0.38,
wspace=0.35,
)
ax_ell = fig.add_subplot(gs[0, :]) # (a) full-width ellipse section
ax_dim = fig.add_subplot(gs[1, 0]) # (b) dimensionality grid
ax_den = fig.add_subplot(gs[1, 1]) # (c) skew-ellipticity density
else:
ax_ell, ax_dim, ax_den = axes_given
fig = ax_ell.figure
# ── panel (a): ellipse pseudo-section ────────────────────────────────
if df.empty:
ax_ell.text(
0.5,
0.5,
"no phase tensor data",
ha="center",
va="center",
transform=ax_ell.transAxes,
)
else:
plot_phase_tensor_psection(
df, # pass pre-filtered DataFrame directly
scale=scale,
c_by=c_by,
cmap=cmap,
clim=clim,
skew_threshold=skew_threshold,
mark_3d=True,
ref_ellipse=True,
title="(a) Phase-tensor ellipse pseudo-section",
recursive=False,
ax=ax_ell,
)
# ── panel (b): dimensionality grid ────────────────────────────────────
if df.empty:
ax_dim.text(
0.5,
0.5,
"no data",
ha="center",
va="center",
transform=ax_dim.transAxes,
)
else:
_draw_dim_grid(
df,
ax_dim,
skew_threshold=skew_threshold,
ellipt_threshold=ellipt_threshold,
)
ax_dim.set_title("(b) Dimensionality (1-D/2-D/3-D)", fontsize=9)
# ── panel (c): skew–ellipticity density ───────────────────────────────
if df.empty:
ax_den.text(
0.5,
0.5,
"no data",
ha="center",
va="center",
transform=ax_den.transAxes,
)
else:
_draw_skew_ellipt_density(
df,
ax_den,
skew_threshold=skew_threshold,
ellipt_threshold=ellipt_threshold,
)
ax_den.set_title("(c) Skew–ellipticity distribution", fontsize=9)
return fig
def _draw_dim_grid(
df: pd.DataFrame,
ax: plt.Axes,
*,
skew_threshold: float = 3.0,
ellipt_threshold: float = 0.2,
) -> None:
"""Internal helper: draw the dimensionality heatmap onto *ax*."""
df = df.copy()
df["_logp"] = np.log10(df["period"].to_numpy(float))
a = np.abs(df["skew"].to_numpy(float))
e = np.abs(df["ellipt"].to_numpy(float))
lab = np.full(len(df), 2, dtype=int) # default: 3-D
lab[(a <= skew_threshold) & (e <= ellipt_threshold)] = 0 # 1-D
lab[(a <= skew_threshold) & (e > ellipt_threshold)] = 1 # 2-D
df["_dim"] = lab
st_list = list(dict.fromkeys(df["station"].tolist()))
{s: i for i, s in enumerate(st_list)}
piv = df.pivot_table(
index="_logp",
columns="station",
values="_dim",
aggfunc="median",
)
piv = piv.reindex(columns=st_list).sort_index()
# piv has shape (n_logp, n_stations); do NOT transpose so that
# imshow maps x → stations and y → log-periods.
Z = piv.to_numpy(dtype=float) # (n_logp, n_st)
cmap_d = mcolors.ListedColormap(["#f7f7f7", "#969696", "#d6604d"])
bounds = [-0.5, 0.5, 1.5, 2.5]
norm_d = mcolors.BoundaryNorm(bounds, cmap_d.N)
ax.imshow(
Z,
aspect="auto",
origin="lower",
interpolation="nearest",
cmap=cmap_d,
norm=norm_d,
)
ax.set_ylabel(LOG10_PERIOD_LABEL, fontsize=8)
n_st = len(st_list)
PYCSAMT_STATION_RENDERING.apply(
ax,
np.arange(n_st, dtype=float),
st_list,
preset="pseudosection",
xlim=(-0.5, n_st - 0.5),
)
n_logp = Z.shape[0] # shape[0] = n_logp now that .T is removed
y_ticks = np.linspace(0, n_logp - 1, num=min(6, n_logp))
y_vals = np.linspace(piv.index.min(), piv.index.max(), num=min(6, n_logp))
ax.set_yticks(y_ticks)
ax.set_yticklabels([f"{v:.1f}" for v in y_vals], fontsize=7)
if not ax.yaxis_inverted():
ax.invert_yaxis()
from matplotlib.patches import Patch
ax.legend(
handles=[
Patch(fc="#f7f7f7", ec="k", lw=0.5, label="1-D"),
Patch(fc="#969696", ec="k", lw=0.5, label="2-D"),
Patch(fc="#d6604d", ec="k", lw=0.5, label="3-D"),
],
fontsize=7,
framealpha=0.9,
loc="lower right",
title=f"|β|≤{skew_threshold:.0f}°,ε≤{ellipt_threshold:.2f}",
title_fontsize=6,
)
def _draw_skew_ellipt_density(
df: pd.DataFrame,
ax: plt.Axes,
*,
skew_threshold: float = 3.0,
ellipt_threshold: float = 0.2,
gridsize: int = 30,
) -> None:
"""Internal helper: draw |β|–ellipticity hexbin density onto *ax*."""
a = np.abs(df["skew"].to_numpy(float))
e = np.abs(df["ellipt"].to_numpy(float))
fin = np.isfinite(a) & np.isfinite(e)
if not fin.any():
ax.text(
0.5,
0.5,
"no data",
ha="center",
va="center",
transform=ax.transAxes,
)
return
ax.hexbin(
a[fin],
e[fin],
gridsize=gridsize,
mincnt=1,
cmap="YlOrRd",
linewidths=0.0,
)
# contour at 20 / 50 / 80 % of peak density
nx = ny = gridsize
H, xe, ye = np.histogram2d(a[fin], e[fin], bins=[nx, ny])
if H.max() > 0:
Xc = 0.5 * (xe[1:] + xe[:-1])
Yc = 0.5 * (ye[1:] + ye[:-1])
levs = [H.max() * f for f in (0.20, 0.50, 0.80)]
ax.contour(Xc, Yc, H.T, levels=levs, colors="k", linewidths=0.6)
# threshold lines
ax.axvline(
skew_threshold,
color="#b2182b",
lw=1.0,
ls="--",
label=f"|β|={skew_threshold:.0f}°",
)
ax.axhline(
ellipt_threshold,
color="#2166ac",
lw=1.0,
ls="--",
label=f"ε={ellipt_threshold:.2f}",
)
ax.set_xlabel("|β| (°)", fontsize=8)
ax.set_ylabel("Ellipticity", fontsize=8)
ax.legend(fontsize=7, framealpha=0.9)
[docs]
def phase_tensor_legend(
*,
size: float = 1.0,
ellipt: float = 0.45,
theta_deg: float = 20.0,
figsize: tuple[float, float] = (2.8, 2.8),
ax: plt.Axes | None = None,
) -> plt.Axes:
r"""Draw a labeled reference phase-tensor ellipse.
Explains the ellipse convention shared by every phase-tensor plot in
this module -- :func:`plot_phase_tensor_psection`,
:func:`plot_phase_tensor_map`, :func:`plot_phase_tensor_strip`, and
friends -- by drawing one annotated example: a major axis
:math:`\phi_{\max}`, a minor axis :math:`\phi_{\min}`, and an
orientation angle ``theta`` measured counterclockwise from the
horizontal (dashed reference line), matching the ``width``,
``height``, ``angle`` convention Matplotlib's own ``Ellipse`` patch
uses.
Parameters
----------
size : float, default 1.0
Semi-major axis length (:math:`\phi_{\max}`) of the example
ellipse, in axis data units.
ellipt : float, default 0.45
Example ellipticity used only to make the minor axis visibly
shorter than the major axis; :math:`\phi_{\min} = \text{size}
\times (1-\text{ellipt})`. Purely illustrative -- it does not
come from real data.
theta_deg : float, default 20.0
Example orientation angle, in degrees, used to draw the
rotated ellipse and the ``theta`` arc.
figsize : (float, float), default (2.8, 2.8)
Figure size when *ax* is not supplied.
ax : matplotlib.axes.Axes or None
Axes to draw on; created if *None*.
Returns
-------
matplotlib.axes.Axes
"""
if ax is None:
_, ax = plt.subplots(figsize=figsize)
phi_max = float(size)
phi_min = phi_max * max(0.05, 1.0 - float(ellipt))
theta = np.radians(float(theta_deg))
ax.add_patch(
Ellipse(
(0.0, 0.0),
width=2.0 * phi_max,
height=2.0 * phi_min,
angle=float(theta_deg),
fill=False,
lw=1.4,
edgecolor="crimson",
)
)
major = np.array([np.cos(theta), np.sin(theta)]) * phi_max
minor = np.array([-np.sin(theta), np.cos(theta)]) * phi_min
ax.plot([-major[0], major[0]], [-major[1], major[1]], "-", lw=1.0, color="0.15")
ax.plot([-minor[0], minor[0]], [-minor[1], minor[1]], "-", lw=1.0, color="0.15")
label_pad = 1.2
ax.annotate(
r"$\phi_{\max}$",
xy=tuple(major * label_pad),
ha="center",
va="center",
fontsize=9,
)
ax.annotate(
r"$\phi_{\min}$",
xy=tuple(minor * label_pad),
ha="center",
va="center",
fontsize=9,
)
ax.plot([0.0, phi_max * 1.05], [0.0, 0.0], "--", lw=0.7, color="0.6")
arc_r = 0.4 * phi_max
arc = np.linspace(0.0, theta, 30)
ax.plot(arc_r * np.cos(arc), arc_r * np.sin(arc), "-", lw=0.8, color="0.4")
ax.annotate(
r"$\theta$",
xy=(
arc_r * 1.4 * np.cos(theta / 2.0),
arc_r * 1.4 * np.sin(theta / 2.0),
),
ha="center",
va="center",
fontsize=9,
color="0.3",
)
lim = phi_max * 1.6
ax.set_xlim(-lim, lim)
ax.set_ylim(-lim, lim)
ax.set_aspect("equal", adjustable="box")
ax.axis("off")
return ax
# -------------------- small utilities (local) --------------------------- #
def _rho_det_from_z(z: np.ndarray, fr: np.ndarray) -> np.ndarray:
zx = z[:, 0, 1]
zy = z[:, 1, 0]
rx = 0.2 * (np.abs(zx) ** 2) / (fr + 1e-24)
ry = 0.2 * (np.abs(zy) ** 2) / (fr + 1e-24)
rdet = np.sqrt(rx * ry)
return rdet
def _phi_det_from_z(z: np.ndarray) -> np.ndarray:
d = z[:, 0, 0] * z[:, 1, 1] - z[:, 0, 1] * z[:, 1, 0]
return np.degrees(np.angle(d))
def _attach_det_metrics(
df: pd.DataFrame,
S,
) -> pd.DataFrame:
if df.empty:
return df
df = df.copy()
df["rho_det"] = np.nan
df["phi_det"] = np.nan
for i, ed in enumerate(_iter_items(S)):
st = _name(ed, i)
Z, z, fr = _get_z_block(ed)
if Z is None:
continue
rho = _rho_det_from_z(z, fr)
phi = _phi_det_from_z(z)
per = 1.0 / fr
m = df["station"] == st
if not m.any():
continue
p = df.loc[m, "period"].to_numpy()
idx = np.searchsorted(per, p)
idx = np.clip(idx, 0, len(per) - 1)
df.loc[m, "rho_det"] = rho[idx]
df.loc[m, "phi_det"] = phi[idx]
return df
def _color_from_hsv(h: np.ndarray, s: np.ndarray, v: np.ndarray) -> np.ndarray:
hsv = np.stack([h, s, v], axis=-1)
rgb = mcolors.hsv_to_rgb(hsv)
return rgb
# ---------------- 2) dimensionality grid (1D/2D/3D) --------------------- #
[docs]
def plot_dimensionality_grid(
sites: Any,
*,
skew_th: float = 3.0,
ellipt_th: float = 0.2,
figsize: tuple[float, float] = (8.5, 4.0),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
df = build_phase_tensor_table(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
if df.empty:
if ax is None:
_, ax = plt.subplots(figsize=figsize)
ax.text(0.5, 0.5, "no phase tensor", ha="center", va="center")
return ax
df = df.copy()
df["logp"] = np.log10(df["period"].to_numpy())
lab = np.zeros(len(df), dtype=int)
a = np.abs(df["skew"].to_numpy())
e = np.abs(df["ellipt"].to_numpy())
lab[:] = 2
lab[(a <= skew_th) & (e <= ellipt_th)] = 0
lab[(a <= skew_th) & (e > ellipt_th)] = 1
df["dim"] = lab
piv = df.pivot_table(
index="logp",
columns="station",
values="dim",
aggfunc="median",
)
piv = piv.sort_index()
Z = piv.to_numpy(dtype=float) # (n_logp, n_st) — no transpose
if ax is None:
_, ax = plt.subplots(figsize=figsize)
im = ax.imshow(
Z,
aspect="auto",
origin="lower",
interpolation="nearest",
)
ax.set_ylabel(LOG10_PERIOD_LABEL)
PYCSAMT_STATION_RENDERING.apply(
ax,
np.arange(len(piv.columns), dtype=float),
list(piv.columns),
preset="pseudosection",
xlim=(-0.5, len(piv.columns) - 0.5),
)
yt = np.linspace(
0, Z.shape[0] - 1, num=min(8, Z.shape[0])
) # shape[0] = n_logp
yv = np.linspace(
piv.index.min(), piv.index.max(), num=min(8, len(piv.index))
)
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("dim (0=1D,1=2D,2=3D)")
return ax
# ---------------- 3) theta stability stripe (HSV image) ----------------- #
[docs]
def plot_theta_stability_stripe(
sites: Any,
*,
win: int = 5,
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:
df = build_phase_tensor_table(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
if df.empty:
if ax is None:
_, ax = plt.subplots(figsize=figsize)
ax.text(0.5, 0.5, "no phase tensor", ha="center", va="center")
return ax
df = df.copy()
df["logp"] = np.log10(df["period"].to_numpy())
sts = list(df["station"].unique())
X = []
H = []
for st in sts:
s = df[df["station"] == st].sort_values("logp")
th = s["theta"].to_numpy()
lp = s["logp"].to_numpy()
# hue from theta in [0,180) → [0,1)
h = (th % 180.0) / 180.0
# variance in sliding window
k = max(3, int(win))
if len(h) < k:
v = np.full_like(h, np.nan)
else:
v = np.convolve(
(h - np.nanmean(h)) ** 2,
np.ones(k) / k,
mode="same",
)
# saturation from 1 - norm(var)
v0 = (
np.nanpercentile(v[np.isfinite(v)], 5)
if np.isfinite(v).any()
else 0.0
)
v1 = (
np.nanpercentile(v[np.isfinite(v)], 95)
if np.isfinite(v).any()
else 1.0
)
s_sat = 1.0 - np.clip((v - v0) / (v1 - v0 + 1e-12), 0, 1)
H.append(np.vstack([h, s_sat, np.ones_like(h)]))
X.append(lp)
# align to common y-grid for imshow
yall = np.unique(np.concatenate(X))
Himg = np.zeros((len(yall), len(sts), 3))
for j, (lp, hs) in enumerate(zip(X, H)):
i = np.searchsorted(yall, lp)
i = np.clip(i, 0, len(yall) - 1)
h, s, v = hs
rgb = _color_from_hsv(h, s, v)
for r, k in enumerate(i):
Himg[k, j, :] = rgb[r]
if ax is None:
_, ax = plt.subplots(figsize=figsize)
ax.imshow(
Himg,
aspect="auto",
origin="lower",
interpolation="nearest",
)
ax.set_ylabel(LOG10_PERIOD_LABEL)
PYCSAMT_STATION_RENDERING.apply(
ax,
np.arange(len(sts)),
sts,
preset="pseudosection",
xlim=(-0.5, len(sts) - 0.5),
)
if not ax.yaxis_inverted():
ax.invert_yaxis()
yt = np.linspace(0, len(yall) - 1, num=min(8, len(yall)))
yv = np.linspace(yall.min(), yall.max(), num=min(8, len(yall)))
ax.set_yticks(yt)
ax.set_yticklabels([f"{v:.2g}" for v in yv])
return ax
# ---------------- 4) skew–ellipticity density (hexbin + contours) ------- #
[docs]
def plot_skew_ellipt_density(
sites: Any,
*,
band: tuple[float, float] | None = None,
gridsize: int = 40,
figsize: tuple[float, float] = (6.5, 5.5),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
df = build_phase_tensor_table(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
if df.empty:
if ax is None:
_, ax = plt.subplots(figsize=figsize)
ax.text(0.5, 0.5, "no phase tensor", ha="center", va="center")
return ax
a = np.abs(df["beta"].to_numpy())
e = np.abs(df["ellipt"].to_numpy())
if band is not None:
lo, hi = band
m = (df["period"] >= lo) & (df["period"] <= hi)
a = a[m]
e = e[m]
if ax is None:
_, ax = plt.subplots(figsize=figsize)
ax.hexbin(a, e, gridsize=gridsize, mincnt=1, linewidths=0.0)
ax.set_xlabel("|beta| (deg)")
ax.set_ylabel("|ellipticity|")
# contours from 2D hist
nx = ny = gridsize
H, xedges, yedges = np.histogram2d(a, e, bins=[nx, ny])
Xc = 0.5 * (xedges[1:] + xedges[:-1])
Yc = 0.5 * (yedges[1:] + yedges[:-1])
lev = np.linspace(H.max() * 0.2, H.max() * 0.9, 4)
ax.contour(Xc, Yc, H.T, levels=lev, colors="k", linewidths=0.6)
return ax
# ---------------- 5) theta rose per band (small multiples) -------------- #
[docs]
def plot_theta_rose_grid(
sites: Any,
*,
n_bands: int = 6,
axes=None,
figsize: tuple[float, float] = (13.0, 3.8),
bins: int = 24,
style: str | RoseStyle | None = "pycsamt",
panel_title_fontsize: float = 7.5,
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
) -> plt.Figure:
"""Phase-tensor θ rose grid — one pycsamt-styled rose per frequency decade.
Draws *n_bands* polar rose diagrams side by side, each covering one
equal-log-width period band. Each rose applies axial symmetry (0–180°
mirrored to 180–360°) and is rendered using the active
:class:`~pycsamt.api._rose_style.RoseStyle`.
Parameters
----------
sites : any
Input accepted by :func:`~pycsamt.emtools._core.ensure_sites`.
n_bands : int, default ``6``
Number of equal-log-width period bands.
figsize : (width, height), default ``(13.0, 3.8)``
Figure size in inches. Constrained layout is used internally so
an external ``fig.suptitle`` fits without producing blank space.
bins : int, default ``24``
Number of bins over 0–180° (mirrored to 360°).
style : str, :class:`~pycsamt.api._rose_style.RoseStyle`, or None
Rose visual style. Strings resolved via
:func:`~pycsamt.api._rose_style.resolve_rose_style`.
panel_title_fontsize : float, default ``7.5``
Font size for the period-band label above each panel.
recursive, on_dup, strict, verbose
Passed to :func:`~pycsamt.emtools._core.ensure_sites`.
Returns
-------
:class:`matplotlib.figure.Figure`
"""
# ── resolve rose style ────────────────────────────────────────────────
rs = resolve_rose_style(style)
# ── axial mean helper ─────────────────────────────────────────────────
def _axial_mean(a_deg: np.ndarray) -> float:
rad = np.radians(2.0 * a_deg)
mu = (
np.degrees(
np.arctan2(np.nanmean(np.sin(rad)), np.nanmean(np.cos(rad)))
)
/ 2.0
)
return float(mu % 180.0)
df = build_phase_tensor_table(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
axes_given = _axes_list(axes, 1) if axes is not None else None
if df.empty:
if axes_given is None:
fig = plt.figure(figsize=figsize)
ax = fig.add_subplot(111, polar=True)
else:
ax = axes_given[0]
fig = ax.figure
ax.text(0.5, 0.5, "no phase tensor", ha="center", va="center")
return fig
p = df["period"].to_numpy()
lo_g = float(np.nanmin(p[p > 0])) if np.any(p > 0) else 1e-6
hi_g = float(np.nanmax(p))
edges = np.logspace(np.log10(lo_g), np.log10(hi_g), n_bands + 1)
bins_ = int(max(12, bins))
edges_deg = np.linspace(0.0, 180.0, bins_ + 1)
dw = np.radians(180.0 / bins_)
ang_mid = np.radians(0.5 * (edges_deg[1:] + edges_deg[:-1]))
# ── constrained_layout avoids blank space when suptitle is added ─────
axes_given = _axes_list(axes, n_bands) if axes is not None else None
fig = (
plt.figure(figsize=figsize, constrained_layout=True)
if axes_given is None
else axes_given[0].figure
)
for i in range(n_bands):
ax = (
axes_given[i]
if axes_given is not None
else fig.add_subplot(1, n_bands, i + 1, polar=True)
)
m = (p >= edges[i]) & (p < edges[i + 1])
th_deg = df.loc[m, "theta"].to_numpy(float) % 180.0
n_obs = int(np.sum(m))
# histogram — 0–180°, mirrored for axial symmetry
hist, _ = np.histogram(th_deg, bins=edges_deg)
rmax = float(hist.max()) if hist.max() > 0 else 1.0
# ── polar setup ───────────────────────────────────────────────────
ax.set_theta_zero_location("N")
ax.set_theta_direction(-1)
ax.set_yticklabels([])
ax.set_yticks([])
hide_polar_radius_labels(ax)
ax.yaxis.grid(False)
ax.xaxis.grid(False)
ax.set_frame_on(False)
# ── bars with axial symmetry (0–180° + mirror 180–360°) ──────────
if rs.bar_style == "gradient":
cm_ = plt.get_cmap(rs.cmap)
for a, h in zip(ang_mid, hist):
col = cm_(h / rmax) if rmax > 0 else cm_(0.5)
for a_plot in (a, a + np.pi):
ax.bar(
a_plot,
h,
width=dw,
color=col,
edgecolor=rs.bar_edgecolor,
linewidth=rs.bar_edgelw,
alpha=rs.bar_alpha,
)
else: # solid
for a, h in zip(ang_mid, hist):
for a_plot in (a, a + np.pi):
ax.bar(
a_plot,
h,
width=dw,
color=rs.bar_color,
edgecolor=rs.bar_edgecolor,
linewidth=rs.bar_edgelw,
alpha=rs.bar_alpha,
)
# ── concentric rings ──────────────────────────────────────────────
step = rmax / rs.n_rings
r_levels = [step * k for k in range(1, rs.n_rings + 1)]
theta_full = np.linspace(0, 2 * np.pi, 360)
for rv in r_levels:
ax.plot(
theta_full,
np.full_like(theta_full, rv),
color=rs.ring_color,
ls=rs.ring_ls,
lw=rs.ring_lw,
zorder=0,
)
# ── radial spokes ─────────────────────────────────────────────────
n_spokes = int(round(360.0 / rs.spoke_every))
spoke_angles = np.radians(np.arange(n_spokes) * rs.spoke_every)
for sa in spoke_angles:
ax.plot(
[sa, sa],
[0, rmax],
color=rs.spoke_color,
ls=rs.spoke_ls,
lw=rs.spoke_lw,
zorder=0,
)
# ── outer bold ring ────────────────────────────────────────────────
ax.spines["polar"].set_linewidth(rs.outer_ring_lw)
ax.spines["polar"].set_edgecolor(rs.outer_ring_color)
ax.set_ylim(0, rmax * 1.08)
# ── compass / degree labels ───────────────────────────────────────
spoke_degs = np.arange(n_spokes) * rs.spoke_every
if rs.compass_labels == "NESW":
_card = {0: "N", 90: "E", 180: "S", 270: "W"}
lbl_list = [_card.get(int(d) % 360, "") for d in spoke_degs]
elif rs.compass_labels == "degrees":
lbl_list = [f"{int(d)}°" for d in spoke_degs]
else:
lbl_list = [""] * n_spokes
ax.set_thetagrids(
spoke_degs, labels=lbl_list, fontsize=rs.compass_fontsize
)
for lbl in ax.get_xticklabels():
lbl.set_color(rs.compass_color)
lbl.set_fontweight(rs.compass_fontweight)
# ── mean direction line ───────────────────────────────────────────
if rs.show_mean and n_obs > 0:
mu = _axial_mean(th_deg)
mu_r = np.radians(mu)
sec_lw = rs.mean_lw if rs.secondary_lw is None else rs.secondary_lw
sec_col = (
rs.mean_color
if rs.secondary_color is None
else rs.secondary_color
)
for ang_pt in (mu_r, mu_r + np.pi):
ax.plot(
[ang_pt, ang_pt],
[0, rmax],
color=rs.mean_color,
lw=rs.mean_lw,
ls=rs.mean_ls,
zorder=6,
)
if rs.show_secondary:
for ang_pt in (mu_r + np.pi / 2, mu_r - np.pi / 2):
ax.plot(
[ang_pt, ang_pt],
[0, rmax],
color=sec_col,
lw=sec_lw,
ls=rs.secondary_ls,
zorder=6,
)
# ── panel annotation: period range + n ────────────────────────────
if rs.show_annotation and n_obs > 0:
mu_str = ""
if rs.show_mean and n_obs > 0:
mu = _axial_mean(th_deg)
mu_str = f"θ̄={mu:.0f}°"
n_str = f" n={n_obs}" if rs.show_n else ""
ax.text(
*rs.annotation_pos,
mu_str + n_str,
transform=ax.transAxes,
fontsize=rs.annotation_fontsize,
ha="left",
va="top",
bbox=dict(
fc=rs.annotation_bg,
ec=rs.annotation_ec,
alpha=0.80,
pad=1.5,
boxstyle="round,pad=0.3",
),
zorder=10,
)
# ── panel title: period band ───────────────────────────────────────
ax.set_title(
f"[{edges[i]:.2g}, {edges[i + 1]:.2g}]s",
fontsize=panel_title_fontsize,
pad=8,
)
return fig
# ─────────────────────────────────────────────────────────────────────────────
# Per-station ellipse strip + multi-profile small-multiples grid
# ─────────────────────────────────────────────────────────────────────────────
def _pt_size_reference(
s1_vals: np.ndarray,
normalise_by: str,
s1_ref: float | None,
) -> float | None:
"""Resolve the ellipse-size reference for ``"cell"``/``"unity"``/``"abs"``.
Mirrors the sizing rule used by :func:`plot_phase_tensor_psection` so
that all phase-tensor ellipse views scale identically for a given
*normalise_by* / *s1_ref* combination.
"""
if normalise_by == "cell":
if s1_ref is not None:
return max(float(s1_ref), 1e-9)
finite_s1 = s1_vals[np.isfinite(s1_vals)]
ref = float(np.nanpercentile(finite_s1, 90)) if len(finite_s1) else 1.0
return max(ref, 1e-9)
if normalise_by == "unity":
return max(float(s1_ref) if s1_ref is not None else 1.0, 1e-9)
return None # "abs" — raw data units
[docs]
def plot_phase_tensor_strip(
sites: Any,
*,
# ── data selection ────────────────────────────────────────────────────
station: str | None = None,
period_range: tuple[float, float] | None = None,
# ── ellipse sizing ────────────────────────────────────────────────────
scale=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.scale
normalise_by=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.normalise_by
s1_ref: float | None = None,
min_aspect=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.min_aspect
cells_per_decade: float = 7.0,
# ── colour ────────────────────────────────────────────────────────────
c_by=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.c_by
cmap=_UNSET, # default: auto from c_by via resolve_cmap()
clim: tuple[float, float] | None = None,
clim_pct=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.clim_pct
symmetric_clim=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.resolve_symmetric_clim()
# ── aesthetics ────────────────────────────────────────────────────────
edgecolor=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.edgecolor
linewidth=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.linewidth
alpha=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.alpha
# ── overlays ─────────────────────────────────────────────────────────
skew_threshold=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.skew_threshold
mark_3d=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.mark_3d
# ── decorative phase scale (y-axis) ───────────────────────────────────
phase_ticks: tuple[float, float, float] | None = (0.0, 45.0, 90.0),
ylabel: str = "",
# ── station corner label ──────────────────────────────────────────────
station_label: bool = True,
station_label_fontsize: float = 8.0,
# ── labels & layout ───────────────────────────────────────────────────
title: str = "",
xlabel: str = "",
axis_style: str = "period",
figsize: tuple[float, float] = (6.0, 1.4),
show_colorbar: bool = True,
colorbar_label: str | None = None,
# ── standard emtools ──────────────────────────────────────────────────
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
ax: plt.Axes | None = None,
) -> plt.Axes:
"""Single-station phase-tensor ellipse strip vs period.
Draws one horizontal row of phase-tensor ellipses for a single
station, one ellipse per period, in the classic "ellipse timeseries"
style used e.g. by WinGLink / mtpy single-station PT plots. The
y-axis carries no per-point data — it is a schematic 0°–90° phase
scale (*phase_ticks*) used only to calibrate ellipse size by eye; the
physical information (φ_max, φ_min, θ, fill colour) is identical to
:func:`plot_phase_tensor_psection`.
Combine several stations/profiles into the small-multiples layout
(columns = profiles, rows = stations, one shared colorbar) with
:func:`plot_phase_tensor_strip_grid`.
Parameters
----------
sites : any
EDI paths, glob pattern, :class:`~pycsamt.site.base.Sites`, any
input accepted by :func:`~pycsamt.emtools.ensure_sites`, or a
pre-built :class:`pandas.DataFrame` from
:func:`build_phase_tensor_table` (already filtered to one
station — in that case *station* may be left ``None``).
station : str or None
Station to plot. Required when *sites* resolves to more than
one station.
period_range : (T_min, T_max) in seconds or None
Restrict the period range plotted.
scale, normalise_by, s1_ref, min_aspect : see
:func:`plot_phase_tensor_psection`. Sizing controls;
``normalise_by="unity"`` (φ=45° 1-D reference fills *scale* of the
row) makes *phase_ticks* an exact scale. *min_aspect* floors the
ellipse height so near-degenerate cells stay visible.
cells_per_decade : float, default ``7.0``
Visual ellipse pitch along the period axis, expressed as the
number of ellipse-widths per log10 decade. Unlike
:func:`plot_phase_tensor_psection` (one ellipse per station
column), a period axis is typically sampled far more densely
than is useful for ellipse width — sizing each ellipse to its
*local* sample spacing would shrink it to an invisible sliver
wherever sampling is dense. This value is independent of how
many periods were actually measured, so ellipses overlap
(by design — the classic single-station "ellipse timeseries"
look) rather than shrinking as sampling gets denser.
c_by, cmap, clim, clim_pct, symmetric_clim :
Fill-colour controls; see :func:`plot_phase_tensor_psection`.
edgecolor, linewidth, alpha : ellipse border/opacity controls.
skew_threshold, mark_3d : 3-D cell highlighting, see
:func:`plot_phase_tensor_psection`.
phase_ticks : (lo, mid, hi) or None, default ``(0.0, 45.0, 90.0)``
Tick values drawn on the schematic y-scale. ``None`` hides the
y-axis entirely.
ylabel : str
Y-axis label; defaults to ``"Phase (°)"`` when *phase_ticks* is
not ``None``.
station_label : bool, default ``True``
Annotate the station name in the upper-left corner of the axes.
station_label_fontsize : float, default ``8.0``
title, xlabel : str
Axes title / x-label. *xlabel* defaults to ``"Period (s)"``
(or :data:`~pycsamt.api.labels.LOG10_PERIOD_LABEL` when
*axis_style* is ``"logperiod"``).
axis_style : {"period", "logperiod"}, default ``"period"``
Tick-label convention for the internally log10(period)-spaced
x-axis. ``"period"`` (default, unchanged) formats ticks as
``$10^{n}$`` under a ``"Period (s)"`` label. ``"logperiod"``
shows the raw log10 values (``-3``, ``-2``, ...) under
:data:`~pycsamt.api.labels.LOG10_PERIOD_LABEL`, matching
:func:`plot_phase_tensor_psection`'s own convention.
figsize : (float, float), default ``(6.0, 1.4)``
Figure size (ignored when *ax* is provided).
show_colorbar : bool, default ``True``
Attach a right-side colorbar. Set ``False`` when composing a
grid with a single shared colorbar (see
:func:`plot_phase_tensor_strip_grid`).
colorbar_label : str or None
Override the automatic colorbar label derived from *c_by*.
recursive, on_dup, strict, verbose : see :func:`ensure_sites`.
ax : :class:`~matplotlib.axes.Axes` or None
If provided, draw into this axes and return it; otherwise create
a new figure.
Returns
-------
ax : :class:`~matplotlib.axes.Axes`
Examples
--------
>>> from pycsamt.emtools import plot_phase_tensor_strip
>>> ax = plot_phase_tensor_strip(sites, station="S1")
Fixed colour scale (for visual consistency across several calls):
>>> ax = plot_phase_tensor_strip(
... sites,
... station="S1",
... c_by="skew",
... clim=(-9.0, 9.0),
... )
See Also
--------
plot_phase_tensor_strip_grid : Multi-station / multi-profile facet grid.
plot_phase_tensor_psection : Station × period pseudo-section.
"""
if isinstance(sites, pd.DataFrame):
df = sites.copy()
else:
df = build_phase_tensor_table(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
# ── resolve visual style from PYCSAMT_STYLE.pt_ellipse ──────────────
_es = PYCSAMT_STYLE.pt_ellipse
if scale is _UNSET:
scale = _es.scale
if normalise_by is _UNSET:
normalise_by = _es.normalise_by
if min_aspect is _UNSET:
min_aspect = _es.min_aspect
if c_by is _UNSET:
c_by = _es.c_by
if cmap is _UNSET:
cmap = _es.copy(c_by=c_by).resolve_cmap()
if clim_pct is _UNSET:
clim_pct = _es.clim_pct
if symmetric_clim is _UNSET:
symmetric_clim = _es.copy(c_by=c_by).resolve_symmetric_clim()
if edgecolor is _UNSET:
edgecolor = _es.edgecolor
if linewidth is _UNSET:
linewidth = _es.linewidth
if alpha is _UNSET:
alpha = _es.alpha
if skew_threshold is _UNSET:
skew_threshold = _es.skew_threshold
if mark_3d is _UNSET:
mark_3d = _es.mark_3d
# ── data selection ────────────────────────────────────────────────────
if not df.empty and period_range is not None:
lo, hi = float(period_range[0]), float(period_range[1])
df = df[(df["period"] >= lo) & (df["period"] <= hi)]
if not df.empty:
stations_present = list(dict.fromkeys(df["station"].tolist()))
if station is not None:
df = df[df["station"] == station]
elif len(stations_present) > 1:
raise ValueError(
"plot_phase_tensor_strip() needs a single station; "
f"got {len(stations_present)} — pass station=... "
"(or use plot_phase_tensor_strip_grid for many stations)."
)
else:
station = stations_present[0] if stations_present else station
if ax is None:
_, ax = plt.subplots(figsize=figsize)
if df.empty:
ax.text(
0.5,
0.5,
"no phase tensor data",
ha="center",
va="center",
transform=ax.transAxes,
)
return ax
df = df.sort_values("period")
# ── x-axis: log10(period); y-axis: single schematic row ──────────────
# cell_x is a *visual* pitch (ellipse-widths per decade), not the raw
# sample spacing — see `cells_per_decade` docstring for why.
x_all = np.log10(df["period"].to_numpy(float))
cell_x = 1.0 / max(cells_per_decade, 1e-6)
cell_y = 1.0
s1_vals = df["s1"].to_numpy(float)
ref = _pt_size_reference(s1_vals, normalise_by, s1_ref)
cvals, cbar_label = _resolve_cvals(df, c_by)
if colorbar_label:
cbar_label = colorbar_label
finite_c = cvals[np.isfinite(cvals)]
if clim is not None:
vmin, vmax = float(clim[0]), float(clim[1])
elif skew_threshold is not None and c_by in ("skew", "beta"):
vmin, vmax = -float(skew_threshold), float(skew_threshold)
else:
lo_pct, hi_pct = clim_pct
vmin = (
float(np.nanpercentile(finite_c, lo_pct))
if len(finite_c)
else -1.0
)
vmax = (
float(np.nanpercentile(finite_c, hi_pct)) if len(finite_c) else 1.0
)
if symmetric_clim and c_by in _SYMMETRIC_C:
vlim = max(abs(vmin), abs(vmax))
vmin, vmax = -vlim, vlim
norm = Normalize(vmin=vmin, vmax=vmax)
cm = plt.get_cmap(cmap)
s1_arr = df["s1"].to_numpy(float)
s2_arr = df["s2"].to_numpy(float)
th_arr = df["theta"].to_numpy(float)
skew_arr = (
df["skew"].to_numpy(float)
if "skew" in df.columns
else np.zeros(len(df))
)
for xi, s1i, s2i, thi, ci, beti in zip(
x_all, s1_arr, s2_arr, th_arr, cvals, skew_arr
):
if not np.all(np.isfinite([xi, s1i, s2i, thi, ci])):
continue
if ref is not None:
w = min(scale * cell_x * s1i / ref, 0.99 * cell_x)
h = min(scale * cell_y * s2i / ref, 0.99 * cell_y)
else:
w = scale * s1i
h = scale * s2i
if min_aspect > 0:
h = max(h, min_aspect * w)
is_3d = (
skew_threshold is not None
and mark_3d
and np.isfinite(beti)
and abs(beti) > skew_threshold
)
lw_i = linewidth * 3.0 if is_3d else linewidth
ax.add_patch(
Ellipse(
(xi, 0.0),
width=w,
height=h,
angle=thi,
facecolor=cm(norm(ci)),
edgecolor=edgecolor,
linewidth=lw_i,
alpha=alpha,
zorder=3,
)
)
# ── x-axis limits & ticks (log10 period; label convention per
# *axis_style*) ─────────────────────────────────────────────────────
if axis_style not in ("period", "logperiod"):
raise ValueError(
f"axis_style must be 'period' or 'logperiod'; got {axis_style!r}."
)
x_lo = float(np.nanmin(x_all)) if len(x_all) else 0.0
x_hi = float(np.nanmax(x_all)) if len(x_all) else 1.0
margin = max(0.5 * cell_x, 0.02 * (x_hi - x_lo + 1e-9))
ax.set_xlim(x_lo - margin, x_hi + margin)
x_int = np.arange(int(np.floor(x_lo)), int(np.ceil(x_hi)) + 1)
ax.set_xticks(x_int)
if axis_style == "logperiod":
ax.set_xticklabels([f"{int(v)}" for v in x_int], fontsize=8)
ax.set_xlabel(xlabel or LOG10_PERIOD_LABEL, fontsize=9)
else:
ax.set_xticklabels([f"$10^{{{int(v)}}}$" for v in x_int], fontsize=8)
ax.set_xlabel(xlabel or "Period (s)", fontsize=9)
# ── y-axis: schematic phase scale, no real data ───────────────────────
ax.set_ylim(-0.62, 0.62)
if phase_ticks is not None:
lo_t, mid_t, hi_t = phase_ticks
ax.set_yticks([-0.5, 0.0, 0.5])
ax.set_yticklabels(
[f"{lo_t:g}", f"{mid_t:g}", f"{hi_t:g}"], fontsize=8
)
ax.set_ylabel(ylabel or "Phase (°)", fontsize=9)
else:
ax.set_yticks([])
ax.set_ylabel(ylabel, fontsize=9)
if title:
ax.set_title(title, fontsize=9)
if station_label and station:
ax.text(
0.015,
0.92,
str(station),
transform=ax.transAxes,
fontsize=station_label_fontsize,
fontweight="bold",
ha="left",
va="top",
bbox=dict(fc="white", ec="none", alpha=0.65, pad=1.0),
)
ax.grid(True, ls=":", lw=0.4, color="#cccccc", alpha=0.7, zorder=0)
if show_colorbar:
sm = ScalarMappable(cmap=cm, norm=norm)
sm.set_array([])
_attach_cbar(ax, sm, cbar_label)
return ax
[docs]
def plot_phase_tensor_strip_grid(
sites: Any,
profiles: dict[str, list[str]],
*,
period_range: tuple[float, float] | None = None,
scale=_UNSET,
normalise_by=_UNSET,
s1_ref: float | None = None,
min_aspect=_UNSET,
cells_per_decade: float = 7.0,
c_by=_UNSET,
cmap=_UNSET,
clim: tuple[float, float] | None = None,
clim_pct=_UNSET,
symmetric_clim=_UNSET,
edgecolor=_UNSET,
linewidth=_UNSET,
alpha=_UNSET,
skew_threshold=_UNSET,
mark_3d=_UNSET,
phase_ticks: tuple[float, float, float] | None = (0.0, 45.0, 90.0),
col_titles: dict[str, str] | None = None,
xlabel: str = "Period (s)",
suptitle: str = "",
colorbar_label: str | None = None,
panel_size: tuple[float, float] = (4.4, 1.05),
recursive: bool = True,
on_dup: str = "replace",
strict: bool = False,
verbose: int = 0,
axes: Any | None = None,
) -> plt.Figure:
"""Phase-tensor ellipse strips for several stations, grouped by profile.
Reproduces the classic multi-panel figure — one
:func:`plot_phase_tensor_strip` row per selected station, tiled in a
grid where each *column* is a profile/line and each *row* is one of
the stations picked for that profile — with a **single shared
colorbar** for the whole figure (so fill colours are comparable
across every panel).
Parameters
----------
sites : any
Input accepted by :func:`~pycsamt.emtools._core.ensure_sites`
covering every station referenced in *profiles*.
profiles : dict[str, list[str]]
Mapping ``{profile_label: [station, ...]}``, e.g.::
{
"Profile L1": ["S1", "S15", "S30", "S45"],
"Profile L3": ["S1", "S4", "S7", "S11"],
}
Each list becomes one column; rows are aligned by position, not
by station identity (profiles may list a different number of
stations — empty cells are left blank).
period_range : (T_min, T_max) or None
Restrict the period range plotted in every panel.
scale, normalise_by, s1_ref, min_aspect, cells_per_decade, c_by, cmap,
clim, clim_pct, symmetric_clim, edgecolor, linewidth, alpha,
skew_threshold, mark_3d :
Forwarded to every :func:`plot_phase_tensor_strip` call; see its
docstring (and :func:`plot_phase_tensor_psection`) for details.
When *clim* is ``None`` it is derived once from the pooled data
of every listed station, so all panels share one colour scale.
phase_ticks : (lo, mid, hi) or None, default ``(0.0, 45.0, 90.0)``
Schematic y-scale ticks drawn on every panel.
col_titles : dict[str, str] or None
Override the column title text; defaults to the *profiles* keys.
xlabel : str, default ``"Period (s)"``
Shown once under the bottom-most panel of each column.
suptitle : str
Figure-level title.
colorbar_label : str or None
Override the automatic colorbar label derived from *c_by*.
panel_size : (width, height), default ``(4.4, 1.05)``
Per-panel size in inches; the figure size is
``(n_cols * width, n_rows * height)``.
recursive, on_dup, strict, verbose : see :func:`ensure_sites`.
axes : 2-D array of :class:`~matplotlib.axes.Axes` or None
Pre-existing ``(n_rows, n_cols)`` axes grid to draw into
(``n_rows = max(len(v) for v in profiles.values())``,
``n_cols = len(profiles)``). A new figure is created when
``None``.
Returns
-------
fig : :class:`~matplotlib.figure.Figure`
Examples
--------
>>> from pycsamt.emtools import plot_phase_tensor_strip_grid
>>> fig = plot_phase_tensor_strip_grid(
... sites,
... profiles={
... "Profile L1": ["S1", "S15", "S30", "S45"],
... "Profile L3": ["S1", "S4", "S7", "S11"],
... },
... c_by="skew",
... cmap="RdBu_r",
... )
See Also
--------
plot_phase_tensor_strip : Single-station ellipse strip (one panel).
plot_phase_tensor_psection : Station × period pseudo-section.
"""
# ── resolve visual style from PYCSAMT_STYLE.pt_ellipse ──────────────
_es = PYCSAMT_STYLE.pt_ellipse
if scale is _UNSET:
scale = _es.scale
if normalise_by is _UNSET:
normalise_by = _es.normalise_by
if min_aspect is _UNSET:
min_aspect = _es.min_aspect
if c_by is _UNSET:
c_by = _es.c_by
if cmap is _UNSET:
cmap = _es.copy(c_by=c_by).resolve_cmap()
if clim_pct is _UNSET:
clim_pct = _es.clim_pct
if symmetric_clim is _UNSET:
symmetric_clim = _es.copy(c_by=c_by).resolve_symmetric_clim()
if edgecolor is _UNSET:
edgecolor = _es.edgecolor
if linewidth is _UNSET:
linewidth = _es.linewidth
if alpha is _UNSET:
alpha = _es.alpha
if skew_threshold is _UNSET:
skew_threshold = _es.skew_threshold
if mark_3d is _UNSET:
mark_3d = _es.mark_3d
all_stations = list(
dict.fromkeys(st for st_list in profiles.values() for st in st_list)
)
df_all = build_phase_tensor_table(
sites,
recursive=recursive,
on_dup=on_dup,
strict=strict,
verbose=verbose,
)
if not df_all.empty:
df_all = df_all[df_all["station"].isin(all_stations)]
if period_range is not None:
lo, hi = float(period_range[0]), float(period_range[1])
df_all = df_all[
(df_all["period"] >= lo) & (df_all["period"] <= hi)
]
# ── shared colour scale across every panel ────────────────────────────
if clim is not None:
clim_shared = clim
elif not df_all.empty:
cvals_all, _ = _resolve_cvals(df_all, c_by)
finite_all = cvals_all[np.isfinite(cvals_all)]
if skew_threshold is not None and c_by in ("skew", "beta"):
clim_shared = (-float(skew_threshold), float(skew_threshold))
else:
lo_pct, hi_pct = clim_pct
vmin = (
float(np.nanpercentile(finite_all, lo_pct))
if len(finite_all)
else -1.0
)
vmax = (
float(np.nanpercentile(finite_all, hi_pct))
if len(finite_all)
else 1.0
)
if symmetric_clim and c_by in _SYMMETRIC_C:
vlim = max(abs(vmin), abs(vmax))
vmin, vmax = -vlim, vlim
clim_shared = (vmin, vmax)
else:
clim_shared = (-1.0, 1.0)
n_cols = len(profiles)
n_rows = max((len(v) for v in profiles.values()), default=1)
if axes is None:
fig, axes = plt.subplots(
n_rows,
n_cols,
squeeze=False,
sharex="col",
figsize=(panel_size[0] * n_cols, panel_size[1] * n_rows),
constrained_layout=True,
)
else:
axes = np.atleast_2d(axes)
fig = axes[0, 0].figure
col_titles = col_titles or {}
_, cbar_label = (
_resolve_cvals(df_all, c_by) if not df_all.empty else (None, c_by)
)
if colorbar_label:
cbar_label = colorbar_label
for j, (prof_name, st_list) in enumerate(profiles.items()):
for i in range(n_rows):
ax = axes[i, j]
if i >= len(st_list):
ax.axis("off")
continue
st = st_list[i]
plot_phase_tensor_strip(
df_all,
station=st,
scale=scale,
normalise_by=normalise_by,
s1_ref=s1_ref,
min_aspect=min_aspect,
cells_per_decade=cells_per_decade,
c_by=c_by,
cmap=cmap,
clim=clim_shared,
clim_pct=clim_pct,
symmetric_clim=symmetric_clim,
edgecolor=edgecolor,
linewidth=linewidth,
alpha=alpha,
skew_threshold=skew_threshold,
mark_3d=mark_3d,
phase_ticks=phase_ticks,
show_colorbar=False,
xlabel=xlabel if i == len(st_list) - 1 else "",
ax=ax,
)
if i != len(st_list) - 1:
ax.tick_params(labelbottom=False)
ax.set_xlabel("")
axes[0, j].set_title(col_titles.get(prof_name, prof_name), fontsize=10)
cm = plt.get_cmap(cmap)
norm = Normalize(vmin=clim_shared[0], vmax=clim_shared[1])
sm = ScalarMappable(cmap=cm, norm=norm)
sm.set_array([])
cb = fig.colorbar(sm, ax=axes, shrink=0.9, pad=0.015, aspect=30)
cb.set_label(cbar_label, fontsize=9)
cb.ax.tick_params(labelsize=8)
if suptitle:
fig.suptitle(suptitle, fontsize=11)
return fig