Source code for pycsamt.emtools.tf

from __future__ import annotations

from collections.abc import Sequence
from typing import (
    Any,
)

import matplotlib.colors as mcolors
import matplotlib.pyplot as plt
import numpy as np

from ..api._rose_style import (
    _UNSET,
    resolve_rose_style,
)
from ..api.labels import LOG10_PERIOD_LABEL
from ..api.plot import add_colorbar, add_polar_colorbar
from ..api.section import PYCSAMT_SECTION, SectionStyle
from ..api.style import PYCSAMT_STYLE
from ._core import (
    _axes_list,
    _get_t_block,
    _get_z_block,
    _iter_items,
    _name,
    ensure_sites,
    hide_polar_radius_labels,
)
from .tensor import build_phase_tensor_table

# ------------------------------- helpers -------------------------------- #


def _pick_station(S, station: str | None) -> tuple[str, Any]:
    pool = {}
    for i, ed in enumerate(_iter_items(S)):
        pool[_name(ed, i)] = ed
    if not pool:
        raise RuntimeError("no sites")
    if station is None:
        station = sorted(pool.keys())[0]
    ed = pool.get(station, None)
    if ed is None:
        raise RuntimeError("station not found")
    return station, ed


def _bands_from_periods(
    per: np.ndarray,
    *,
    bands: Sequence[tuple[float, float]] | None = None,
    n_bands: int = 3,
) -> list[np.ndarray]:
    if bands:
        ms = []
        for lo, hi in bands:
            ms.append((per >= float(lo)) & (per <= float(hi)))
        return ms
    # quantile bands
    q = np.linspace(0, 1, num=int(n_bands) + 1)
    bb = np.quantile(per, q)
    ms = []
    for i in range(len(bb) - 1):
        lo, hi = bb[i], bb[i + 1]
        # include low edge, exclude high (except last)
        if i == len(bb) - 2:
            ms.append((per >= lo) & (per <= hi))
        else:
            ms.append((per >= lo) & (per < hi))
    return ms


def _station_xy(ed: Any, i: int) -> tuple[float, float]:
    for kx, ky in [
        ("east", "north"),
        ("easting", "northing"),
        ("x", "y"),
        ("lon", "lat"),
    ]:
        x = getattr(ed, kx, None)
        y = getattr(ed, ky, None)
        if isinstance(x, (int, float)) and isinstance(y, (int, float)):
            return float(x), float(y)
    return float(i), 0.0  # fallback: index on a line


def _nearest_idx(x: np.ndarray, y: np.ndarray) -> np.ndarray:
    j = np.searchsorted(x, y)
    j = np.clip(j, 1, x.size - 1)
    use_left = np.abs(y - x[j - 1]) <= np.abs(y - x[j])
    j[use_left] -= 1
    return j


def _pt_angle(S, station: str, per: np.ndarray) -> np.ndarray | None:
    if build_phase_tensor_table is None:
        return None
    tb = build_phase_tensor_table(
        S,
        recursive=False,
        on_dup="replace",
        strict=False,
        verbose=0,
    )
    if getattr(tb, "empty", False):
        return None
    sdf = tb[tb["station"] == station]
    if sdf.empty:
        return None
    p_ref = sdf["period"].to_numpy(dtype=float)
    for col in ("azimuth", "strike", "phi", "theta"):
        if col in sdf.columns:
            phi = sdf[col].to_numpy(dtype=float)
            j = _nearest_idx(p_ref, per)
            return phi[j]
    return None


# ------------------------- 11) Tipper hodograms ------------------------- #


