Source code for pycsamt.emtools.tensor

from __future__ import annotations

from typing import Any

import matplotlib.colors as mcolors
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib.cm import ScalarMappable
from matplotlib.colors import 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 # _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 _get_z(ed: Any) -> tuple[np.ndarray | None, np.ndarray | None]: Zobj = getattr(ed, "Z", None) or 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): return z, fr if isinstance(Zobj, np.ndarray) and Zobj.ndim == 3: 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]: num_b = b + c den_b = a - d beta = 0.5 * np.degrees(np.arctan2(num_b, den_b)) num_a = -(b - c) den_a = a + d alpha = 0.5 * np.degrees(np.arctan2(num_a, den_a)) 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)", } # Symmetric c_by quantities (vmin = -vmax enforced by default) _SYMMETRIC_C: frozenset = frozenset( ("skew", "beta", "alpha", "|skew|", "|beta|") ) 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"] 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, ) -> None: """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) cb.set_label(label, fontsize=labelsize) cb.ax.tick_params(labelsize=ticksize) # ------------------------------ 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 = True, # ── 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() # ── 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 ref_ellipse=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.show_ref legend_fontsize: float = 8.0, # ── 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 ``True`` When ``True`` (MT convention) long period (low frequency) is at the top of the figure; ``False`` flips the y-axis. 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 : ``"cell"`` | ``"unity"`` | ``"abs"`` Sizing strategy: ``"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``. 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. skew_threshold : float or None, default ``3.0`` |β| threshold (degrees) separating 1-D/2-D from 3-D structure. Used to cap *clim* symmetrically when *clim* is ``None``, and to highlight 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°) in a reserved legend strip above the data, as a scale indicator. 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. 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 == "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]) 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) # ── 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)) ) # ── draw ellipses ───────────────────────────────────────────────────── 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 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, yi), width=w, height=h, angle=thi, facecolor=cm(norm(ci)), edgecolor=edgecolor, linewidth=lw_i, alpha=alpha, zorder=3, ) ) # ── axes limits and ticks ───────────────────────────────────────────── ax.set_xlim(-0.6, n_st - 0.4) y_all = y_arr[np.isfinite(y_arr)] 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)) # 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(ref_ellipse) or ( skew_threshold is not None and c_by in ("skew", "beta") ) legend_depth = 3.2 * cell_y if want_legend else 0.0 if period_up: top_edge, top_sign = y_hi, 1.0 ax.set_ylim(y_lo - margin, y_hi + margin + legend_depth) else: top_edge, top_sign = y_lo, -1.0 ax.set_ylim(y_hi + margin, y_lo - margin - legend_depth) PYCSAMT_STATION_RENDERING.apply( ax, np.arange(n_st, dtype=float), st_list, preset="pseudosection", xlim=(-0.6, n_st - 0.4), ) # 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 ───────────────────────────────────────────────────────── sm = ScalarMappable(cmap=cm, norm=norm) sm.set_array([]) _attach_cbar(ax, sm, cbar_label) # ── 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 = bound + top_sign * 0.32 * legend_depth y_far = bound + top_sign * 0.75 * legend_depth ax.text( -0.45, y_far, f"|β| < {skew_threshold:.0f}° → 1-D/2-D", fontsize=legend_fontsize, va="center", ha="left", color="#2166ac", bbox=dict(fc="white", ec="0.75", lw=0.5, alpha=0.9, pad=2.0), ) ax.text( -0.45, y_near, f"|β| ≥ {skew_threshold:.0f}° → 3-D", fontsize=legend_fontsize, va="center", ha="left", color="#b2182b", 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 = n_st - 1 ry = bound + top_sign * 0.38 * legend_depth rw = min(scale * cell_x, 0.99 * cell_x) rh = max(min(scale * cell_y, 0.99 * cell_y), min_aspect * rw) ax.add_patch( Ellipse( (rx, ry), width=rw, height=rh, angle=0.0, facecolor="0.9", edgecolor="k", linewidth=0.9, zorder=5, ) ) ref_label = { "unity": "1-D reference\n(Φ=45°)", "cell": "size reference\n(90th-pct Φ_max)", }.get(normalise_by, "size reference") ax.text( rx, ry + top_sign * (0.5 * rh + 0.10 * legend_depth), ref_label, ha="center", va=va_out, fontsize=legend_fontsize, linespacing=1.3, bbox=dict(fc="white", ec="0.75", lw=0.5, alpha=0.9, pad=2.0), ) 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
[docs] def plot_dimensionality_psection( 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 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 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]), 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]) cb = plt.colorbar(im, ax=ax) 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
[docs] def plot_phase_tensor_map( sites: Any, *, period: float = 10.0, # ── ellipse style (honours PhaseTensorEllipseStyle) ─────────────── scale=_UNSET, 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, # ── 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, # ── 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. 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)", ) 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*. 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) # ── 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 ─────────────────────────────────────── 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", "") 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: add_colorbar( bg_mappable, ax, label=g_lbl, side="left", 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 ────────────────────────────────────────────────────── 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
[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, figsize: tuple[float, float] = (2.5, 2.5), ax: plt.Axes | None = None, ) -> plt.Axes: if ax is None: _, ax = plt.subplots(figsize=figsize) e = Ellipse( (0.0, 0.0), width=size, height=size, angle=0.0, fill=False, lw=1.0 ) ax.add_patch(e) ax.plot([0, 0], [0, size * 0.6], "-", lw=1.0) ax.set_xlim(-size, size) ax.set_ylim(-size, size) 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 = "", 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)"``. 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, "10^n" labels) ─────────────── 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) 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