Source code for pycsamt.emtools.overview

# pycsamt/emtools/overview.py
"""Combined single-station MT/CSAMT response overview.

:func:`plot_response_overview` reproduces the classic multi-panel MT
"full response" figure (as produced by tools such as MTpy) -- apparent
resistivity and phase for the off-diagonal and diagonal impedance
components, induction arrows, and phase-tensor ellipses, all sharing one
period axis -- while wiring the whole thing through pyCSAMT's own style
and view-control system (:data:`~pycsamt.api.style.PYCSAMT_STYLE`,
:data:`~pycsamt.api.control.PYCSAMT_CONTROL`) so every colour, error bar,
and axis convention matches the rest of ``emtools``.
"""

from __future__ import annotations

import copy
from dataclasses import replace
from typing import Any

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.cm import ScalarMappable
from matplotlib.colors import Normalize
from matplotlib.lines import Line2D
from matplotlib.patches import Ellipse
from matplotlib.transforms import Affine2D

from ..api._rose_style import _UNSET
from ..api.style import PYCSAMT_STYLE
from ._core import _axes_list, _get_t_block, ensure_sites
from .plot import (
    _component_style,
    _comp_slice,
    _control_or_default,
    _err_rhoa,
    _errorbar_from_style,
    _phase_display,
    _rho_display,
    _rhoa_from,
    _x_from_freq,
    _zblk_flex,
)
from .tensor import _attach_cbar, _resolve_cvals, build_phase_tensor_table
from .tf import _pick_station

__all__ = ["plot_response_overview"]


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


def _x_offset(x0: float, delta: float, log_x: bool) -> float:
    """Shift *x0* by *delta* "decades" on a shared period/frequency axis.

    When the shared axis is drawn with a log scale (``log_x=True``, the
    ``"period"``/``"frequency"`` views), *delta* is applied
    multiplicatively (``x0 * 10**delta``) so a fixed *delta* always reads
    as the same visual fraction of a decade regardless of *x0*.  When the
    axis already carries ``log10(...)`` values on a linear scale
    (``"log10_period"``/``"log10_frequency"``), *delta* is simply added.
    """
    return x0 * (10.0**delta) if log_x else x0 + delta


def _legend_handles(components, style_source, colors):
    handles, labels = [], []
    for comp in components:
        style = style_source(comp)
        color = colors.get(comp) if colors else None
        kw = style.plot_kwargs()
        if color is not None:
            kw["color"] = color
        kw.pop("label", None)
        handles.append(Line2D([], [], **kw))
        labels.append(f"$Z_{{{comp}}}$")
    return handles, labels


def _draw_component_column(
    ax_rho,
    ax_phase,
    *,
    z,
    ze,
    fr,
    x,
    components,
    ctl,
    phase_range,
    colors,
    show_error_bars,
    show_phase_error_bars,
    raw,
    force_style,
    log_log_rho,
):
    """Draw rho/phase curves for one impedance-component pair.

    When *log_log_rho* is true (the default, matching ``ctl.rho.view ==
    "log10"``), apparent resistivity is drawn from *raw* Ohm-metre values
    on a true Matplotlib log y-axis (``ax_rho.set_yscale("log")``) instead
    of pre-transforming values to :math:`\\log_{10}\\rho_a` on a linear
    axis. Both display the same information, but only the true log axis
    draws Matplotlib's own per-decade minor-tick grid -- the classic
    "log-log" look -- rather than sparse integer gridlines over already
    logged numbers.
    """
    rho_label = phase_label = ""
    if log_log_rho:
        ax_rho.set_yscale("log")
    for comp in components:
        zz = _comp_slice(z, comp)
        ee = None
        if isinstance(ze, np.ndarray) and ze.shape == z.shape:
            ee = _comp_slice(ze, comp)
        if log_log_rho:
            rho_y = _rhoa_from(zz, fr)
            rho_err = _err_rhoa(zz, ee, fr)
            rho_label = r"$\rho_a$ ($\Omega\,\mathrm{m}$)"
        else:
            rho_y, rho_err, rho_label = _rho_display(zz, ee, fr, ctl)
        phase_y, phase_err, phase_label, display_range = _phase_display(
            zz, ee, ctl, phase_range
        )
        style = _component_style(comp, raw, force_style)
        color = colors.get(comp) if colors else None
        _errorbar_from_style(
            ax_rho,
            x,
            rho_y,
            rho_err,
            style,
            color=color,
            show_error_bars=show_error_bars,
        )
        _errorbar_from_style(
            ax_phase,
            x,
            phase_y,
            phase_err,
            style,
            color=color,
            show_error_bars=show_phase_error_bars,
        )
    if display_range is not None:
        ax_phase.set_ylim(*display_range)
    return rho_label, phase_label