[docs] def plot_tipper_hodograms( sites: Any, *, station: str | None = None, bands: Sequence[tuple[float, float]] | None = None, n_bands: int = 3, normalize: bool = False, colors: Sequence[str] | None = None, marker: str = "o", ms: float = 3.0, lw: float = 1.0, ls: str = "-", unit_circle: bool = True, axes=None, figsize: tuple[float, float] = (6.4, 3.2), recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ): S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) st, ed = _pick_station(S, station) T, t, fr = _get_t_block(ed) axes_given = _axes_list(axes, 1) if axes is not None else None if T is None or t is None: if axes_given is None: fig = plt.figure(figsize=figsize) ax = fig.add_subplot(111) else: ax = axes_given[0] fig = ax.figure ax.text(0.5, 0.5, "no tipper", ha="center", va="center") return fig per = 1.0 / fr Ms = _bands_from_periods(per, bands=bands, n_bands=n_bands) if colors is None: cols = [plt.cm.viridis(c) for c in np.linspace(0, 1, len(Ms))] else: cols = list(colors)[: len(Ms)] axes_given = _axes_list(axes, 2) if axes is not None else None if axes_given is None: fig = plt.figure(figsize=figsize) gs = fig.add_gridspec(1, 2, wspace=0.25) axX = fig.add_subplot(gs[0]) axY = fig.add_subplot(gs[1]) else: axX, axY = axes_given fig = axX.figure for k, m in enumerate(Ms): if not np.any(m): continue tx = t[m, 0] ty = t[m, 1] X = np.real(tx) Y = np.imag(tx) U = np.real(ty) V = np.imag(ty) if normalize: s = ( np.nanpercentile(np.hypot(np.r_[X, U], np.r_[Y, V]), 95) + 1e-24 ) X, Y, U, V = X / s, Y / s, U / s, V / s col = cols[k] axX.plot(X, Y, ls=ls, lw=lw, color=col) axX.scatter(X, Y, s=12 * ms, color=col) axY.plot(U, V, ls=ls, lw=lw, color=col) axY.scatter(U, V, s=12 * ms, color=col) for ax, lab in [(axX, "Tx"), (axY, "Ty")]: ax.axhline(0, color="0.85", lw=0.8) ax.axvline(0, color="0.85", lw=0.8) if unit_circle: th = np.linspace(0, 2 * np.pi, 256) ax.plot(np.cos(th), np.sin(th), ":", color="0.7", lw=0.8) ax.set_aspect("equal", adjustable="box") ax.set_xlabel("Real") ax.set_ylabel("Imag") ax.set_title(f"{st}{lab}") ax.grid(True, alpha=0.25) return fig
# --------- 12) Induction arrows + phase-tensor strike overlay ----------- # def _arrow_from_tipper( t: np.ndarray, *, convention: str = "park", # park|wiese|real|imag ) -> np.ndarray: tx = t[:, 0] ty = t[:, 1] if convention == "real": vx = np.real(tx) vy = np.real(ty) else: # default: use imaginary (Parkinson-style) vx = -np.imag(tx) vy = -np.imag(ty) if convention == "wiese": # 90° rotation of Parkinson vector vx, vy = -vy, vx if convention == "imag": pass # already imag-based return np.vstack([vx, vy]).T
[docs] def plot_induction_arrows( sites: Any, *, periods: Sequence[float] = (1.0,), convention: str = "park", # park|wiese|real|imag scale: float = 1.0, # quiver scale factor normalize: bool = True, strike_ticks: bool = True, tick_len: float = 0.25, figsize: tuple[float, float] = (7.2, 3.4), recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ax: plt.Axes | None = None, ): S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) # collect site positions and arrows per requested period sts, _XY, _AR = [], [], [] per_layers: list[tuple[float, np.ndarray]] = [] for p in periods: xs, ys, u, v = [], [], [], [] for i, ed in enumerate(_iter_items(S)): T, t, fr = _get_t_block(ed) if T is None or t is None: continue per = 1.0 / fr j = _nearest_idx(per, np.array([p], float)) tx = t[j[0]] vec = _arrow_from_tipper(np.asarray([tx]), convention=convention)[ 0 ] x, y = _station_xy(ed, i) xs.append(x) ys.append(y) u.append(vec[0]) v.append(vec[1]) if p == periods[0]: sts.append(_name(ed, i)) per_layers.append( ( p, np.vstack( [np.array(xs), np.array(ys), np.array(u), np.array(v)] ), ) ) if not per_layers: if ax is None: _, ax = plt.subplots(figsize=figsize) ax.text(0.5, 0.5, "no tipper", ha="center", va="center") return ax # normalization if normalize: all_mag = [] for _, L in per_layers: mag = np.hypot(L[2], L[3]) all_mag.append(mag) m95 = np.nanpercentile(np.hstack(all_mag), 95) + 1e-24 for k in range(len(per_layers)): p, L = per_layers[k] L[2] /= m95 L[3] /= m95 per_layers[k] = (p, L) if ax is None: _, ax = plt.subplots(figsize=figsize) # axis as map or profile depending on coords XY0 = per_layers[0][1] if np.unique(XY0[1]).size == 1: # one line ax.set_xlabel("Station index / x") ax.set_ylabel("Arrow (arb.)") else: ax.set_xlabel("East / lon") ax.set_ylabel("North / lat") # draw layers, one color per period cols = plt.cm.viridis(np.linspace(0.15, 0.85, len(per_layers))) for c, (p, L) in zip(cols, per_layers): ax.quiver( L[0], L[1], L[2] * scale, L[3] * scale, angles="xy", scale_units="xy", scale=1.0, color=c, width=0.003, headlength=4, headaxislength=3, minlength=0.0, ) # optional strike ticks from PT if strike_ticks and build_phase_tensor_table is not None: # one average angle per site (first period layer’s sites) per = np.array(periods, dtype=float) # use the first period to sample strike p0 = float(per[0]) labs = sts th_map = {} for i, ed in enumerate(_iter_items(S)): st = _name(ed, i) if st not in labs: continue Z, z, fr = _get_z_block(ed)[:3] if Z is None: continue th = _pt_angle(S, st, np.array([p0])) if th is None: continue th_map[st] = float(np.radians(th[0])) for i, ed in enumerate(_iter_items(S)): st = _name(ed, i) if st not in th_map: continue x, y = _station_xy(ed, i) th = th_map[st] dx = tick_len * np.cos(th) dy = tick_len * np.sin(th) ax.plot( [x - dx, x + dx], [y - dy, y + dy], "-", color="0.2", lw=1.2, alpha=0.9, ) # cosmetics if np.unique(XY0[1]).size == 1: # profile: center around zero vertically ax.axhline(0.0, color="0.8", lw=0.8) ax.grid(True, alpha=0.25) leg = [f"P={p:g}s" for p, _ in per_layers] fig = ax.figure fig.legend( [plt.Line2D([], [], color=c, lw=2.0) for c in cols], leg, ncol=min(len(leg), 4), frameon=False, loc="upper center", bbox_to_anchor=(0.5, 1.02), fontsize=9, ) return ax
# ───────────────────────────────────────────────────────────────────────────── # Internal helpers shared by the new functions # ───────────────────────────────────────────────────────────────────────────── def _spine_style(ax) -> None: ax.grid(True, which="both", ls=":", lw=0.4, color="0.75", zorder=0) ax.set_axisbelow(True) def _set_map_aspect(ax, y_values: np.ndarray, scale: float) -> None: """Set aspect ratio without wasteful whitespace. For a true 2-D map (stations spread in both x and y) use equal scaling. For a profile (all stations on a near-horizontal line) switch to *auto* and size the y-window tightly around the actual plotted data instead of guessing from *scale* alone. Parameters ---------- y_values : array Every y-coordinate that must stay visible: station positions *and* arrow-tip positions (real + imaginary, whichever are actually drawn). Sizing the margin from *scale* alone (as a worst-case "arrow could be this long") overshoots badly whenever *scale* is turned up to make small arrows more visible — the window grows in lock-step with the arrows, so they end up no bigger on screen, and the colorbar (whose height matches the axes) is stretched into a long, unreadable sliver. """ ax.autoscale(True) xlim = ax.get_xlim() ylim = ax.get_ylim() xspan = xlim[1] - xlim[0] + 1e-12 yspan = ylim[1] - ylim[0] + 1e-12 if yspan < 0.25 * xspan: # Profile case: fit a tight y-window around the real data. y_values = np.asarray(y_values, dtype=float) y_values = y_values[np.isfinite(y_values)] y_ctr = 0.5 * (ylim[0] + ylim[1]) data_half = ( float(np.max(np.abs(y_values - y_ctr))) if y_values.size else 0.0 ) margin = max(data_half * 1.25, xspan * 0.04, scale * 0.1, 1e-6) ax.set_ylim(y_ctr - margin, y_ctr + margin) ax.set_aspect("auto") else: ax.set_aspect("equal", adjustable="box") def _thin_label_indices( n: int, max_labels: int = 15, width_in: float = 8.0, ) -> np.ndarray: """Indices of station labels to draw so they stay legible. Map-view station labels are drawn at a fixed pixel offset from each marker; with many closely-spaced stations that offset is not enough to keep them from overlapping into an unreadable smear, so only a thinned subset is labelled (always including the last station). *max_labels* is scaled by *width_in* (the actual axes width in inches) so a narrow panel -- e.g. one quadrant of a 2x2 comparison figure -- thins more aggressively than a full-width map. """ max_labels = max(3, int(round(max_labels * width_in / 8.0))) if n <= max_labels: return np.arange(n) step = int(np.ceil(n / max_labels)) idx = np.arange(0, n, step) if idx[-1] != n - 1: idx = np.append(idx, n - 1) return idx def _collect_tipper_spectrum( S: Any, ) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray], dict[str, Any]]: """Return dicts keyed by station: tipper (nf,2), freq, xy.""" tip_dict: dict[str, np.ndarray] = {} freq_dict: dict[str, np.ndarray] = {} xy_dict: dict[str, tuple[float, float]] = {} for i, ed in enumerate(_iter_items(S)): T, t, fr = _get_t_block(ed) if T is None or t is None: continue name = _name(ed, i) tip_dict[name] = np.asarray(t, complex) freq_dict[name] = np.asarray(fr, float) xy_dict[name] = _station_xy(ed, i) return tip_dict, freq_dict, xy_dict def _tipper_from_spectra( sp_input: Any, ) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray]]: """Extract tipper from a Spectra object or collection.""" from ..seg.spectra import ( Spectra as _Spectra, # noqa: PLC0415 ) if isinstance(sp_input, _Spectra): items = {"site": sp_input} elif isinstance(sp_input, dict): items = sp_input elif isinstance(sp_input, (list, tuple)): items = { getattr(s, "name", None) or f"site{k}": s for k, s in enumerate(sp_input) } else: raise TypeError(type(sp_input)) tip_dict: dict[str, np.ndarray] = {} freq_dict: dict[str, np.ndarray] = {} for name, sp in items.items(): _, tip = sp.to_Z(estimate_error=False) if tip is None or tip.tipper is None: continue tip_dict[str(name)] = tip.tipper[:, 0, :] # (nf, 2) freq_dict[str(name)] = tip.freq return tip_dict, freq_dict # ───────────────────────────────────────────────────────────────────────────── # 13) Map view — real + imaginary induction arrows on station positions # ─────────────────────────────────────────────────────────────────────────────
[docs] def plot_induction_map( sites: Any, *, period: float = 1.0, convention: str = "park", show_real: bool = True, show_imag: bool = True, scale: float = _UNSET, cmap: str = "plasma", clim: tuple[float, float] | None = None, show_colorbar: bool = True, reference_arrow: float = 0.1, station_labels: bool = True, title: str = "", figsize: tuple[float, float] = (8, 7), recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ax: plt.Axes | None = None, ) -> plt.Axes: r"""Map-view induction arrows at one period. Real (solid) and imaginary (dashed) Parkinson arrows at every station, coloured by |T| magnitude. Parameters ---------- sites : Sites-like period : float Target period in seconds. convention : {'park', 'wiese', 'real', 'imag'} show_real, show_imag : bool scale : float or _UNSET Arrow scale factor (auto from station spacing). cmap : str clim : (vmin, vmax) or None show_colorbar : bool reference_arrow : float Length of the scale-bar reference arrow. station_labels : bool title : str figsize, ax : standard Returns ------- ax : Axes """ S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) names, xs, ys, re_vecs, im_vecs = [], [], [], [], [] for i, ed in enumerate(_iter_items(S)): T, t, fr = _get_t_block(ed) if T is None or t is None: continue per = 1.0 / fr j = _nearest_idx(per, np.array([float(period)]))[0] tx = t[j, 0] ty = t[j, 1] re_vecs.append([np.real(tx), np.real(ty)]) im_vecs.append([np.imag(tx), np.imag(ty)]) x, y = _station_xy(ed, i) xs.append(x) ys.append(y) names.append(_name(ed, i)) if not names: if ax is None: _, ax = plt.subplots(figsize=figsize) ax.text( 0.5, 0.5, "no tipper data", ha="center", va="center", transform=ax.transAxes, ) return ax xs = np.array(xs, float) ys = np.array(ys, float) re = np.array(re_vecs, float) im = np.array(im_vecs, float) if scale is _UNSET: dist = ( (np.diff(xs) ** 2 + np.diff(ys) ** 2).mean() ** 0.5 if len(xs) > 1 else 1.0 ) scale = float(dist * 0.4) mag = np.hypot(re[:, 0], re[:, 1]) norm = mcolors.Normalize( vmin=clim[0] if clim else mag.min(), vmax=clim[1] if clim else mag.max() + 1e-12, ) cm = plt.get_cmap(cmap) if ax is None: # NOTE: constrained_layout=True silently drops the colorbar's # tick labels and axis label when the colorbar axes is created # via make_axes_locatable (as add_colorbar does below) -- the # two layout mechanisms don't compose. Use tight_layout() at # the end instead. fig, ax = plt.subplots(figsize=figsize) else: fig = ax.get_figure() label_idx = ( set( _thin_label_indices( len(names), width_in=fig.get_figwidth() ).tolist() ) if station_labels else set() ) for k in range(len(names)): c = cm(norm(mag[k])) if show_real: ax.annotate( "", xy=(xs[k] + re[k, 0] * scale, ys[k] + re[k, 1] * scale), xytext=(xs[k], ys[k]), arrowprops=dict( arrowstyle="-|>", color=c, lw=1.8, mutation_scale=10 ), ) if show_imag: ax.annotate( "", xy=(xs[k] + im[k, 0] * scale, ys[k] + im[k, 1] * scale), xytext=(xs[k], ys[k]), arrowprops=dict( arrowstyle="-|>", color=c, lw=1.2, linestyle="dashed", mutation_scale=8, ), ) ax.plot(xs[k], ys[k], "v", ms=5, color="0.3", zorder=5) if k in label_idx: ax.annotate( names[k], (xs[k], ys[k]), xytext=(3, 4), textcoords="offset points", fontsize=6.5, color="0.4", ) # Reference scale arrow x0 = xs.min() y0 = ys.min() - 0.15 * (np.ptp(ys) + 1e-6) ax.annotate( "", xy=(x0 + reference_arrow * scale, y0), xytext=(x0, y0), arrowprops=dict( arrowstyle="-|>", color="0.2", lw=1.8, mutation_scale=10 ), ) ax.text( x0 + 0.5 * reference_arrow * scale, y0, f"|T|={reference_arrow}", ha="center", va="top", fontsize=7, color="0.3", ) if show_colorbar: sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm) sm.set_array([]) add_colorbar(sm, ax, label="|T|", size="3%", pad=0.04, max_ticks=5) from matplotlib.lines import Line2D handles = [] if show_real: handles.append( Line2D([], [], color="0.4", lw=1.8, label="Real (Parkinson)") ) if show_imag: handles.append( Line2D([], [], color="0.4", lw=1.2, ls="--", label="Imaginary") ) if handles: ax.legend( handles=handles, fontsize=8, framealpha=0.8, loc="upper right" ) y_extent = [ys, [y0]] if show_real: y_extent.append(ys + re[:, 1] * scale) if show_imag: y_extent.append(ys + im[:, 1] * scale) _set_map_aspect(ax, np.concatenate(y_extent), scale) ax.set_xlabel("x / Easting (m)", fontsize=9) ax.set_ylabel("y / Northing (m)", fontsize=9) ax.set_title( title or f"Induction arrows — T = {period:g} s [{convention}]", fontsize=10, pad=6, ) _spine_style(ax) fig.tight_layout() return ax
# ───────────────────────────────────────────────────────────────────────────── # 14) Period section — |T| pseudo-section (station × log-period) # ─────────────────────────────────────────────────────────────────────────────
[docs] def plot_induction_section( sites: Any, *, component: str = "abs", n_periods: int = 20, cmap: str = "RdBu_r", clim: tuple[float, float] | None = None, section: str | SectionStyle = "pseudosection", title: str = "", figsize: tuple[float, float] | None = None, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ax: plt.Axes | None = None, ) -> plt.Axes: """Period × station pseudo-section coloured by |T| magnitude. Parameters ---------- sites : Sites-like component : {'real', 'imag', 'abs'} n_periods : int cmap : str clim : (vmin, vmax) or None section : str or SectionStyle title, figsize, ax : standard Returns ------- ax : Axes """ S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) tip_dict, freq_dict, xy_dict = _collect_tipper_spectrum(S) if not tip_dict: if ax is None: _, ax = plt.subplots() ax.text( 0.5, 0.5, "no tipper", ha="center", va="center", transform=ax.transAxes, ) return ax all_fr = list(freq_dict.values()) f_min = max(float(f.min()) for f in all_fr) f_max = min(float(f.max()) for f in all_fr) f_grid = np.logspace(np.log10(f_min), np.log10(f_max), n_periods) per = 1.0 / f_grid names = list(tip_dict.keys()) n_st = len(names) mat = np.full((n_st, n_periods), np.nan) for si, name in enumerate(names): t, fr = tip_dict[name], freq_dict[name] for pi, f0 in enumerate(f_grid): j = np.argmin(np.abs(fr - f0)) tx, ty = t[j, 0], t[j, 1] if component == "real": mat[si, pi] = np.hypot(np.real(tx), np.real(ty)) elif component == "imag": mat[si, pi] = np.hypot(np.imag(tx), np.imag(ty)) else: mat[si, pi] = np.hypot(np.abs(tx), np.abs(ty)) y_log = np.log10(per) sty = ( section if isinstance(section, SectionStyle) else PYCSAMT_SECTION.style_for(str(section)).copy() ) if figsize is None: figsize = sty.figsize_for(n_stations=n_st, n_y=n_periods) if ax is None: _, ax = plt.subplots(figsize=figsize, constrained_layout=True) ax.get_figure() else: ax.get_figure() st_x = np.arange(n_st, dtype=float) x_edges = np.r_[st_x[0] - 0.5, st_x + 0.5] if len(y_log) > 1: dy = np.abs(np.diff(y_log)) / 2.0 sgn = np.sign(np.diff(y_log)) y_edges = np.r_[ y_log[0] - dy[0], y_log[:-1] + sgn * dy, y_log[-1] + sgn[-1] * dy[-1], ] else: y_edges = np.r_[y_log[0] - 0.2, y_log[0] + 0.2] vmin, vmax = ( clim if clim else (0.0, float(np.nanpercentile(mat, 95)) + 1e-12) ) pc = ax.pcolormesh( x_edges, y_edges, mat.T, cmap=cmap, shading="flat", vmin=vmin, vmax=vmax, ) sty.add_colorbar(pc, ax, label=f"|T| {component}") if y_log[0] > y_log[-1]: ax.invert_yaxis() sty.apply_axis(ax, xlabel="Station", ylabel=LOG10_PERIOD_LABEL) sty.apply_stations(ax, st_x, names) ax.set_title( title or f"Tipper section [{component}]", fontsize=10, pad=6 ) _spine_style(ax) return ax
# ───────────────────────────────────────────────────────────────────────────── # 15) Convention comparison — 2×2 panel # ─────────────────────────────────────────────────────────────────────────────
[docs] def plot_induction_convention( sites: Any, *, period: float = 1.0, scale: float = _UNSET, station_labels: bool = True, title: str = "", axes=None, figsize: tuple[float, float] = (11, 10), recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> np.ndarray: r"""2×2 panel: Parkinson/Wiese × Real/Imaginary conventions. +-----------------------+-----------------------+ | Parkinson — Real | Parkinson — Imaginary | +-----------------------+-----------------------+ | Wiese — Real | Wiese — Imaginary | +-----------------------+-----------------------+ Parameters ---------- sites : Sites-like period : float scale, station_labels, title, figsize : standard Returns ------- axes : ndarray of Axes, shape (2, 2) """ S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) names, xs, ys, txs, tys = [], [], [], [], [] for i, ed in enumerate(_iter_items(S)): T, t, fr = _get_t_block(ed) if T is None or t is None: continue per = 1.0 / fr j = _nearest_idx(per, np.array([float(period)]))[0] txs.append(t[j, 0]) tys.append(t[j, 1]) x, y = _station_xy(ed, i) xs.append(x) ys.append(y) names.append(_name(ed, i)) axes_given = _axes_list(axes, 4) if axes is not None else None if not names: if axes_given is None: fig, axs = plt.subplots(2, 2, figsize=figsize) axs_flat = axs.ravel() else: axs_flat = np.asarray(axes_given, dtype=object) fig = axs_flat[0].figure for ax in axs_flat: ax.text( 0.5, 0.5, "no tipper", ha="center", va="center", transform=ax.transAxes, ) return np.asarray(axs_flat, dtype=object).reshape(2, 2) xs = np.array(xs, float) ys = np.array(ys, float) txs = np.array(txs) tys = np.array(tys) if scale is _UNSET: dist = ( (np.diff(xs) ** 2 + np.diff(ys) ** 2).mean() ** 0.5 if len(xs) > 1 else 1.0 ) scale = float(dist * 0.4) park_re = np.vstack([np.real(txs), np.real(tys)]).T park_im = np.vstack([np.imag(txs), np.imag(tys)]).T wies_re = np.vstack([-park_re[:, 1], park_re[:, 0]]).T wies_im = np.vstack([-park_im[:, 1], park_im[:, 0]]).T # 2x2 grid: each panel is roughly half the figure width, so thin # more aggressively than a single full-width map would. label_idx = ( set( _thin_label_indices( len(names), width_in=figsize[0] / 2.0 ).tolist() ) if station_labels else set() ) def _panel(ax, vecs, label): u, v = vecs[:, 0] * scale, vecs[:, 1] * scale mag = np.hypot(u, v) / (scale + 1e-24) norm = mcolors.Normalize(vmin=0, vmax=max(mag.max(), 1e-6)) cm = plt.get_cmap("plasma") for k in range(len(names)): c = cm(norm(mag[k])) ax.annotate( "", xy=(xs[k] + u[k], ys[k] + v[k]), xytext=(xs[k], ys[k]), arrowprops=dict( arrowstyle="-|>", color=c, lw=1.6, mutation_scale=10 ), ) ax.plot(xs[k], ys[k], "v", ms=5, color="0.3", zorder=5) if k in label_idx: ax.annotate( names[k], (xs[k], ys[k]), xytext=(3, 4), textcoords="offset points", fontsize=6, color="0.4", ) # A forced equal aspect makes sense for a true 2-D map, but for # a near-linear profile (yspan << xspan) it stretches the axes # into a mostly-blank tall strip with the arrows squeezed into # a thin band -- switch to a tight, non-equal window in that case. _set_map_aspect(ax, ys + v, scale) ax.set_title(label, fontsize=9, pad=5) ax.set_xlabel("x (m)", fontsize=8) ax.set_ylabel("y (m)", fontsize=8) _spine_style(ax) axes_given = _axes_list(axes, 4) if axes is not None else None if axes_given is None: fig, axs = plt.subplots( 2, 2, figsize=figsize, constrained_layout=True ) else: axs = np.asarray(axes_given, dtype=object).reshape(2, 2) fig = axs.ravel()[0].figure _panel(axs[0, 0], park_re, "Parkinson — Real") _panel(axs[0, 1], park_im, "Parkinson — Imaginary") _panel(axs[1, 0], wies_re, "Wiese — Real") _panel(axs[1, 1], wies_im, "Wiese — Imaginary") fig.suptitle( title or f"Induction arrow conventions — T = {period:g} s", fontsize=11, y=1.01, ) return axs
# ───────────────────────────────────────────────────────────────────────────── # 16) Polar tipper plot # ─────────────────────────────────────────────────────────────────────────────
[docs] def plot_tipper_polar( sites: Any, *, station: str | None = None, component: str = "real", cmap: str = _UNSET, lw: float = _UNSET, alpha: float = _UNSET, title: str = "", figsize: tuple[float, float] = (5.5, 5.5), recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ax: plt.Axes | None = None, ) -> plt.Axes: """Polar view: tipper azimuth (angle) and magnitude (radius) vs period. Each frequency is one scatter point; colour encodes log₁₀(period). North (0°) = up, clockwise positive, following geomagnetic convention. Parameters ---------- sites : Sites-like station : str or None component : {'real', 'imag', 'abs'} cmap : str or _UNSET lw, alpha : float or _UNSET title, figsize, ax : standard Returns ------- ax : polar Axes """ _ml = PYCSAMT_STYLE.multiline if lw is _UNSET: lw = _ml.lw if alpha is _UNSET: alpha = _ml.alpha if cmap is _UNSET: cmap = "viridis" S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) st, ed = _pick_station(S, station) T, t, fr = _get_t_block(ed) if T is None or t is None: if ax is None: fig = plt.figure(figsize=figsize) ax = fig.add_subplot(111, polar=True) hide_polar_radius_labels(ax) ax.set_title("no tipper") return ax per = 1.0 / fr tx = t[:, 0] ty = t[:, 1] if component == "real": u, v = np.real(tx), np.real(ty) elif component == "imag": u, v = np.imag(tx), np.imag(ty) else: u, v = np.abs(tx), np.abs(ty) azimuth = np.arctan2(v, u) magnitude = np.hypot(u, v) log_per = np.log10(np.maximum(per, 1e-9)) norm = mcolors.Normalize(vmin=log_per.min(), vmax=log_per.max()) if ax is None: fig = plt.figure(figsize=figsize) ax = fig.add_subplot(111, polar=True) sc = ax.scatter( azimuth, magnitude, c=log_per, cmap=cmap, norm=norm, s=30, alpha=alpha, edgecolors="none", zorder=4, ) order = np.argsort(per) ax.plot( azimuth[order], magnitude[order], lw=lw * 0.7, alpha=alpha * 0.5, color="0.6", zorder=3, ) ax.set_theta_zero_location("N") ax.set_theta_direction(-1) ax.grid(True, alpha=0.3) hide_polar_radius_labels(ax) ax.set_title( title or f"{st} — tipper polar [{component}]", fontsize=10, pad=10 ) cbar = add_polar_colorbar( sc, ax, label=r"$\log_{10}T$ (s)", pad=0.10, shrink=0.72, aspect=18, max_ticks=5, ) cbar.ax.tick_params(labelsize=7) return ax
# ───────────────────────────────────────────────────────────────────────────── # 17) Induction-arrow rose diagram # ─────────────────────────────────────────────────────────────────────────────
[docs] def plot_induction_rose( sites: Any, *, component: str = "real", pband: tuple[float, float] | None = None, nbins: int = 36, style=_UNSET, title: str = "", figsize: tuple[float, float] = (5, 5), recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ax: plt.Axes | None = None, ) -> plt.Axes: """Rose diagram of induction arrow azimuths (all stations & periods). Parameters ---------- sites : Sites-like component : {'real', 'imag', 'abs'} pband : (T_min, T_max) or None nbins : int (default 36 → 10° bins) style : RoseStyle or str or _UNSET title, figsize, ax : standard Returns ------- ax : polar Axes """ rs = ( PYCSAMT_STYLE.rose.copy() if style is _UNSET else resolve_rose_style(style) ) S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) azimuths: list[float] = [] for _i, ed in enumerate(_iter_items(S)): T, t, fr = _get_t_block(ed) if T is None or t is None: continue per = 1.0 / fr mask = np.ones(fr.size, bool) if pband: lo, hi = float(pband[0]), float(pband[1]) mask &= (per >= lo) & (per <= hi) if not mask.any(): continue tx, ty = t[mask, 0], t[mask, 1] if component == "real": u, v = np.real(tx), np.real(ty) elif component == "imag": u, v = np.imag(tx), np.imag(ty) else: u, v = np.abs(tx), np.abs(ty) azimuths.extend((np.degrees(np.arctan2(v, u)) % 360.0).tolist()) if not azimuths: if ax is None: fig = plt.figure(figsize=figsize) ax = fig.add_subplot(111, polar=True) hide_polar_radius_labels(ax) ax.set_title("no arrows") return ax az = np.array(azimuths, float) edges = np.linspace(0, 360, nbins + 1) counts, _ = np.histogram(az, bins=edges) counts = counts / counts.sum() * 100.0 theta = np.radians(0.5 * (edges[:-1] + edges[1:])) width = np.radians(360.0 / nbins) * 0.92 r_max = counts.max() or 1.0 if ax is None: fig = plt.figure(figsize=figsize) ax = fig.add_subplot(111, polar=True) if rs.bar_style == "gradient": _cm = plt.get_cmap(rs.cmap) cols = [_cm(c / (r_max + 1e-12)) for c in counts] else: cols = rs.bar_color ax.bar( theta, counts, width=width, color=cols, alpha=rs.bar_alpha, edgecolor=rs.bar_edgecolor, linewidth=rs.bar_edgelw, zorder=3, ) ax.plot( np.linspace(0, 2 * np.pi, 256), np.full(256, r_max), lw=rs.outer_ring_lw, color=rs.outer_ring_color, zorder=4, ) if rs.show_mean and az.size > 0: mean_az = ( np.degrees( np.arctan2( np.sin(np.radians(az)).sum(), np.cos(np.radians(az)).sum() ) ) % 360.0 ) r_m = np.radians(mean_az) ax.plot( [r_m, r_m + np.pi], [r_max * 0.9] * 2, lw=rs.mean_lw, color=rs.mean_color, ls=rs.mean_ls, zorder=5, ) ax.set_theta_zero_location("N") ax.set_theta_direction(-1) ax.grid(True, alpha=0.25) hide_polar_radius_labels(ax) ax.set_title( title or f"Induction arrow rose [{component}]", fontsize=10, pad=12 ) return ax
# ───────────────────────────────────────────────────────────────────────────── # 18) Spectra-direct wrappers (no Sites needed) # ─────────────────────────────────────────────────────────────────────────────
[docs] def plot_induction_map_from_spectra( sp_input: Any, *, period: float = 1.0, coords: dict[str, tuple[float, float]] | None = None, show_real: bool = True, show_imag: bool = True, scale: float = _UNSET, cmap: str = "plasma", station_labels: bool = True, title: str = "", figsize: tuple[float, float] = (8, 5), ax: plt.Axes | None = None, ) -> plt.Axes: """Map-view induction arrows from :class:`~pycsamt.seg.spectra.Spectra`. Parameters ---------- sp_input : Spectra, list, or dict[str, Spectra] period : float coords : dict[name → (x, y)] or None Station positions. Equidistant line when ``None``. show_real, show_imag : bool scale, cmap, station_labels, title, figsize, ax : standard Returns ------- ax : Axes """ tip_dict, freq_dict = _tipper_from_spectra(sp_input) if not tip_dict: if ax is None: _, ax = plt.subplots(figsize=figsize) ax.text( 0.5, 0.5, "no tipper", ha="center", va="center", transform=ax.transAxes, ) return ax names = list(tip_dict.keys()) if coords is None: coords = {n: (float(i), 0.0) for i, n in enumerate(names)} xs = np.array( [coords.get(n, (i, 0.0))[0] for i, n in enumerate(names)], float ) ys = np.array( [coords.get(n, (i, 0.0))[1] for i, n in enumerate(names)], float ) re_list, im_list = [], [] for name in names: t = tip_dict[name] fr = freq_dict[name] j = np.argmin(np.abs(1.0 / np.maximum(fr, 1e-24) - period)) tx, ty = t[j, 0], t[j, 1] re_list.append([np.real(tx), np.real(ty)]) im_list.append([np.imag(tx), np.imag(ty)]) re = np.array(re_list, float) im = np.array(im_list, float) if scale is _UNSET: dist = abs(np.diff(xs)).mean() if len(xs) > 1 else 1.0 scale = float(dist * 0.4) if dist > 1e-9 else 0.3 mag = np.hypot(re[:, 0], re[:, 1]) norm = mcolors.Normalize(vmin=mag.min(), vmax=mag.max() + 1e-12) cm = plt.get_cmap(cmap) if ax is None: # NOTE: constrained_layout=True silently drops the colorbar's # tick labels and axis label when the colorbar axes is created # via make_axes_locatable (as add_colorbar does below) -- the # two layout mechanisms don't compose. Use tight_layout() at # the end instead. fig, ax = plt.subplots(figsize=figsize) else: fig = ax.get_figure() label_idx = ( set( _thin_label_indices( len(names), width_in=fig.get_figwidth() ).tolist() ) if station_labels else set() ) for k, name in enumerate(names): c = cm(norm(mag[k])) if show_real: ax.annotate( "", xy=(xs[k] + re[k, 0] * scale, ys[k] + re[k, 1] * scale), xytext=(xs[k], ys[k]), arrowprops=dict( arrowstyle="-|>", color=c, lw=1.8, mutation_scale=10 ), ) if show_imag: ax.annotate( "", xy=(xs[k] + im[k, 0] * scale, ys[k] + im[k, 1] * scale), xytext=(xs[k], ys[k]), arrowprops=dict( arrowstyle="-|>", color=c, lw=1.2, linestyle="dashed", mutation_scale=8, ), ) ax.plot(xs[k], ys[k], "v", ms=5, color="0.3", zorder=5) if k in label_idx: ax.annotate( name, (xs[k], ys[k]), xytext=(3, 4), textcoords="offset points", fontsize=6.5, color="0.4", ) sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm) sm.set_array([]) add_colorbar(sm, ax, label="|T|", size="3%", pad=0.04, max_ticks=5) from matplotlib.lines import Line2D handles = [] if show_real: handles.append(Line2D([], [], color="0.4", lw=1.8, label="Real")) if show_imag: handles.append( Line2D([], [], color="0.4", lw=1.2, ls="--", label="Imaginary") ) if handles: ax.legend(handles=handles, fontsize=8, framealpha=0.8) y_extent = [ys] if show_real: y_extent.append(ys + re[:, 1] * scale) if show_imag: y_extent.append(ys + im[:, 1] * scale) _set_map_aspect(ax, np.concatenate(y_extent), scale) ax.set_xlabel("Station", fontsize=9) ax.set_title( title or f"Induction arrows from spectra — T={period:g} s", fontsize=10, pad=6, ) _spine_style(ax) fig.tight_layout() return ax
[docs] def plot_tipper_polar_from_spectra( sp: Any, *, component: str = "real", cmap: str = "viridis", title: str = "", ax=None, figsize: tuple[float, float] = (5.5, 5.5), ) -> plt.Axes: """Polar tipper from a :class:`~pycsamt.seg.spectra.Spectra` object. Parameters ---------- sp : Spectra component : {'real', 'imag', 'abs'} cmap, title, figsize : standard Returns ------- ax : polar Axes """ tip_dict, freq_dict = _tipper_from_spectra(sp) if not tip_dict: if ax is None: fig = plt.figure(figsize=figsize) ax = fig.add_subplot(111, polar=True) else: fig = ax.figure hide_polar_radius_labels(ax) ax.set_title("no tipper") return ax name = list(tip_dict.keys())[0] t = tip_dict[name] fr = freq_dict[name] per = 1.0 / np.maximum(fr, 1e-24) tx, ty = t[:, 0], t[:, 1] if component == "real": u, v = np.real(tx), np.real(ty) elif component == "imag": u, v = np.imag(tx), np.imag(ty) else: u, v = np.abs(tx), np.abs(ty) azimuth = np.arctan2(v, u) magnitude = np.hypot(u, v) log_per = np.log10(np.maximum(per, 1e-9)) norm = mcolors.Normalize(vmin=log_per.min(), vmax=log_per.max()) if ax is None: fig = plt.figure(figsize=figsize) ax = fig.add_subplot(111, polar=True) else: fig = ax.figure sc = ax.scatter( azimuth, magnitude, c=log_per, cmap=cmap, norm=norm, s=30, alpha=0.85, edgecolors="none", zorder=4, ) order = np.argsort(per) ax.plot(azimuth[order], magnitude[order], lw=0.8, alpha=0.4, color="0.6") ax.set_theta_zero_location("N") ax.set_theta_direction(-1) ax.grid(True, alpha=0.3) hide_polar_radius_labels(ax) ax.set_title( title or f"{name} — polar tipper [{component}]", fontsize=10, pad=10 ) add_polar_colorbar( sc, ax, label=r"$\log_{10}T$ (s)", pad=0.10, shrink=0.72, aspect=18, max_ticks=5, ) return ax
[docs] def plot_induction_rose_from_spectra( sp_input: Any, *, component: str = "real", pband: tuple[float, float] | None = None, nbins: int = 36, style=_UNSET, title: str = "", figsize: tuple[float, float] = (5, 5), ax: plt.Axes | None = None, ) -> plt.Axes: """Rose diagram of induction arrow directions from Spectra objects. Parameters ---------- sp_input : Spectra, list, or dict[str, Spectra] component : {'real', 'imag', 'abs'} pband : (T_min, T_max) or None nbins : int style : RoseStyle or str or _UNSET title, figsize, ax : standard Returns ------- ax : polar Axes """ rs = ( PYCSAMT_STYLE.rose.copy() if style is _UNSET else resolve_rose_style(style) ) tip_dict, freq_dict = _tipper_from_spectra(sp_input) azimuths: list[float] = [] for name, t in tip_dict.items(): fr = freq_dict[name] per = 1.0 / np.maximum(fr, 1e-24) mask = np.ones(fr.size, bool) if pband: lo, hi = float(pband[0]), float(pband[1]) mask &= (per >= lo) & (per <= hi) if not mask.any(): continue tx, ty = t[mask, 0], t[mask, 1] if component == "real": u, v = np.real(tx), np.real(ty) elif component == "imag": u, v = np.imag(tx), np.imag(ty) else: u, v = np.abs(tx), np.abs(ty) azimuths.extend((np.degrees(np.arctan2(v, u)) % 360.0).tolist()) if not azimuths: if ax is None: fig = plt.figure(figsize=figsize) ax = fig.add_subplot(111, polar=True) hide_polar_radius_labels(ax) ax.set_title("no arrows") return ax az = np.array(azimuths, float) edges = np.linspace(0, 360, nbins + 1) counts, _ = np.histogram(az, bins=edges) counts = counts / counts.sum() * 100.0 theta = np.radians(0.5 * (edges[:-1] + edges[1:])) width = np.radians(360.0 / nbins) * 0.92 r_max = counts.max() or 1.0 if ax is None: fig = plt.figure(figsize=figsize) ax = fig.add_subplot(111, polar=True) if rs.bar_style == "gradient": _cm = plt.get_cmap(rs.cmap) cols = [_cm(c / (r_max + 1e-12)) for c in counts] else: cols = rs.bar_color ax.bar( theta, counts, width=width, color=cols, alpha=rs.bar_alpha, edgecolor=rs.bar_edgecolor, linewidth=rs.bar_edgelw, zorder=3, ) ax.plot( np.linspace(0, 2 * np.pi, 256), np.full(256, r_max), lw=rs.outer_ring_lw, color=rs.outer_ring_color, zorder=4, ) if rs.show_mean and az.size > 0: mean_az = ( np.degrees( np.arctan2( np.sin(np.radians(az)).sum(), np.cos(np.radians(az)).sum() ) ) % 360.0 ) r_m = np.radians(mean_az) ax.plot( [r_m, r_m + np.pi], [r_max * 0.9] * 2, lw=rs.mean_lw, color=rs.mean_color, ls=rs.mean_ls, zorder=5, ) ax.set_theta_zero_location("N") ax.set_theta_direction(-1) ax.grid(True, alpha=0.25) hide_polar_radius_labels(ax) ax.set_title( title or f"Induction rose (spectra) [{component}]", fontsize=10, pad=12, ) return ax
# ───────────────────────────────────────────────────────────────────────────── # 19) Multi-period induction vector map (Boukhalfa et al. 2020 style) # ───────────────────────────────────────────────────────────────────────────── def _synthetic_background( x0: float, x1: float, y0: float, y1: float, *, nx: int = 120, ny: int = 100, seed: int = 42, elev_min: float = 1000.0, elev_max: float = 2200.0, ) -> tuple[np.ndarray, tuple[float, float, float, float]]: """Return a smooth synthetic terrain image (ny, nx) and its extent.""" from scipy.ndimage import gaussian_filter as _gf rng = np.random.default_rng(seed) raw = rng.normal(size=(ny, nx)) raw = _gf(raw, sigma=12) raw = (raw - raw.min()) / (np.ptp(raw) + 1e-12) bg = raw * (elev_max - elev_min) + elev_min extent = (x0, x1, y0, y1) return bg, extent
[docs] def plot_induction_multiperiod_map( sites: Any, *, periods: Sequence[float] = (1.0, 10.0, 100.0, 1000.0), tipper_data: dict[str, np.ndarray] | None = None, convention: str = "park", panel_labels: Sequence[str] | None = None, background: np.ndarray | None = None, background_extent: tuple[float, float, float, float] | None = None, background_cmap: str = "terrain", background_alpha: float = 0.75, background_clim: tuple[float, float] | None = None, bg_colorbar_label: str = "Elevation (m)", bg_colorbar_side: str = "right", show_background_cbar: bool = True, arrow_color: str = "black", arrow_scale: float = _UNSET, arrow_lw: float = 1.6, reference_arrow: float = 0.1, reference_panel: int = 0, reference_label: str = _UNSET, show_stations: bool = True, station_labels: bool = False, annotations: dict[str, Any] | None = None, annotation_color: str = "navy", annotation_fontsize: float = 8.0, title: str = "", axes=None, figsize: Any = _UNSET, panel_height: float = 3.0, panel_width: float = 8.5, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> tuple[plt.Figure, np.ndarray]: r"""Stacked multi-period induction vector map. Produces one panel per period (stacked vertically), each showing the **real** Parkinson induction vectors on a background colour map (elevation, resistivity, or any 2-D raster). The layout mimics the style of Boukhalfa et al. (2020, *GJI*) Fig. 7 and the paper figure described in the session above. Visual conventions ------------------ * **Arrows**: black solid vectors using the Parkinson (1962) convention (real component pointing toward the conductor). * **Background**: smooth terrain coloured with *background_cmap*; a synthetic gradient is auto-generated when *background* is ``None``. * **Reference vector**: drawn in the first (or *reference_panel*) sub-plot; labelled with its normalised length. * **Shared colorbar**: placed on the **right** edge of the figure, spanning all panels. * **Panel labels**: ``"(A) 1 s"``, ``"(B) 10 s"``, … placed in the lower-left corner of each panel. * **Geological / site annotations**: optional blue text labels supplied via *annotations*. Parameters ---------- sites : Sites-like EDI collection accepted by :func:`ensure_sites`. When the EDIs carry no tipper (``Tipper.tipper`` all-zero), pass synthetic tipper via *tipper_data*. periods : sequence of float Target periods in seconds, one panel each. tipper_data : dict {period → (n_sites, 2) complex array}, optional Explicit tipper override. Keys must match *periods* (nearest match is used). Column 0 = T_x, column 1 = T_y. When absent, the tipper is read from the EDIs. convention : str Arrow convention. ``"park"`` (Parkinson real) is the only one used in published induction-vector maps; ``"wiese"`` is supported. panel_labels : sequence of str, optional Panel corner labels (e.g. ``["(A) 1 s", "(B) 10 s", ...]``). Auto-generated when ``None``. background : ndarray (ny, nx), optional Pre-computed background raster. A smooth synthetic terrain is generated when ``None``. background_extent : (x_left, x_right, y_bottom, y_top), optional Geographic extent for the background raster. Auto-inferred from station positions when ``None``. background_cmap : str Colormap for the background. Default ``"terrain"`` (green→brown elevation appearance). background_alpha : float Background opacity (0–1). background_clim : (vmin, vmax) or None bg_colorbar_label : str Label for the shared colorbar. bg_colorbar_side : {'right', 'left', 'top', 'bottom'}, default 'right' Side of the figure where the shared background colorbar is placed. Right-side placement is the package default because it keeps section axes and station labels visually grouped. show_background_cbar : bool arrow_color : str Single colour for all induction vectors. Default ``"black"``. arrow_scale : float or _UNSET Multiplier for arrow length in data units. Auto-computed from the typical station spacing when ``_UNSET``. arrow_lw : float Arrow shaft line width in points. reference_arrow : float Normalised length of the scale-reference arrow drawn in the *reference_panel* sub-plot. Default 0.1. reference_panel : int Sub-plot index (0-based) where the reference arrow appears. reference_label : str or _UNSET Text for the reference arrow. Defaults to ``"Vector length {reference_arrow}"``. show_stations : bool Draw a small marker (``▼``) at each station position. station_labels : bool Annotate station names next to markers. annotations : dict {label: (x, y)} or {label: (x, y, {fontsize, color, …})}, optional Geological or site annotations drawn in *annotation_color* on **every** panel. annotation_color : str Default colour for *annotations*. annotation_fontsize : float title : str Figure suptitle. figsize : (float, float) or _UNSET Auto-computed from *panel_height*, *panel_width*, and number of panels when omitted. panel_height, panel_width : float Per-panel size in inches used for auto *figsize*. recursive, on_dup, strict, verbose : standard ensure_sites kwargs. Returns ------- fig : Figure axes : ndarray of Axes, shape (n_periods,) Examples -------- Real data with tipper:: fig, axs = plot_induction_multiperiod_map( "site.edi", periods=[1, 10, 100, 1000], ) Synthetic tipper supplied explicitly:: tipper = {1.0: st_tips_1s, 10.0: st_tips_10s, 100.0: st_tips_100s} fig, axs = plot_induction_multiperiod_map( "profile/*.edi", periods=[1, 10, 100], tipper_data=tipper, ) """ import string import matplotlib.gridspec as gridspec S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) # ── Collect station positions ────────────────────────────────────────── all_items = list(_iter_items(S)) st_names: list[str] = [] st_xy: list[tuple[float, float]] = [] for i, ed in enumerate(all_items): st_names.append(_name(ed, i)) st_xy.append(_station_xy(ed, i)) st_xy = np.array(st_xy, float) # (n, 2) n_st = len(st_names) axes_given = _axes_list(axes, 1) if axes is not None else None if n_st == 0: if axes_given is None: fig = plt.figure() ax = fig.add_subplot(111) else: ax = axes_given[0] fig = ax.figure ax.text( 0.5, 0.5, "no sites", ha="center", va="center", transform=ax.transAxes, ) return fig, np.array([ax]) xs, ys = st_xy[:, 0], st_xy[:, 1] # ── Auto scale from station spacing ─────────────────────────────────── if arrow_scale is _UNSET: if n_st > 1: dists = np.sqrt(np.diff(xs) ** 2 + np.diff(ys) ** 2) med = ( np.median(dists[dists > 1e-9]) if dists[dists > 1e-9].size else 1.0 ) else: med = 1.0 arrow_scale = float(med * 0.35) # ── Build background ────────────────────────────────────────────────── pad_x = max((np.ptp(xs) + 1e-9) * 0.12, arrow_scale * 2) pad_y = max((np.ptp(ys) + 1e-9) * 0.12, arrow_scale * 2) ext = ( xs.min() - pad_x, xs.max() + pad_x, ys.min() - pad_y, ys.max() + pad_y, ) if background is None: background, _ = _synthetic_background( ext[0], ext[1], ext[2], ext[3], elev_min=1000.0, elev_max=2200.0, ) if background_extent is None: background_extent = ext bg_vmin = ( background_clim[0] if background_clim else float(np.nanpercentile(background, 2)) ) bg_vmax = ( background_clim[1] if background_clim else float(np.nanpercentile(background, 98)) ) # ── Panel labels ────────────────────────────────────────────────────── n_per = len(periods) letters = list(string.ascii_uppercase) if panel_labels is None: panel_labels = [ f"({letters[k]}) {p:g} s" for k, p in enumerate(periods) ] # ── Reference label ─────────────────────────────────────────────────── if reference_label is _UNSET: reference_label = f"Vector length {reference_arrow}" # ── Tipper per period ───────────────────────────────────────────────── def _get_tip(period: float) -> np.ndarray | None: """Return (n_st, 2) complex tipper at *period*, or None.""" if tipper_data is not None: # find nearest key keys = np.array(list(tipper_data.keys()), float) k = int(np.argmin(np.abs(keys - period))) arr = np.asarray(tipper_data[keys[k]], complex) if arr.shape[0] == n_st: return arr # fall back to EDI tipper vecs: list[complex] = [] for _i, ed in enumerate(all_items): T, t, fr = _get_t_block(ed) if T is None or t is None: vecs.append(0.0 + 0.0j) continue per = 1.0 / fr j = _nearest_idx(per, np.array([float(period)]))[0] vecs.append(t[j, 0] + 0.0j) # Tx only (first component) tx_arr = np.array([v.real for v in vecs], float) ty_arr = np.zeros(n_st, float) # no Ty if single component return (tx_arr + 1j * ty_arr)[:, np.newaxis] # fall-through # ── Figure layout ───────────────────────────────────────────────────── if figsize is _UNSET: figsize = (panel_width, panel_height * n_per + 0.6) axes_given = _axes_list(axes, n_per) if axes is not None else None if axes_given is None: fig = plt.figure(figsize=figsize) gs = gridspec.GridSpec( n_per, 1, figure=fig, hspace=0.04, # minimal gap between panels left=0.12, right=0.97, top=0.95 if title else 0.97, bottom=0.06, ) axs = [fig.add_subplot(gs[k]) for k in range(n_per)] else: axs = list(axes_given) fig = axs[0].figure # share axes for ax in axs[1:]: ax.sharex(axs[0]) ax.sharey(axs[0]) # ── Draw each panel ─────────────────────────────────────────────────── im_handle = None for k, (period, ax) in enumerate(zip(periods, axs)): # background im = ax.imshow( background, extent=background_extent, origin="lower", cmap=background_cmap, alpha=background_alpha, vmin=bg_vmin, vmax=bg_vmax, aspect="auto", interpolation="bilinear", zorder=1, ) if im_handle is None: im_handle = im # station markers if show_stations: ax.scatter( xs, ys, marker="v", s=18, color="0.15", zorder=4, linewidths=0 ) if station_labels: for ki, (xi, yi) in enumerate(zip(xs, ys)): ax.annotate( st_names[ki], (xi, yi), xytext=(2, 3), textcoords="offset points", fontsize=5, color="0.25", ) # induction vectors tip_arr = _get_tip(period) # (n_st, 2) or (n_st,) if tip_arr is not None: tip_arr = np.asarray(tip_arr, complex) if tip_arr.ndim == 1: tip_arr = tip_arr[:, np.newaxis] for si in range(n_st): tx = complex(tip_arr[si, 0]) ty = complex(tip_arr[si, 1]) if tip_arr.shape[1] > 1 else 0.0 if convention == "wiese": ux = -float(np.real(ty)) uy = float(np.real(tx)) else: # Parkinson: real component ux = float(np.real(tx)) uy = float(np.real(ty)) mag = np.hypot(ux, uy) if mag < 1e-9: continue ax.annotate( "", xy=(xs[si] + ux * arrow_scale, ys[si] + uy * arrow_scale), xytext=(xs[si], ys[si]), arrowprops=dict( arrowstyle="-|>", color=arrow_color, lw=arrow_lw, mutation_scale=9, ), zorder=5, ) # reference arrow (first panel only) if k == reference_panel: rx = ( background_extent[0] + (background_extent[1] - background_extent[0]) * 0.05 ) ry = ( background_extent[2] + (background_extent[3] - background_extent[2]) * 0.80 ) ax.annotate( "", xy=(rx + reference_arrow * arrow_scale, ry), xytext=(rx, ry), arrowprops=dict( arrowstyle="-|>", color="black", lw=arrow_lw + 0.2, mutation_scale=11, ), zorder=6, ) ax.text( rx + reference_arrow * arrow_scale * 0.5, ry + (background_extent[3] - background_extent[2]) * 0.035, reference_label, ha="center", va="bottom", fontsize=7, color="black", fontweight="bold", zorder=6, ) # geological / site annotations if annotations: for label, info in annotations.items(): if isinstance(info, (list, tuple)) and len(info) >= 2: ax_x, ax_y = float(info[0]), float(info[1]) kw = ( info[2] if len(info) > 2 and isinstance(info[2], dict) else {} ) fc = kw.pop("color", annotation_color) fs = kw.pop("fontsize", annotation_fontsize) fw = kw.pop("fontweight", "bold") ax.text( ax_x, ax_y, label, color=fc, fontsize=fs, fontweight=fw, ha="center", va="center", zorder=7, **kw, ) # panel label in lower-left corner ax.text( 0.015, 0.06, panel_labels[k], transform=ax.transAxes, fontsize=9, color="white", fontweight="bold", va="bottom", ha="left", zorder=8, bbox=dict( boxstyle="round,pad=0.2", fc="black", alpha=0.55, ec="none" ), ) # clean ticks: only bottom panel keeps x labels if k < n_per - 1: plt.setp(ax.get_xticklabels(), visible=False) ax.tick_params(labelsize=7) ax.set_xlim(background_extent[0], background_extent[1]) ax.set_ylim(background_extent[2], background_extent[3]) # x-axis label on bottom panel only axs[-1].set_xlabel("x (m)", fontsize=8) for ax in axs: ax.set_ylabel("y (m)", fontsize=8) # ── Shared background colorbar ──────────────────────────────────────── if show_background_cbar and im_handle is not None: bg_colorbar_side = str(bg_colorbar_side).lower() if bg_colorbar_side not in {"right", "left", "top", "bottom"}: msg = ( "bg_colorbar_side must be 'right', 'left', 'top', " "or 'bottom'." ) raise ValueError(msg) vertical = bg_colorbar_side in {"right", "left"} cbar = fig.colorbar( im_handle, ax=axs, location=bg_colorbar_side, shrink=0.75, pad=0.04 if vertical else 0.08, fraction=0.025, aspect=30, ) cbar.set_label( bg_colorbar_label, rotation=90 if vertical else 0, labelpad=10, fontsize=8, ) cbar.ax.tick_params(labelsize=7) if title: fig.suptitle(title, fontsize=11, y=0.99) return fig, np.array(axs)