# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""MTPy-style apparent-resistivity and phase pseudo-sections.
:class:`PlotResPhasePseudoSection` draws the classic magnetotelluric
pseudo-section: apparent resistivity above phase, one column per
impedance component, and one stacked ``[resistivity, phase]`` row-pair
per site group. By default every group shares one period (vertical)
axis, one resistivity colour scale, and one phase colour scale; the
station axis (names + inverted-triangle markers) is drawn on top, from
:data:`pycsamt.api.PYCSAMT_STATION_RENDERING`.
"""
from __future__ import annotations
import copy
from typing import Any
import matplotlib.pyplot as plt
import numpy as np
from ..api.control import wrap_phase
from ..api.labels import LOG10_PERIOD_LABEL, PERIOD_LABEL, STATION_LABEL
from ..api.station import PYCSAMT_STATION_RENDERING
from ..api.station import _NICE_STEPS
from ..core.base import CoreObject
from ._core import _iter_items, _name, ensure_sites
from .inspect import _df_resphase
__all__ = [
"PlotResPhasePseudoSection",
"plot_res_phase_pseudosection",
]
# Recognised impedance components, in canonical order.
_COMPONENTS: tuple[str, ...] = ("xy", "yx", "xx", "yy")
# Panel titles.
_Z_LABEL: dict[str, str] = {
"xy": r"$Z_{xy}$",
"yx": r"$Z_{yx}$",
"xx": r"$Z_{xx}$",
"yy": r"$Z_{yy}$",
}
# pyCSAMT default colormaps -- the same pair ``plot_mt_composite_section``
# and the apparent-resistivity helpers in ``pycsamt.emtools.advanced`` use.
_DEFAULT_RES_CMAP = "jet_r"
_DEFAULT_PHASE_CMAP = "plasma"
# Named phase windows: ``(vmin, vmax)`` in degrees.
_PHASE_PRESETS: dict[str, tuple[float, float]] = {
"0-90": (0.0, 90.0),
"-45-45": (-45.0, 45.0),
"-90-90": (-90.0, 90.0),
"-180-180": (-180.0, 180.0),
}
# Log-period tolerance for snapping a station's samples onto the shared
# grid -- the same 0.3-decade window used by ``plot_mt_composite_section``.
_LOG_SNAP = 0.30
[docs]
class PlotResPhasePseudoSection(CoreObject):
r"""Apparent-resistivity / phase pseudo-section, MTPy style.
Each **column** is one impedance component; within a column the
apparent-resistivity panel sits above the phase panel. Passing
several site groups stacks them as extra ``[resistivity, phase]``
row-pairs down the figure. Every panel shares the period axis; by
default every resistivity panel shares one colour scale and every
phase panel shares another, so the columns and groups are directly
comparable.
Parameters
----------
sites : Sites-like, or mapping of str to Sites-like
One survey (any input :func:`~pycsamt.emtools.ensure_sites`
accepts -- a directory, an EDI list, a ``Sites``), or a mapping
``{group_label: survey}`` to stack several groups vertically.
components : sequence of {"xy", "yx", "xx", "yy"}, optional
Which components to draw, **in the order given**. ``None``
(default) auto-detects: every component that carries finite data
in at least one group is plotted, in canonical ``xy, yx, xx, yy``
order.
res_phase_ratio : float, default ``2.0``
Height of a resistivity panel relative to its phase panel, fed
straight into the ``[res, phase]`` ``height_ratios``. The default
gives resistivity two-thirds of the row-pair and phase the
remaining third; pass ``1.0`` for equal panels, or a fraction
below ``1.0`` to make phase the taller of the two.
station_side : {"top", "bottom", "none"}, default "top"
Where the station axis (ticks, names, and inverted-triangle
markers) is drawn. ``"top"`` matches the pyCSAMT section
convention (:data:`~pycsamt.api.PYCSAMT_STATION_RENDERING`) and
gives every stacked line its own station axis above its
resistivity panel; ``"bottom"`` places it under each phase panel;
``"none"`` omits it.
station_markers : bool, default True
Draw the triangle station markers from
:data:`~pycsamt.api.PYCSAMT_STATION_RENDERING`. ``False`` keeps
only the tick names.
panel_labels : bool or sequence of str, default False
Add a per-group corner tag. ``True`` uses ``(a)``, ``(b)``, …;
a sequence supplies the tags verbatim.
grid : bool, default False
Draw faint horizontal period grid lines on every panel.
show_colorbar : bool, default True
Draw the two shared colourbars (resistivity, phase) on the right.
phase_range : str or tuple of float, default ``"0-90"``
Phase colour limits in degrees: one of the presets ``"0-90"``,
``"-45-45"``, ``"-90-90"``, ``"-180-180"``, the string
``"auto"`` (2nd/98th percentiles), or an explicit
``(vmin, vmax)`` tuple. The ``yx`` and ``yy`` phases are always
rotated by 180 degrees first, so a 1-D response plots near
45 degrees in every component (the MTPy convention).
res_range : tuple of float, optional
Resistivity colour limits in :math:`\Omega\cdot\mathrm{m}`.
``None`` uses the 5th/95th percentiles.
log_res : bool, default True
Map :math:`\log_{10}\rho_a` rather than :math:`\rho_a` to colour.
log_period : bool, default True
Label the vertical axis with :math:`\log_{10}T` on a linear axis
-- the convention :func:`~pycsamt.emtools.tensor
.plot_phase_tensor_psection` and the rest of this module's
pseudo-section family already default to. ``False`` keeps
:math:`T` in seconds on a logarithmic axis instead.
res_cmap, phase_cmap : str or Colormap, optional
Colormaps. ``None`` uses the pyCSAMT defaults
(``"jet_r"`` for resistivity, ``"plasma"`` for phase).
share_scales : bool, default True
Share one resistivity colour scale and one phase colour scale
across every panel. When ``False`` each panel is scaled on its
own data.
res_scale_from : {"offdiag", "all"}, default "offdiag"
When *share_scales* is on, whether the shared resistivity limits
are taken from the off-diagonal components only (``xy``/``yx`` --
the physically meaningful ones) or from every drawn component.
Ignored when only diagonal components are present.
period_range : tuple of float, optional
``(T_min, T_max)`` in seconds; samples outside are dropped.
station_order : sequence of str, optional
Explicit left-to-right station order. ``None`` keeps the order
the sites are read in.
n_grid : int, default 60
Number of log-spaced period rows per group.
share_period : bool, default True
Resample every group onto one common period grid and share the
vertical axis. Set ``False`` when stacking lines from different
instruments (e.g. an AMT line above an LMT line) so each keeps
its own period window instead of a mostly-empty shared axis.
station_label_step : int, optional
Show every *n*-th station label. ``None`` picks a step that
keeps at most ~12 labels.
figsize : tuple of float, optional
title : str, optional
Figure suptitle.
axes : sequence of matplotlib.axes.Axes, optional
A flat, row-major grid of ``2 * n_groups * n_components`` axes to
draw into (resistivity then phase for each column, top group
first). When given, the figure and its two shared colourbars are
the caller's responsibility. ``None`` (default) builds a fresh
figure.
Examples
--------
All present components of one survey:
>>> from pycsamt.emtools import PlotResPhasePseudoSection
>>> fig = PlotResPhasePseudoSection(
... "data/MT/broken-hill/edis"
... ).plot()
Two lines stacked, resistivity twice the height of phase, station
markers above each line, lettered panels:
>>> fig = PlotResPhasePseudoSection(
... {"Line A": sites_a, "Line B": sites_b},
... components=["xy", "yx"],
... res_phase_ratio=2.0,
... station_side="top",
... panel_labels=True,
... ).plot()
"""
def __init__(
self,
sites: Any,
*,
components: list[str] | tuple[str, ...] | None = None,
res_phase_ratio: float = 2.0,
phase_range: str | tuple[float, float] = "0-90",
res_range: tuple[float, float] | None = None,
log_res: bool = True,
log_period: bool = True,
res_cmap: Any = None,
phase_cmap: Any = None,
share_scales: bool = True,
res_scale_from: str = "offdiag",
period_range: tuple[float, float] | None = None,
station_order: list[str] | None = None,
n_grid: int = 60,
share_period: bool = True,
station_label_step: int | None = None,
station_side: str = "top",
station_markers: bool = True,
panel_labels: bool | list[str] | tuple[str, ...] = False,
grid: bool = False,
show_colorbar: bool = True,
figsize: tuple[float, float] | None = None,
title: str | None = None,
axes: Any = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self._sites = sites
self.components = (
list(components) if components is not None else None
)
self.res_phase_ratio = max(float(res_phase_ratio), 1e-3)
self.phase_range = phase_range
self.res_range = res_range
self.log_res = bool(log_res)
self.log_period = bool(log_period)
self.res_cmap = res_cmap or _DEFAULT_RES_CMAP
self.phase_cmap = phase_cmap or _DEFAULT_PHASE_CMAP
self.share_scales = bool(share_scales)
self.res_scale_from = str(res_scale_from)
self.period_range = period_range
self.station_order = (
list(station_order) if station_order is not None else None
)
self.n_grid = max(int(n_grid), 4)
self.share_period = bool(share_period)
self.station_label_step = station_label_step
self.station_side = str(station_side).lower()
self.station_markers = bool(station_markers)
self.panel_labels = panel_labels
self.grid = bool(grid)
self.show_colorbar = bool(show_colorbar)
self.figsize = figsize
self.title = title
self.axes = axes
if self.station_side not in ("top", "bottom", "none"):
raise ValueError(
"station_side must be 'top', 'bottom', or 'none'; "
f"got {station_side!r}."
)
# -- data -------------------------------------------------------
def _groups(self) -> dict[str, Any]:
"""Return an ordered ``{label: sites-like}`` mapping."""
if isinstance(self._sites, dict):
return dict(self._sites)
return {"": self._sites}
def _survey_frame(self, sites: Any):
"""Return ``(stations, resphase DataFrame with a period column)``."""
S = ensure_sites(sites, recursive=True)
df = _df_resphase(S, kind="resphase")
if df is None or df.empty:
return [], None
df = df.copy()
df["period"] = 1.0 / df["freq"].replace(0, np.nan)
if self.period_range is not None:
lo, hi = self.period_range
df = df[(df["period"] >= float(lo)) & (df["period"] <= float(hi))]
if df.empty:
return [], None
seen = [
_name(ed, i) for i, ed in enumerate(_iter_items(S))
]
stations = [s for s in dict.fromkeys(seen) if s in set(df["station"])]
if not stations:
stations = list(dict.fromkeys(df["station"]))
if self.station_order is not None:
stations = [s for s in self.station_order if s in stations]
return stations, df
def _grid_image(self, df, stations, per_grid, column: str) -> np.ndarray:
"""Snap one column onto the shared ``(n_grid, n_station)`` grid."""
lpg = np.log10(per_grid)
out = np.full((len(per_grid), len(stations)), np.nan)
if column not in df.columns:
return out
for si, st in enumerate(stations):
sub = df[df["station"] == st]
ps = sub["period"].to_numpy(float)
vs = sub[column].to_numpy(float)
ok = np.isfinite(ps) & np.isfinite(vs) & (ps > 0)
ps, vs = ps[ok], vs[ok]
if ps.size == 0:
continue
lps = np.log10(ps)
for gi, lg in enumerate(lpg):
j = int(np.argmin(np.abs(lps - lg)))
if abs(lps[j] - lg) < _LOG_SNAP:
out[gi, si] = vs[j]
return out
@staticmethod
def _robust_period_window(df) -> tuple[float, float] | None:
"""Return an outlier-tolerant ``(T_min, T_max)`` for one frame.
Keeps only the half-decade period bins in which at least a
fraction of the line's stations carry a finite apparent
resistivity, so a lone station's stray high-frequency segment in
an otherwise long-period line does not stretch the grid across
empty decades.
"""
per = df["period"].to_numpy(float)
rho_cols = [c for c in df.columns if c.startswith("rho_")]
has = np.zeros(len(df), dtype=bool)
for c in rho_cols:
has |= np.isfinite(df[c].to_numpy(float))
ok = np.isfinite(per) & (per > 0) & has
if not ok.any():
return None
lp = np.log10(per[ok])
sta = df["station"].to_numpy()[ok]
n_st = len(set(df["station"]))
thr = max(2, int(np.ceil(0.15 * n_st)))
bins: dict[float, set] = {}
for b, s in zip(np.round(lp * 2.0) / 2.0, sta):
bins.setdefault(float(b), set()).add(s)
keep = sorted(b for b, ss in bins.items() if len(ss) >= thr)
if not keep:
keep = sorted(bins)
return 10.0 ** min(keep), 10.0 ** (max(keep) + 0.25)
def _build(self):
"""Assemble every panel image plus each group's period grid.
Returns
-------
dict
``components`` and ``groups`` -- the latter a list of
``{"label", "stations", "res", "phase", "period_grid"}``
records, ``res``/``phase`` keyed by component. When
*share_period* is on every group carries the same grid.
"""
groups_in = self._groups()
frames = {
lab: self._survey_frame(s) for lab, s in groups_in.items()
}
frames = {
lab: fr for lab, fr in frames.items() if fr[1] is not None
}
if not frames:
raise ValueError(
"no apparent-resistivity / phase data in the input"
)
# per-group period window, then one shared grid or one each
windows = {
lab: self._robust_period_window(df)
for lab, (_, df) in frames.items()
}
windows = {k: v for k, v in windows.items() if v is not None}
if not windows:
raise ValueError("no finite periods in the input")
if self.share_period:
lo = min(w[0] for w in windows.values())
hi = max(w[1] for w in windows.values())
grids = {
lab: np.logspace(
np.log10(lo), np.log10(hi), self.n_grid
)
for lab in frames
}
else:
grids = {
lab: np.logspace(
np.log10(windows[lab][0]),
np.log10(windows[lab][1]),
self.n_grid,
)
for lab in windows
}
# resolve components
comps = self.components
if comps is None:
comps = [
c
for c in _COMPONENTS
if any(
np.isfinite(df[f"rho_{c}"]).any()
for _, df in frames.values()
if f"rho_{c}" in df.columns
)
]
if not comps:
raise ValueError("no usable impedance component found")
groups = []
for lab, (stations, df) in frames.items():
per_grid = grids[lab]
res: dict[str, np.ndarray] = {}
phase: dict[str, np.ndarray] = {}
for c in comps:
res[c] = self._grid_image(
df, stations, per_grid, f"rho_{c}"
)
ph = self._grid_image(df, stations, per_grid, f"phi_{c}")
# bring yx/yy out of the third quadrant so a 1-D response
# sits near +45 deg in every component (MTPy convention)
if c in ("yx", "yy"):
ph = ph + 180.0
phase[c] = wrap_phase(ph, (-180.0, 180.0))
groups.append(
{
"label": lab,
"stations": stations,
"res": res,
"phase": phase,
"period_grid": per_grid,
}
)
return {"components": comps, "groups": groups}
# -- limits ----------------------------------------------------
def _res_limits(self, built) -> tuple[float, float]:
if self.res_range is not None:
lo, hi = float(self.res_range[0]), float(self.res_range[1])
else:
comps = built["components"]
pick = comps
if self.res_scale_from == "offdiag":
off = [c for c in comps if c in ("xy", "yx")]
pick = off or comps
vals = np.concatenate(
[
g["res"][c].ravel()
for g in built["groups"]
for c in pick
]
)
vals = vals[np.isfinite(vals) & (vals > 0)]
if vals.size == 0:
lo, hi = 1.0, 1000.0
else:
lo, hi = np.percentile(vals, [5, 95])
if self.log_res:
lo = np.log10(max(lo, 1e-6))
hi = np.log10(max(hi, 10 ** (lo + 0.5) if self.log_res else lo))
return float(lo), float(hi)
def _phase_limits(self, built) -> tuple[float, float]:
pr = self.phase_range
if isinstance(pr, str):
if pr == "auto":
vals = np.concatenate(
[
g["phase"][c].ravel()
for g in built["groups"]
for c in built["components"]
]
)
vals = vals[np.isfinite(vals)]
if vals.size == 0:
return 0.0, 90.0
return tuple(
float(v) for v in np.percentile(vals, [2, 98])
)
if pr not in _PHASE_PRESETS:
raise ValueError(
f"phase_range must be a (vmin, vmax) tuple, 'auto', "
f"or one of {sorted(_PHASE_PRESETS)}; got {pr!r}."
)
return _PHASE_PRESETS[pr]
return float(pr[0]), float(pr[1])
# -- decoration helpers --------------------------------------
def _panel_label_list(self, n: int) -> list[str]:
"""Return the per-group corner tags, or ``[]`` when disabled."""
pl = self.panel_labels
if not pl:
return []
if pl is True:
return [f"({chr(97 + i)})" for i in range(n)]
return [str(x) for x in pl][:n]
def _station_style(self, ci: int, n_col: int, n_st: int):
"""Return a configured copy of the pseudo-section station style."""
st = copy.deepcopy(
PYCSAMT_STATION_RENDERING.style_for("pseudosection")
)
st.show_markers = self.station_markers
if self.station_side == "bottom":
st.side = "bottom"
# the "pseudosection" preset marker sits just above the axis
# (axes-fraction 1.025); flip it below for a bottom axis
st.marker.offset = -0.03
st.xlabel = STATION_LABEL if ci == n_col // 2 else ""
else:
st.side = "top"
# a repeated "Station" caption above every column only adds
# noise -- the triangle markers already read as stations
st.xlabel = ""
# Columns are narrow, so thin the names harder than the global
# station-axis default ("auto") would if it saw the whole shared
# figure width -- but pick the step the same way
# StationAxisStyle.compute_every() does (round up to a "nice"
# step) rather than requiring it to exactly divide ``n_st - 1``.
# That divisor search used to leave ``every=1`` (every station
# labelled -- exactly the clutter this thinning exists to avoid)
# whenever ``n_st - 1`` happened to be prime or otherwise
# low-divisor (e.g. n_st=44 or 60); label_indices() already
# force-includes the final station regardless of whether the
# step evenly divides the run, so that constraint bought nothing.
if self.station_label_step:
st.every = int(self.station_label_step)
elif n_st > 3:
target = n_st / (6.0 if n_col <= 2 else 4.5)
for step in _NICE_STEPS:
if step >= target:
st.every = step
break
else:
st.every = max(1, round(target))
else:
st.every = 1
return st
# -- plot ----------------------------------------------------
[docs]
def plot(self):
"""Render the pseudo-section.
Returns
-------
matplotlib.figure.Figure
Raises
------
ValueError
If the input carries no resistivity/phase data, if no
component is usable, or if *phase_range* is not recognised.
"""
built = self._build()
comps = built["components"]
groups = built["groups"]
n_col = len(comps)
n_grp = len(groups)
side = self.station_side
top_axis = side == "top"
bot_axis = side == "bottom"
r_lo, r_hi = self._res_limits(built)
p_lo, p_hi = self._phase_limits(built)
ratio = self.res_phase_ratio
plabels = self._panel_label_list(n_grp)
ylabel = (
LOG10_PERIOD_LABEL if self.log_period else PERIOD_LABEL
)
# ---- figure + nested grid (one inner grid per site group) -----
# margins are sized in inches, then converted to figure
# fractions, so a tall (high res_phase_ratio) figure does not
# grow a huge blank band above the panels
n_needed = 2 * n_grp * n_col
show_cbar = self.show_colorbar and self.axes is None
gs_left = 0.13 if n_grp > 1 else 0.10
gs_right = 0.865 if show_cbar else 0.965
row_h = 2.4 * (ratio + 1.0) # one [res, phase] pair, inches
top_in = (0.72 if self.title else 0.32) + (
0.62 if top_axis else 0.0
)
bot_in = 0.85 if bot_axis else 0.48
gap_in = (1.15 if (top_axis or bot_axis) else 0.2) * (n_grp - 1)
if self.axes is not None:
axlist = [
a for a in np.asarray(self.axes, dtype=object).ravel()
]
if len(axlist) < n_needed:
raise ValueError(
f"axes must supply at least {n_needed} axes "
f"(2 x {n_grp} groups x {n_col} components); "
f"got {len(axlist)}."
)
fig = axlist[0].figure
inners = None
fig_h = fig.get_figheight()
else:
axlist = None
fig_h = row_h * n_grp + top_in + bot_in + gap_in
figsize = self.figsize or (2.2 * n_col + 1.9, fig_h)
fig = plt.figure(figsize=figsize)
fig_h = figsize[1]
gs_top = 1.0 - top_in / fig_h
gs_bottom = bot_in / fig_h
if self.axes is None:
group_gap = gap_in / row_h if n_grp > 1 else 0.0
outer = fig.add_gridspec(
n_grp,
1,
hspace=group_gap,
left=gs_left,
right=gs_right,
top=gs_top,
bottom=gs_bottom,
)
inners = [
outer[gi].subgridspec(
2,
n_col,
height_ratios=[ratio, 1.0],
hspace=0.07,
wspace=0.09,
)
for gi in range(n_grp)
]
title_pad = 22.0 if top_axis else 6.0
im_r = im_p = None
share_y = None
all_axes: list[tuple[Any, np.ndarray]] = []
for gi, g in enumerate(groups):
stations = g["stations"]
n_st = len(stations)
x_edges = np.arange(n_st + 1) - 0.5
per_grid = g["period_grid"]
yc = np.log10(per_grid) if self.log_period else per_grid
y_edges = _edges(yc)
axr0 = axp0 = None
for ci, c in enumerate(comps):
link_y = share_y if self.share_period else None
if inners is not None:
axr = fig.add_subplot(
inners[gi][0, ci], sharey=link_y
)
axp = fig.add_subplot(
inners[gi][1, ci], sharex=axr, sharey=link_y
)
if share_y is None:
share_y = axr
else:
axr = axlist[(2 * gi) * n_col + ci]
axp = axlist[(2 * gi + 1) * n_col + ci]
all_axes += [(axr, y_edges), (axp, y_edges)]
if ci == 0:
axr0, axp0 = axr, axp
R = g["res"][c]
if self.log_res:
R = np.log10(np.clip(R, 1e-6, None))
if self.share_scales:
rk = dict(vmin=r_lo, vmax=r_hi)
pk = dict(vmin=p_lo, vmax=p_hi)
else:
rk = pk = {}
im_r = axr.pcolormesh(
x_edges, y_edges, R, cmap=self.res_cmap,
shading="flat", **rk,
)
im_p = axp.pcolormesh(
x_edges, y_edges, g["phase"][c], cmap=self.phase_cmap,
shading="flat", **pk,
)
axr.tick_params(labelsize=8)
axp.tick_params(labelsize=8)
axr.tick_params(labelbottom=False)
if gi == 0:
axr.set_title(
_Z_LABEL.get(c, c), fontsize=12, pad=title_pad
)
if ci == 0:
axr.set_ylabel(ylabel, fontsize=9)
axp.set_ylabel(ylabel, fontsize=9)
else:
axr.tick_params(labelleft=False)
axp.tick_params(labelleft=False)
if self.grid:
for a in (axr, axp):
a.grid(
True, which="both", axis="y",
ls=":", lw=0.4, alpha=0.45,
)
# station axis -- one per group, on the resistivity row
# (top) or the phase row (bottom); columns align by
# station index so each line gets its own axis
if side == "none":
axp.tick_params(labelbottom=False, labeltop=False)
else:
target = axr if top_axis else axp
other = axp if top_axis else axr
self._station_style(ci, n_col, n_st).apply(
target,
np.arange(n_st, dtype=float),
stations,
xlim=(-0.5, n_st - 0.5),
)
other.tick_params(labelbottom=False, labeltop=False)
if plabels and axr0 is not None:
axr0.annotate(
plabels[gi],
xy=(0.0, 1.0),
xycoords="axes fraction",
xytext=(-34 if n_grp > 1 else -30, 4),
textcoords="offset points",
fontsize=11,
fontweight="bold",
va="bottom",
ha="left",
)
if n_grp > 1 and g["label"] and axr0 is not None:
y_top = axr0.get_position().y1
y_bot = axp0.get_position().y0
fig.text(
0.028,
0.5 * (y_top + y_bot),
g["label"],
rotation=90,
va="center",
ha="center",
fontsize=10,
fontweight="bold",
)
# period axis: log scale (unless the values are already log10),
# period increasing downward, each group clipped to its own grid
for a, yed in all_axes:
if not self.log_period:
a.set_yscale("log")
a.set_ylim(yed[-1], yed[0])
# two shared colourbars on the right -- only when we own the
# figure; a caller passing *axes* manages its own colour key
if show_cbar:
rlabel = (
r"$\log_{10}\rho_a$ ($\Omega\cdot$m)"
if self.log_res
else r"$\rho_a$ ($\Omega\cdot$m)"
)
mid = 0.5 * (gs_top + gs_bottom)
cb_x = gs_right + 0.022
cax_r = fig.add_axes(
[cb_x, mid + 0.035, 0.016, gs_top - mid - 0.07]
)
cb_r = fig.colorbar(im_r, cax=cax_r)
cb_r.set_label(rlabel, fontsize=9)
cb_r.ax.tick_params(labelsize=8)
cax_p = fig.add_axes(
[cb_x, gs_bottom + 0.035, 0.016, mid - gs_bottom - 0.07]
)
cb_p = fig.colorbar(im_p, cax=cax_p)
cb_p.set_label(r"$\varphi$ (deg)", fontsize=9)
cb_p.ax.tick_params(labelsize=8)
if self.title:
fig.suptitle(self.title, fontsize=12, y=0.995, va="top")
return fig
def _edges(centres: np.ndarray) -> np.ndarray:
"""Return ``n+1`` bin edges bracketing ``n`` monotonic centres."""
c = np.asarray(centres, dtype=float)
if c.size == 1:
return np.array([c[0] - 0.5, c[0] + 0.5])
mid = 0.5 * (c[:-1] + c[1:])
return np.concatenate(
[[c[0] - (mid[0] - c[0])], mid, [c[-1] + (c[-1] - mid[-1])]]
)
[docs]
def plot_res_phase_pseudosection(
sites: Any, *, axes: Any = None, **kwargs: Any
):
"""Functional wrapper around :class:`PlotResPhasePseudoSection`.
Equivalent to
``PlotResPhasePseudoSection(sites, axes=axes, **kwargs).plot()``.
"""
return PlotResPhasePseudoSection(sites, axes=axes, **kwargs).plot()