def _draw_induction_arrow_row(
    ax,
    x,
    u,
    v,
    *,
    color,
    tilt_decades,
    dy_scale,
    lw,
    mutation_scale,
    log_x,
    zorder=3,
):
    """Draw one real- or imaginary-part induction-arrow strip.

    Each arrow tail sits exactly at its sample's position on the shared
    period/frequency axis. Its 2-D direction encodes the true
    ``(u, v) = (Tx, Ty)`` vector: the vertical head offset is the
    physically meaningful part (``dy_scale * |T| * sin(angle)``, plotted
    in real data units on the row's own linear y-axis); the horizontal
    tilt (``tilt_decades * |T| * cos(angle)``) is a schematic fan
    reserved for visual separation between neighbouring periods and does
    not represent a period shift.
    """
    tips = []
    for xi, ui, vi in zip(x, u, v):
        if not np.all(np.isfinite([xi, ui, vi])):
            continue
        mag = float(np.hypot(ui, vi))
        if mag < 1e-9:
            continue
        ang = float(np.arctan2(vi, ui))
        x1 = _x_offset(float(xi), tilt_decades * mag * np.cos(ang), log_x)
        y1 = dy_scale * mag * np.sin(ang)
        tips.append(y1)
        ax.annotate(
            "",
            xy=(x1, y1),
            xytext=(float(xi), 0.0),
            arrowprops=dict(
                arrowstyle="-|>",
                color=color,
                lw=lw,
                mutation_scale=mutation_scale,
                shrinkA=0,
                shrinkB=0,
            ),
            zorder=zorder,
        )
    return tips


def _draw_pt_ellipse_row(
    ax,
    x,
    s1,
    s2,
    theta,
    cvals,
    skew,
    *,
    cmap,
    norm,
    scale,
    min_aspect,
    edgecolor,
    linewidth,
    alpha,
    skew_threshold,
    mark_3d,
    cells_per_decade,
    log_x,
):
    """Draw a phase-tensor ellipse strip anchored on a shared period axis.

    Ellipse geometry is built in *display pixels* (the same technique
    used by ``normalise_by="shape"`` in
    :func:`pycsamt.emtools.tensor.plot_phase_tensor_psection`) rather
    than in axis data units.  This is required here, not merely
    cosmetic: this row's x-axis is the same log-scale period axis shared
    with the rho/phase panels above it, and a data-unit ellipse width
    would be nonlinearly warped by that log transform (a constant "width
    in seconds" balloons at long period and vanishes at short period).
    Pixel sizing keeps every ellipse a true, undistorted shape wherever
    it falls on the shared axis.
    """
    ax.figure.canvas.draw()
    finite_x = x[np.isfinite(x)]
    x0 = float(np.nanmedian(finite_x)) if finite_x.size else 0.0
    x1 = _x_offset(x0, 1.0 / max(cells_per_decade, 1e-6), log_x)
    px0 = ax.transData.transform((x0, 0.0))[0]
    px1 = ax.transData.transform((x1, 0.0))[0]
    cell_px = max(abs(px1 - px0), 1e-6)
    width_px = scale * cell_px

    bbox = ax.bbox
    for xi, s1i, s2i, thi, ci, beti in zip(x, s1, s2, theta, cvals, skew):
        if not np.all(np.isfinite([xi, s1i, s2i, thi, ci])):
            continue
        ratio = abs(s2i) / max(abs(s1i), 1e-12)
        ratio = min(1.0, max(float(min_aspect), ratio))
        disp = ax.transData.transform((xi, 0.0))
        x_frac, y_frac = ax.transAxes.inverted().transform(disp)
        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
        shape_transform = (
            Affine2D()
            .scale(0.5 * width_px, 0.5 * width_px * ratio)
            .rotate_deg(thi)
            .scale(1.0 / bbox.width, 1.0 / bbox.height)
            .translate(x_frac, y_frac)
            + ax.transAxes
        )
        ell = Ellipse(
            (0.0, 0.0),
            width=2.0,
            height=2.0,
            angle=0.0,
            transform=shape_transform,
            facecolor=cmap(norm(ci)),
            edgecolor=edgecolor,
            linewidth=lw_i,
            alpha=alpha,
            zorder=3,
        )
        ell.set_clip_path(ax.patch)
        ax.add_patch(ell)


# ------------------------------ main plot --------------------------------- #


[docs] def plot_response_overview( sites: Any, *, station: str | None = None, control: Any | None = None, x_view: str | None = "period", log_log_rho=_UNSET, # default: True when the resolved control.rho.view == "log10" # ── which components ────────────────────────────────────────────────── offdiag_components: tuple[str, str] = ("xy", "yx"), diag_components: tuple[str, str] = ("xx", "yy"), show_diag: bool = True, phase_range: tuple[float, float] | None = None, # ── layout ──────────────────────────────────────────────────────────── height_ratios: tuple[float, float, float, float] = (2.0, 1.0, 1.4, 1.7), cbar_orientation: str = "horizontal", cbar_height_ratio: float = 0.12, cbar_pad_ratio: float = 0.55, cbar_width_ratio: float = 0.10, figsize: tuple[float, float] = (11.0, 9.8), wspace: float = 0.24, hspace: float = 0.10, axes=None, # ── impedance style ────────────────────────────────────────────────── colors: dict[str, str] | None = None, raw: bool = False, force_style: bool = False, show_error_bars: bool = True, show_phase_error_bars: bool = False, show_component_legend: bool = True, title: str | None = None, # ── induction-arrow row ────────────────────────────────────────────── show_arrows: bool = True, arrow_colors: tuple[str | None, str | None] = (None, None), arrow_tilt_decades: float = 0.4, arrow_dy_scale: float = 1.0, arrow_lw: float = 1.3, arrow_mutation_scale: float = 7.0, ylim_arrows: tuple[float, float] | None = None, show_arrow_legend: bool = True, # ── phase-tensor ellipse row ───────────────────────────────────────── show_ellipses: bool = True, 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() ellipse_scale: float = 1.5, # bigger than PYCSAMT_STYLE.pt_ellipse.scale # (0.85): this row's pixel/"shape"-based sizing (see # _draw_pt_ellipse_row) does not tile a fixed station grid the way # plot_phase_tensor_psection does, so headroom for a larger scale # exists without ellipses colliding into their neighbours' data. min_aspect=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.min_aspect cells_per_decade: float = 6.0, edgecolor=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.edgecolor linewidth=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.linewidth ellipse_alpha=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.alpha skew_threshold=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.skew_threshold mark_3d=_UNSET, # default: PYCSAMT_STYLE.pt_ellipse.mark_3d show_ellipse_colorbar: bool = True, ellipse_colorbar_label: str | None = None, # ── ticks / grid ───────────────────────────────────────────────────── tick_fontsize: int = 8, grid: bool = True, # ── standard emtools ───────────────────────────────────────────────── recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> plt.Figure: r"""Single-station MT/CSAMT "full response" overview figure. Reproduces the classic multi-panel layout used to QC a wideband MT sounding -- apparent resistivity and phase for the off-diagonal (:math:`Z_{xy}, Z_{yx}`) and diagonal (:math:`Z_{xx}, Z_{yy}`) impedance components side by side, with induction arrows and phase-tensor ellipses spanning both columns underneath, all sharing one period/frequency axis -- while going entirely through pyCSAMT's own :data:`~pycsamt.api.style.PYCSAMT_STYLE` and :data:`~pycsamt.api.control.PYCSAMT_CONTROL` systems instead of hardcoded colours or axis conventions. Layout (rows, top to bottom; all four data rows share the x-axis):: ┌─────────────────┬─────────────────┐ │ App. Res. xy/yx │ App. Res. xx/yy │ height_ratios[0] ├─────────────────┼─────────────────┤ │ Phase xy/yx │ Phase xx/yy │ height_ratios[1] ├─────────────────┴─────────────────┤ │ Induction arrows (real/imag) │ height_ratios[2] ├─────────────────────────────────────┤ │ Phase-tensor ellipse strip │ height_ratios[3] ├─────────────────────────────────────┤ │ skew β (°) colourbar │ cbar_height_ratio └─────────────────────────────────────┘ The rho/phase/arrow/ellipse rows all span the full data width; the ellipse row's colourbar lives in its own reserved row below it by default (see *cbar_orientation*) rather than a right-hand gutter, so it never narrows the ellipse row relative to the arrow row above it and the right margin stays free. Every row shares one x-axis. By default (*x_view* = ``"period"``) that axis is a true Matplotlib log scale over raw period in seconds -- the classic MT "log-log" quicklook, complete with Matplotlib's own per-decade minor-tick grid. Apparent resistivity likewise defaults to a true log y-axis over raw :math:`\Omega\cdot\mathrm{m}` values (*log_log_rho*), so the top row is genuinely log-log and the phase row below it (linear degrees over the same log x-axis) is genuinely semilog -- both draw the dense reference-figure-style grid rather than sparse integer gridlines over pre-logged numbers. Pass ``x_view="log10_period"`` for the alternative :math:`\log_{10}T\,(\mathrm{s})` linear-axis convention used elsewhere in ``emtools`` (e.g. :func:`plot_raw_sites_1d`), or ``x_view=None`` to defer entirely to ``control.x.view``. Apparent resistivity similarly can be forced to the pre-logged :math:`\log_{10}\rho_a` linear-axis convention with ``log_log_rho=False`` (then following ``control.rho.view``), and phase follows ``control.phase`` throughout. Parameters ---------- sites : Sites-like EDI path, glob pattern, :class:`~pycsamt.site.base.Sites`, or any input accepted by :func:`pycsamt.emtools.ensure_sites`. station : str or None Station to plot. Defaults to the first station (sorted by name) when *sites* resolves to more than one. control : object, optional Plot view control. Defaults to :data:`pycsamt.api.control.PYCSAMT_CONTROL`. Only ``control.phase`` (and, when *log_log_rho* is ``False``, ``control.rho``) is read directly from this object -- the x-axis view is controlled separately by *x_view* so this function's default log-log look does not depend on, or silently change, the shared global control's ``x.view`` setting. x_view : str or None, default "period" X-axis convention for all four rows, applied as a local override on top of *control* (the shared control object is never mutated). One of ``"period"``, ``"log10_period"``, ``"frequency"``, ``"log10_frequency"`` (see :class:`pycsamt.api.control.FrequencyAxisControl`), or ``None`` to use whatever ``control.x.view`` is already set to. log_log_rho : bool, optional Force the apparent-resistivity row onto a true log y-axis over raw values (``True``) or the pre-logged :math:`\log_{10}\rho_a`-on-linear-axis convention (``False``). Defaults to ``True`` when the resolved ``control.rho.view`` is ``"log10"`` (the package default) and ``False`` otherwise, so passing ``rho__view="linear"`` through *control* still works as expected without needing to also touch this flag. offdiag_components, diag_components : (str, str) Impedance components drawn (overlaid on the same axes pair) in the left and right column respectively. Any two-letter component keys accepted by :attr:`PYCSAMT_STYLE.mt` work here, so a TE/TM pair (``("te", "tm")``) is equally valid if that is how a survey was processed. show_diag : bool, default True Draw the right-hand (*diag_components*) column. When ``False`` the figure narrows to a single column and the arrow/ellipse rows span that one column's width. phase_range : (lo, hi) or None, optional Explicit phase display range shared by both columns. If omitted, the active ``control.phase`` policy is used (default :math:`\pm 180^\circ`) -- one consistent range for both columns, unlike tools that let the off-diagonal and diagonal phase axes drift to different ranges. height_ratios : (rho, phase, arrows, ellipses), default (2.0, 1.0, 1.4, 1.7) Relative row heights. Apparent resistivity gets twice the phase row's height by default (a 2:1 ratio, i.e. rho is 2/3 and phase 1/3 of the combined rho+phase height) -- the conventional "big rho, small phase" look; pass e.g. ``(1.0, 1.0, ...)`` for equal rows instead. cbar_orientation : {"horizontal", "vertical"}, default "horizontal" Placement of the ellipse-row colourbar. ``"horizontal"`` (the default) adds it as its own thin row *below* the ellipse strip, spanning the full data width and freeing the right-hand margin entirely. ``"vertical"`` instead reserves a narrow gutter column to the right of every row (see *cbar_width_ratio*). Either way the space is reserved in the gridspec *up front* rather than carved out of the ellipse axes after the fact -- the latter is what :func:`matplotlib.figure.Figure.colorbar`-via-divider approaches normally do, and it silently shrinks the ellipse row relative to the arrow row above it even though both still report "the same" x-limits, so a given period ends up at a different pixel column in each row. Reserving the space in advance keeps the arrow and ellipse rows pixel-aligned. cbar_height_ratio : float, default 0.12 Height of the horizontal colourbar row (``cbar_orientation= "horizontal"``), relative to the same units as *height_ratios* (e.g. relative to the ellipse row's own ``height_ratios[3]``). cbar_pad_ratio : float, default 0.55 Height of a blank spacer row inserted between the ellipse row and the horizontal colourbar row, in the same units as *height_ratios*. Needed because the ellipse row's own x tick labels and "Period (s)" label are drawn outside its axes box, in the margin below it -- with too little pad they collide with the colourbar's own tick labels. Increase if your *tick_fontsize* or a custom x label still overlaps the colourbar. cbar_width_ratio : float, default 0.10 Width of the vertical colourbar column (``cbar_orientation= "vertical"``), as a fraction of one data column. Both *cbar_height_ratio* and *cbar_width_ratio* -- like the rest of the reserved-space guarantee described under *cbar_orientation* -- are ignored when *axes* is provided (the colourbar then falls back to narrowing the ellipse axes itself, since new gridspec rows/columns cannot be inserted into an already-built external layout). figsize : (float, float), default (11.0, 9.8) Figure size (ignored when *axes* is provided). wspace, hspace : float Column/row spacing (ignored when *axes* is provided). axes : sequence of :class:`~matplotlib.axes.Axes` or None Pre-built axes to draw into instead of creating a new figure and gridspec. Must supply, in order, the rho and phase axes for each column (``2 * ncols`` axes, ``ncols=2`` unless *show_diag* is ``False``), followed by the arrow-row axes and the ellipse-row axes -- e.g. for the default two-column layout: ``[ax_rho_off, ax_rho_diag, ax_phase_off, ax_phase_diag, ax_arrow, ax_ellipse]``. Useful for composing this overview into a larger multi-station figure built with your own gridspec. Note the arrow/ellipse pixel-alignment guarantee described under *cbar_width_ratio* does not apply here -- align them yourself if you supply axes. colors : dict, optional Optional per-component colour overrides (keys are component letters, e.g. ``{"xy": "black"}``). Omitted components keep their :data:`PYCSAMT_STYLE.mt` colour -- the package default is already the reference-figure convention (blue circles for :math:`Z_{xy}`, red squares for :math:`Z_{yx}`). raw, force_style : bool When ``raw=True``, curves use :data:`PYCSAMT_STYLE.raw` (a neutral diagnostic style) instead of per-component colours, unless *force_style* is also ``True``. See :func:`plot_raw_sites_1d` for the same convention. show_error_bars, show_phase_error_bars : bool, default True, False Toggle apparent-resistivity and phase error bars independently. Both use the *same colour* as their component's curve (through ``style.errorbar_kwargs()``), which is the "error bars based on that colour" behaviour. show_component_legend : bool, default True Draw a small legend (component colour + marker) inside each top-row (apparent-resistivity) panel. title : str or None Figure title. Defaults to the station name. show_arrows : bool, default True Draw the induction-arrow row. Skipped (with a "no tipper" placeholder) when the station has no tipper data. arrow_colors : (real, imag), default (None, None) Explicit colour override for the real- and imaginary-part arrows. ``None`` falls back to :data:`PYCSAMT_STYLE.mt.xy` and :data:`PYCSAMT_STYLE.mt.yx` respectively -- the same real/imag colour convention already used by :func:`pycsamt.emtools.plot.plot_response_tipper`. arrow_tilt_decades : float, default 0.4 Schematic horizontal fan applied to each arrow (see :func:`_draw_induction_arrow_row`); purely cosmetic separation, not a period shift. arrow_dy_scale : float, default 1.0 Scales the arrow's vertical (physically meaningful) extent. Tipper magnitudes are typically O(0.01-1), so the default already matches a sensible row height; increase for a very "quiet" station or decrease if arrows overrun neighbouring rows. arrow_lw, arrow_mutation_scale : float Arrow line width and matplotlib ``mutation_scale`` (head size). ylim_arrows : (lo, hi) or None Explicit y-limits for the arrow row. Auto-scaled from the drawn arrow tips (with margin) when omitted. show_arrow_legend : bool, default True Draw a "real"/"imag" legend below the arrow row. show_ellipses : bool, default True Draw the phase-tensor ellipse row. Skipped (with a placeholder) when phase-tensor invariants cannot be computed for the station. c_by, cmap, clim, clim_pct, symmetric_clim, ellipse_scale, min_aspect, edgecolor, linewidth, ellipse_alpha, skew_threshold, mark_3d : Phase-tensor ellipse controls -- identical semantics to :func:`pycsamt.emtools.tensor.plot_phase_tensor_strip`, defaulting to :data:`PYCSAMT_STYLE.pt_ellipse`. To reproduce the classic MTpy "phimin" colouring (blue-to-orange-to-navy over :math:`0^\circ`-:math:`90^\circ`), pass ``c_by="phimin_deg", clim=(0.0, 90.0)`` together with a diverging colormap of your choice -- note this differs from ``c_by="phi_min"``, which colours by the raw (tan-units) phase-tensor singular value instead of its arctan in degrees and saturates near one end of a 0-90 scale; the package default (``c_by="skew"``) is the more diagnostic choice for flagging 3-D structure. cells_per_decade : float, default 8.0 Visual ellipse pitch along the period axis (ellipse-widths per decade); see :func:`pycsamt.emtools.tensor.plot_phase_tensor_strip` for why this is independent of the actual sample spacing. show_ellipse_colorbar : bool, default True Attach a colourbar to the ellipse row. ellipse_colorbar_label : str or None Override the automatic colourbar label derived from *c_by*. tick_fontsize : int, default 8 Tick-label size shared by all rows. grid : bool, default True Draw light panel grids on the rho/phase/arrow rows. recursive, on_dup, strict, verbose Forwarded to :func:`pycsamt.emtools.ensure_sites`. Returns ------- matplotlib.figure.Figure Examples -------- >>> from pycsamt.emtools import plot_response_overview >>> fig = plot_response_overview("data/gv_data/gv_final_edi", station="gv100") Raw resistivity (not log10) and a "phimin"-style ellipse colouring: >>> from pycsamt.api.control import PYCSAMT_CONTROL >>> with PYCSAMT_CONTROL.context(rho__view="linear"): ... fig = plot_response_overview( ... "data/gv_data/gv_final_edi", station="gv100", ... c_by="phimin_deg", clim=(0.0, 90.0), cmap="turbo", ... ) See Also -------- plot_response_tipper : Per-component grid across many stations. plot_phase_tensor_strip : Standalone ellipse strip for one station. plot_induction_arrows : Map-view induction arrows across a survey. """ ctl = _control_or_default(control) if x_view is not None and str(x_view).lower() != ctl.x.view.lower(): ctl = copy.copy(ctl) ctl.x = replace(ctl.x, view=str(x_view)) if log_log_rho is _UNSET: log_log_rho = ctl.rho.view.lower() == "log10" S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose ) station, ed = _pick_station(S, station) ncols = 2 if show_diag else 1 columns = [offdiag_components] + ([diag_components] if show_diag else []) cbar_orientation = str(cbar_orientation).lower() if cbar_orientation not in {"horizontal", "vertical"}: raise ValueError("cbar_orientation must be 'horizontal' or 'vertical'.") cbar_horizontal = cbar_orientation == "horizontal" axes_given = _axes_list(axes, 2 * ncols + 2) if axes is not None else None ax_cbar = None if axes_given is None: fig = plt.figure(figsize=figsize, constrained_layout=False) # A dedicated colourbar row/column is reserved up front rather # than carved out of ax_ell after the fact -- see # *cbar_orientation* in the docstring for why: the latter # silently shrinks the ellipse row relative to the arrow row # above it even though both still share the same x-limits. if cbar_horizontal: # A blank spacer row sits between the ellipse row and the # colourbar row. `hspace` alone is not enough here: it pads # between *axes boxes*, but the ellipse row's own x tick # labels and "Period (s)" label are drawn *outside* that box, # in the margin -- with no spacer they collide with the # colourbar's tick labels directly below. outer = fig.add_gridspec( 6, ncols, height_ratios=[*height_ratios, cbar_pad_ratio, cbar_height_ratio], wspace=wspace, hspace=hspace, ) else: outer = fig.add_gridspec( 4, ncols + 1, width_ratios=[*([1.0] * ncols), cbar_width_ratio], height_ratios=height_ratios, wspace=wspace, hspace=hspace, ) ax_rho = [fig.add_subplot(outer[0, j]) for j in range(ncols)] ax_phase = [ fig.add_subplot(outer[1, j], sharex=ax_rho[j]) for j in range(ncols) ] for j in range(1, ncols): ax_rho[j].sharex(ax_rho[0]) ax_arrow = fig.add_subplot(outer[2, :ncols], sharex=ax_rho[0]) ax_ell = fig.add_subplot(outer[3, :ncols], sharex=ax_rho[0]) ax_cbar = fig.add_subplot(outer[5, :] if cbar_horizontal else outer[3, ncols]) ax_cbar.axis("off") else: ax_rho = axes_given[:ncols] ax_phase = axes_given[ncols : 2 * ncols] ax_arrow = axes_given[2 * ncols] ax_ell = axes_given[2 * ncols + 1] fig = ax_rho[0].figure fig.suptitle(title or str(station), fontsize=11, fontweight="bold") out = _zblk_flex(ed) if len(out) == 4: _, z, fr, ze = out else: _, z, fr = out[:3] ze = None if z is None or fr is None: for ax in ax_rho + ax_phase + [ax_arrow, ax_ell]: ax.text( 0.5, 0.5, "no impedance data", ha="center", va="center", transform=ax.transAxes, ) return fig fr = np.asarray(fr, dtype=float) x, x_label, log_x = _x_from_freq(fr, ctl) for j, comps in enumerate(columns): a_rho, a_phase = ax_rho[j], ax_phase[j] if log_x: a_rho.set_xscale("log") a_phase.set_xscale("log") if grid: a_rho.grid(True, alpha=0.25, which="both") a_phase.grid(True, alpha=0.25, which="both") rho_label, phase_label = _draw_component_column( a_rho, a_phase, z=z, ze=ze, fr=fr, x=x, components=comps, ctl=ctl, phase_range=phase_range, colors=colors, show_error_bars=show_error_bars, show_phase_error_bars=show_phase_error_bars, raw=raw, force_style=force_style, log_log_rho=log_log_rho, ) a_rho.set_ylabel(rho_label if j == 0 else "") a_phase.set_ylabel(phase_label if j == 0 else "") a_rho.tick_params(axis="x", labelbottom=False) a_phase.tick_params(axis="x", labelbottom=False) a_rho.tick_params(axis="both", labelsize=tick_fontsize) a_phase.tick_params(axis="both", labelsize=tick_fontsize) if show_component_legend: handles, labels = _legend_handles( comps, lambda c: _component_style(c, raw, force_style), colors ) a_rho.legend( handles, labels, loc="best", fontsize=8, framealpha=0.85 ) # ── induction-arrow row ─────────────────────────────────────────────── if show_arrows: t_out = _get_t_block(ed, with_errors=False) _, tip, tfr = t_out[:3] if tip is None or tfr is None: ax_arrow.text( 0.5, 0.5, "no tipper", ha="center", va="center", transform=ax_arrow.transAxes, color="0.4", ) else: tfr = np.asarray(tfr, dtype=float) tx_x, _, _ = _x_from_freq(tfr, ctl) tip = np.asarray(tip, dtype=complex) mt = PYCSAMT_STYLE.mt real_color = arrow_colors[0] or mt.xy.color imag_color = arrow_colors[1] or mt.yx.color tips_r = _draw_induction_arrow_row( ax_arrow, tx_x, np.real(tip[:, 0]), np.real(tip[:, 1]), color=real_color, tilt_decades=arrow_tilt_decades, dy_scale=arrow_dy_scale, lw=arrow_lw, mutation_scale=arrow_mutation_scale, log_x=log_x, ) tips_i = _draw_induction_arrow_row( ax_arrow, tx_x, np.imag(tip[:, 0]), np.imag(tip[:, 1]), color=imag_color, tilt_decades=arrow_tilt_decades, dy_scale=arrow_dy_scale, lw=arrow_lw, mutation_scale=arrow_mutation_scale, log_x=log_x, ) if ylim_arrows is not None: ax_arrow.set_ylim(*ylim_arrows) else: all_tips = np.asarray(tips_r + tips_i, dtype=float) lim = ( float(np.nanmax(np.abs(all_tips))) * 1.25 if all_tips.size else 0.1 ) lim = max(lim, 1e-3) ax_arrow.set_ylim(-lim, lim) ax_arrow.axhline(0.0, color="0.75", lw=0.7, zorder=0) if show_arrow_legend: handles = [ Line2D([], [], color=real_color, lw=arrow_lw, label="real"), Line2D([], [], color=imag_color, lw=arrow_lw, label="imag"), ] ax_arrow.legend( handles=handles, loc="upper right", fontsize=8, framealpha=0.85, ) else: ax_arrow.axis("off") if grid and show_arrows: ax_arrow.grid(True, alpha=0.25, which="both") ax_arrow.set_ylabel("Induction\narrows", fontsize=9) ax_arrow.tick_params(axis="both", labelsize=tick_fontsize) # The ellipse row directly below shares this same x-axis (sharex= # ax_rho[0]) and already draws the period/frequency tick labels, so # repeating them here would be redundant -- only tick *marks* stay, # not the numeric labels. ax_arrow.tick_params(axis="x", labelbottom=False) # ── phase-tensor ellipse row ───────────────────────────────────────── if show_ellipses: # `ed` is a Site-wrapped object here (from `_pick_station`); passing # it straight through in a list makes `ensure_sites`/`to_sites` # silently resolve to zero items (it expects raw EDI-like items, not # an already-Site-wrapped one). Unwrap to the underlying EDI first, # mirroring `_core._wrap_one`'s convention for the same situation. edi = getattr(ed, "edi", None) pt_item = edi if edi is not None else ed df = build_phase_tensor_table( [pt_item], recursive=False, on_dup=on_dup, strict=False, verbose=verbose, ) if df.empty: ax_ell.text( 0.5, 0.5, "no phase tensor data", ha="center", va="center", transform=ax_ell.transAxes, color="0.4", ) else: _es = PYCSAMT_STYLE.pt_ellipse c_by_ = _es.c_by if c_by is _UNSET else c_by cmap_name = ( _es.copy(c_by=c_by_).resolve_cmap() if cmap is _UNSET else cmap ) clim_pct_ = _es.clim_pct if clim_pct is _UNSET else clim_pct symmetric_ = ( _es.copy(c_by=c_by_).resolve_symmetric_clim() if symmetric_clim is _UNSET else symmetric_clim ) scale_ = _es.scale if ellipse_scale is _UNSET else ellipse_scale min_aspect_ = _es.min_aspect if min_aspect is _UNSET else min_aspect edgecolor_ = _es.edgecolor if edgecolor is _UNSET else edgecolor linewidth_ = _es.linewidth if linewidth is _UNSET else linewidth alpha_ = _es.alpha if ellipse_alpha is _UNSET else ellipse_alpha skew_threshold_ = ( _es.skew_threshold if skew_threshold is _UNSET else skew_threshold ) mark_3d_ = _es.mark_3d if mark_3d is _UNSET else mark_3d df = df.sort_values("freq") x_ell = ctl.x.transform(df["freq"].to_numpy(float)) cvals, cbar_label = _resolve_cvals(df, c_by_) if ellipse_colorbar_label: cbar_label = ellipse_colorbar_label finite_c = cvals[np.isfinite(cvals)] if clim is not None: vmin, vmax = float(clim[0]), float(clim[1]) else: lo_pct, hi_pct = clim_pct_ vmin = ( float(np.nanpercentile(finite_c, lo_pct)) if len(finite_c) else -1.0 ) vmax = ( float(np.nanpercentile(finite_c, hi_pct)) if len(finite_c) else 1.0 ) if symmetric_: vlim = max(abs(vmin), abs(vmax)) vmin, vmax = -vlim, vlim if not np.isfinite(vmin) or not np.isfinite(vmax): vmin, vmax = -1.0, 1.0 elif np.isclose(vmin, vmax): pad = max(abs(vmin) * 0.05, 1e-6) vmin, vmax = vmin - pad, vmax + pad norm = Normalize(vmin=vmin, vmax=vmax) cm = plt.get_cmap(cmap_name) ax_ell.set_xlim(ax_rho[0].get_xlim()) ax_ell.set_ylim(-0.62, 0.62) _draw_pt_ellipse_row( ax_ell, x_ell, df["s1"].to_numpy(float), df["s2"].to_numpy(float), df["theta"].to_numpy(float), cvals, df["skew"].to_numpy(float), cmap=cm, norm=norm, scale=scale_, min_aspect=min_aspect_, edgecolor=edgecolor_, linewidth=linewidth_, alpha=alpha_, skew_threshold=skew_threshold_, mark_3d=mark_3d_, cells_per_decade=cells_per_decade, log_x=log_x, ) ax_ell.set_yticks([]) if show_ellipse_colorbar: sm = ScalarMappable(cmap=cm, norm=norm) sm.set_array([]) if ax_cbar is not None: # Dedicated gridspec row/column (see cbar_orientation): # keeps ax_arrow and ax_ell pixel-aligned since neither # loses space to the colourbar after the fact. ax_cbar.axis("on") cb = fig.colorbar( sm, cax=ax_cbar, orientation="horizontal" if cbar_horizontal else "vertical", ) cb.set_label(cbar_label, fontsize=9) cb.ax.tick_params(labelsize=8) else: _attach_cbar(ax_ell, sm, cbar_label) else: ax_ell.axis("off") ax_ell.set_ylabel("Phase\ntensor", fontsize=9) ax_ell.tick_params(axis="both", labelsize=tick_fontsize) ax_ell.tick_params(axis="x", labelbottom=True) ax_ell.set_xlabel(x_label, fontsize=10) if grid and show_ellipses: ax_ell.grid(True, ls=":", lw=0.4, color="#cccccc", alpha=0.7, zorder=0) return fig