# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
Visualisation for pyCSAMT 1-D and 2-D forward models and responses.
All plot functions read visual parameters from the package-wide API singletons
at call time, so a single :func:`~pycsamt.api.style.configure_style` or
:func:`~pycsamt.api.style.use_style` call propagates to every figure.
Functions
---------
**1-D forward**
.. autosummary::
plot_response_1d
plot_model_1d
plot_response_and_model_1d
**2-D forward**
.. autosummary::
plot_model_2d
plot_pseudosection_2d
plot_response_profiles
Quick start
-----------
Single 1-D sounding::
from pycsamt.forward import MT1DForward, LayeredModel
from pycsamt.forward.plot import plot_response_and_model_1d
import numpy as np
model = LayeredModel([100, 10, 500], [300, 800])
resp = MT1DForward(np.logspace(-3, 3, 30)).run(model)
fig = plot_response_and_model_1d(resp, model, title="My 1-D model")
fig.savefig("model_1d.png", dpi=150, bbox_inches="tight")
2-D pseudo-section::
from pycsamt.forward.plot import plot_pseudosection_2d, plot_model_2d
fig1 = plot_model_2d(grid)
fig2 = plot_pseudosection_2d(resp2d, mode="both")
"""
from __future__ import annotations
from collections.abc import Sequence
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.axes import Axes
from matplotlib.figure import Figure
from ..api._rose_style import _UNSET
from ..api.control import PYCSAMT_CONTROL
from ..api.station import PYCSAMT_STATION_RENDERING
from ..api.style import PYCSAMT_STYLE
__all__ = [
"plot_response_1d",
"plot_model_1d",
"plot_response_and_model_1d",
"plot_model_2d",
"plot_pseudosection_2d",
"plot_response_profiles",
# 3-D forward
"plot_model_3d",
"plot_response_map_3d",
"plot_response_section_3d",
"plot_tensor_components_3d",
]
# ─────────────────────────────────────────────────────────────────────────────
# Internal helpers
# ─────────────────────────────────────────────────────────────────────────────
def _x_vals(freqs: np.ndarray) -> np.ndarray:
"""Return x-axis values from frequency using the active control."""
return PYCSAMT_CONTROL.x.transform(freqs)
def _x_label() -> str:
return PYCSAMT_CONTROL.x.label()
def _x_log() -> bool:
return PYCSAMT_CONTROL.x.use_log_scale()
def _rho_vals(rho: np.ndarray) -> np.ndarray:
return PYCSAMT_CONTROL.rho.transform(rho)
def _rho_label() -> str:
return PYCSAMT_CONTROL.rho.label()
def _phase_vals(phase: np.ndarray) -> np.ndarray:
return PYCSAMT_CONTROL.phase.transform(phase)
def _phase_label() -> str:
return PYCSAMT_CONTROL.phase.label()
def _spine_style(ax: Axes) -> None:
"""Apply a clean spine / grid style consistent with pyCSAMT figures."""
ax.grid(True, which="both", ls=":", lw=0.4, color="0.75", zorder=0)
ax.set_axisbelow(True)
def _add_colorbar(
fig: Figure,
ax: Axes,
mappable,
label: str,
fontsize: float = 8,
pad: float = 0.03,
shrink: float = 0.95,
) -> None:
cb = fig.colorbar(mappable, ax=ax, pad=pad, shrink=shrink, aspect=25)
cb.set_label(label, fontsize=fontsize)
cb.ax.tick_params(labelsize=fontsize - 0.5)
# ─────────────────────────────────────────────────────────────────────────────
# 1-D response plot
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def plot_response_1d(
response,
*,
modes: str | Sequence[str] = "both",
show_te: bool = _UNSET,
show_tm: bool = _UNSET,
color_te=_UNSET,
color_tm=_UNSET,
lw: float = _UNSET,
marker_te=_UNSET,
marker_tm=_UNSET,
ms: float = _UNSET,
label_te: str = _UNSET,
label_tm: str = _UNSET,
title: str = "",
figsize: tuple[float, float] = (7, 5.5),
axes=None,
) -> np.ndarray:
"""Plot 1-D MT/CSAMT apparent resistivity and phase vs period (or frequency).
Reads visual defaults from :data:`~pycsamt.api.style.PYCSAMT_STYLE` and
axis behaviour from :data:`~pycsamt.api.control.PYCSAMT_CONTROL`.
Parameters
----------
response : ForwardResponse
Output of :class:`~pycsamt.forward.em1d.MT1DForward` or
:class:`~pycsamt.forward.em1d.CSAMT1DForward`.
modes : {'te', 'tm', 'both'}
Which modes to plot. For a 1-D response ``response.rho_a`` and
``response.phase`` hold a single polarisation; pass ``'both'`` to
show both ρ_a curves with TE/TM styling on the same axes.
show_te, show_tm : bool
Override visibility of each mode independently.
color_te, color_tm : colour spec
Line colours. Default: ``PYCSAMT_STYLE.mt.te.color`` /
``PYCSAMT_STYLE.mt.tm.color``.
lw : float
Line width.
marker_te, marker_tm : str
Marker styles.
ms : float
Marker size.
label_te, label_tm : str
Legend labels.
title : str
Figure suptitle.
figsize : (float, float)
axes : array-like of Axes or None
Two pre-existing axes (rho, phase). Created when not given.
Returns
-------
axes : ndarray of Axes, shape (2,)
``[ax_rho, ax_phase]``
"""
_st = PYCSAMT_STYLE.mt
# Resolve defaults
if color_te is _UNSET:
color_te = _st.te.color
if color_tm is _UNSET:
color_tm = _st.tm.color
if lw is _UNSET:
lw = _st.te.lw
if marker_te is _UNSET:
marker_te = _st.te.marker
if marker_tm is _UNSET:
marker_tm = _st.tm.marker
if ms is _UNSET:
ms = _st.te.ms
if label_te is _UNSET:
label_te = _st.te.label or "TE"
if label_tm is _UNSET:
label_tm = _st.tm.label or "TM"
freqs = response.freqs
x = _x_vals(freqs)
# Handle both 1D (rho_a shape (nf,)) and 2D responses
rho_a = np.asarray(response.rho_a)
phase = np.asarray(response.phase)
if rho_a.ndim > 1:
rho_a = rho_a[:, 0]
phase = phase[:, 0]
if axes is None:
fig, axs = plt.subplots(
2, 1, figsize=figsize, sharex=True, constrained_layout=True
)
else:
axs = np.asarray(axes).ravel()
fig = axs[0].get_figure()
ax_r, ax_p = axs[0], axs[1]
kw_te = dict(
color=color_te,
lw=lw,
marker=marker_te,
ms=ms,
mfc="white",
mew=1.0,
alpha=_st.te.alpha,
)
kw_tm = dict(
color=color_tm,
lw=lw,
marker=marker_tm,
ms=ms,
mfc="white",
mew=1.0,
alpha=_st.tm.alpha,
)
modes_str = modes if isinstance(modes, str) else ",".join(modes)
plot_te = modes_str in ("te", "both") and show_te is not False
plot_tm = modes_str in ("tm", "both") and show_tm is not False
if plot_te:
ax_r.plot(x, _rho_vals(rho_a), label=label_te, **kw_te)
ax_p.plot(x, _phase_vals(phase), label=label_tm, **kw_te)
if plot_tm and hasattr(response, "rho_a_tm"):
rho_tm = np.asarray(response.rho_a_tm)
phi_tm = np.asarray(response.phase_tm)
if rho_tm.ndim > 1:
rho_tm = rho_tm[:, 0]
phi_tm = phi_tm[:, 0]
ax_r.plot(x, _rho_vals(rho_tm), label=label_tm, **kw_tm)
ax_p.plot(x, _phase_vals(phi_tm), **kw_tm)
for ax in (ax_r, ax_p):
_spine_style(ax)
if _x_log():
ax.set_xscale("log")
ax_r.set_ylabel(_rho_label(), fontsize=9)
ax_p.set_ylabel(_phase_label(), fontsize=9)
ax_p.set_xlabel(_x_label(), fontsize=9)
if plot_te or plot_tm:
ax_r.legend(fontsize=8, framealpha=0.8)
if title:
fig.suptitle(title, fontsize=10, y=1.01)
return np.array([ax_r, ax_p])
# ─────────────────────────────────────────────────────────────────────────────
# 1-D model plot
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def plot_model_1d(
models,
labels: Sequence[str] | None = None,
*,
log_rho: bool = True,
depth_max: float | None = None,
lw: float = _UNSET,
alpha: float = _UNSET,
title: str = "",
figsize: tuple[float, float] = (3.8, 5.5),
ax: Axes | None = None,
) -> Axes:
"""Plot one or more 1-D layered earth models as resistivity-depth profiles.
Multiple models are coloured with :attr:`PYCSAMT_STYLE.multiline` so the
gradient or cycle palette matches all other multi-model plots.
Parameters
----------
models : LayeredModel or list of LayeredModel
One model or a list to overlay.
labels : list of str, optional
Legend labels; defaults to model name attributes.
log_rho : bool
Use log₁₀ scale on the resistivity axis.
depth_max : float or None
Maximum depth shown [m]. Defaults to the deepest interface × 1.2.
lw : float
Line width. Default: ``PYCSAMT_STYLE.multiline.lw``.
alpha : float
Line alpha. Default: ``PYCSAMT_STYLE.multiline.alpha``.
title : str
figsize : (float, float)
ax : Axes or None
Returns
-------
ax : Axes
"""
from .synthetic import LayeredModel
_ml = PYCSAMT_STYLE.multiline
if lw is _UNSET:
lw = _ml.lw
if alpha is _UNSET:
alpha = _ml.alpha
if isinstance(models, LayeredModel):
models = [models]
n = len(models)
colors = _ml.colors(n) if n > 1 else [PYCSAMT_STYLE.mt.xy.color]
if ax is None:
_, ax = plt.subplots(figsize=figsize, constrained_layout=True)
for k, model in enumerate(models):
rho = model.resistivity
thick = model.thickness
depth_top = np.concatenate([[0.0], np.cumsum(thick)])
d_max = depth_max or (
float(depth_top[-1] + thick[-1]) * 1.2
if len(thick)
else float(rho[0]) * 2
)
depth_bot = np.concatenate([depth_top[1:], [d_max]])
xs = np.repeat(rho, 2)
ys = np.empty(2 * len(rho))
ys[0::2] = depth_top
ys[1::2] = depth_bot
lab = (
labels[k]
if labels and k < len(labels)
else model.name or f"model {k + 1}"
)
ax.plot(
xs,
ys,
color=colors[k],
lw=lw,
alpha=alpha,
label=lab if n > 1 else None,
)
ax.invert_yaxis()
if log_rho:
ax.set_xscale("log")
ax.set_xlabel(r"Resistivity ($\Omega\cdot$m)", fontsize=9)
ax.set_ylabel("Depth (m)", fontsize=9)
if n > 1:
ax.legend(fontsize=8, framealpha=0.8)
if title:
ax.set_title(title, fontsize=10)
_spine_style(ax)
return ax
# ─────────────────────────────────────────────────────────────────────────────
# 1-D composite (model + response side-by-side)
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def plot_response_and_model_1d(
response,
model=None,
*,
title: str = "",
figsize: tuple[float, float] = (10, 5),
gridspec_kw: dict | None = None,
) -> Figure:
"""3-panel figure combining model depth profile, ρ_a, and phase.
This is the canonical "validate and save" view for a 1-D forward run.
Parameters
----------
response : ForwardResponse
model : LayeredModel or None
If given, plotted in the left panel. When ``None``, only the
two response panels are shown.
title : str
Figure suptitle.
figsize : (float, float)
gridspec_kw : dict or None
Passed to :func:`~matplotlib.pyplot.subplots`.
Returns
-------
Figure
"""
has_model = model is not None
ncols = 3 if has_model else 2
gs_kw = gridspec_kw or (
{"width_ratios": [1, 2, 2]} if has_model else {"width_ratios": [1, 1]}
)
fig, axs = plt.subplots(
1,
ncols,
figsize=figsize,
gridspec_kw=gs_kw,
constrained_layout=True,
)
if has_model:
ax_m, ax_r, ax_p = axs
plot_model_1d(model, ax=ax_m)
ax_m.set_title("Earth model", fontsize=9, pad=6)
else:
ax_r, ax_p = axs
freqs = response.freqs
x = _x_vals(freqs)
rho_a = np.asarray(response.rho_a)
phase = np.asarray(response.phase)
if rho_a.ndim > 1:
rho_a = rho_a[:, 0]
phase = phase[:, 0]
_st = PYCSAMT_STYLE.mt
kw = _st.te.plot_kwargs(label="TE")
ax_r.plot(x, _rho_vals(rho_a), **kw)
ax_p.plot(x, _phase_vals(phase), **kw)
for ax in (ax_r, ax_p):
_spine_style(ax)
if _x_log():
ax.set_xscale("log")
ax.set_xlabel(_x_label(), fontsize=9)
ax_r.set_ylabel(_rho_label(), fontsize=9)
ax_r.set_title(r"Apparent resistivity $\rho_a$", fontsize=9, pad=6)
ax_p.set_ylabel(_phase_label(), fontsize=9)
ax_p.set_title("Impedance phase", fontsize=9, pad=6)
if title:
fig.suptitle(title, fontsize=11, y=1.02)
return fig
# ─────────────────────────────────────────────────────────────────────────────
# 2-D model view
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def plot_model_2d(
grid,
*,
cmap: str = "jet_r",
log_scale: bool = True,
clip_core: bool = True,
vmin: float | None = None,
vmax: float | None = None,
show_stations: bool = True,
station_preset: str = "inversion",
title: str = "",
figsize: tuple[float, float] = (11, 4),
ax: Axes | None = None,
) -> Axes:
"""Plot the 2-D resistivity model on a colour map.
Station markers and labels are rendered using
:data:`~pycsamt.api.station.PYCSAMT_STATION_RENDERING`.
Parameters
----------
grid : Grid2D
cmap : str
Colourmap name. ``"jet_r"`` is the geophysical convention
(blue = conductive, red = resistive).
log_scale : bool
Display log₁₀(ρ) rather than ρ.
clip_core : bool
Clip to the non-padding core region.
vmin, vmax : float or None
Colour limits in log₁₀(Ω·m) when *log_scale* is True.
show_stations : bool
Render station markers and labels on the surface.
station_preset : str
``"inversion"`` or ``"pseudosection"`` or ``"survey"``.
title : str
figsize : (float, float)
ax : Axes or None
Returns
-------
ax : Axes
"""
if ax is None:
fig, ax = plt.subplots(figsize=figsize, constrained_layout=True)
else:
fig = ax.get_figure()
p = grid.n_pad
xs = grid.core_x_slice if clip_core else slice(None)
zs = grid.core_z_slice if clip_core else slice(None)
rho = grid.resistivity[zs, xs]
xn = (
grid.x_nodes[p : grid.nx + 1 - p]
if clip_core and p > 0
else grid.x_nodes
)
zn = (
grid.z_nodes[: grid.nz + 1 - p]
if clip_core and p > 0
else grid.z_nodes
)
if log_scale:
data = np.log10(np.maximum(rho, 1e-12))
clabel = r"$\log_{10}\rho$ ($\Omega\cdot$m)"
else:
data = rho
clabel = r"$\rho$ ($\Omega\cdot$m)"
pc = ax.pcolormesh(
xn,
zn,
data,
cmap=cmap,
shading="flat",
vmin=vmin,
vmax=vmax,
)
_add_colorbar(fig, ax, pc, clabel)
ax.invert_yaxis()
ax.set_xlabel("Distance (m)", fontsize=9)
ax.set_ylabel("Depth (m)", fontsize=9)
if show_stations and grid.n_stations > 0:
# Map station x to the core-clipped coordinate system
st_x = grid.x_stations
xlim = (float(xn[0]), float(xn[-1]))
labels = [f"{i + 1}" for i in range(grid.n_stations)]
sty = PYCSAMT_STATION_RENDERING.style_for(station_preset)
sty.apply(ax, st_x, labels, xlim=xlim)
ax.set_title(title or "2-D resistivity model", fontsize=10, pad=6)
_spine_style(ax)
return ax
# ─────────────────────────────────────────────────────────────────────────────
# 2-D pseudo-section
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def plot_pseudosection_2d(
response,
*,
mode: str = "te",
quantity: str = "rho_a",
cmap: str = _UNSET,
vmin: float | None = None,
vmax: float | None = None,
n_contours: int = 0,
show_stations: bool = True,
station_preset: str = "pseudosection",
title: str = "",
figsize: tuple[float, float] = (11, 5),
ax: Axes | None = None,
) -> Axes:
"""Plot a 2-D MT pseudo-section (period × station distance).
The colour shows log₁₀(ρ_a) or phase at each (period, station) point.
Station markers are rendered via
:data:`~pycsamt.api.station.PYCSAMT_STATION_RENDERING`.
Parameters
----------
response : ForwardResponse2D
mode : {'te', 'tm'}
Which polarisation mode to display.
quantity : {'rho_a', 'phase'}
Quantity to colour.
cmap : str
Colour map. Defaults to ``"jet_r"`` for ρ_a and ``"RdBu_r"``
for phase.
vmin, vmax : float or None
Colour limits.
n_contours : int
Number of contour lines overlaid on the colour map (0 = none).
show_stations : bool
Add station axis with markers.
station_preset : str
title : str
figsize : (float, float)
ax : Axes or None
Returns
-------
ax : Axes
"""
# Resolve the data
mode = mode.lower()
quantity = quantity.lower()
attr = f"rho_a_{mode}" if quantity == "rho_a" else f"phase_{mode}"
data_raw = getattr(response, attr) # shape (n_freqs, n_stations)
freqs = response.freqs
st_x = response.stations_x # (n_stations,)
# Build x-axis (station distance), y-axis (log10 period)
y_vals = np.log10(1.0 / freqs) # log10(period)
x_vals = st_x
# Prepare colour data
if quantity == "rho_a":
data_c = np.log10(np.maximum(data_raw, 1e-12))
default_cmap = "jet_r"
cb_label = (
r"$\log_{10}\rho_a$ ($\Omega\cdot$m) " + f"[{mode.upper()}]"
)
else:
data_c = _phase_vals(data_raw)
default_cmap = "RdBu_r"
cb_label = _phase_label() + f" [{mode.upper()}]"
if cmap is _UNSET:
cmap = default_cmap
if ax is None:
fig, ax = plt.subplots(figsize=figsize, constrained_layout=True)
else:
fig = ax.get_figure()
# pcolormesh needs coordinate edges (n+1 values)
dx_half = np.diff(x_vals) / 2.0 if len(x_vals) > 1 else np.array([50.0])
x_edges = np.concatenate(
[
[x_vals[0] - dx_half[0]],
x_vals[:-1] + dx_half,
[x_vals[-1] + dx_half[-1]],
]
)
dy_half = (
np.abs(np.diff(y_vals)) / 2.0 if len(y_vals) > 1 else np.array([0.2])
)
y_edges = np.concatenate(
[
[y_vals[0] - dy_half[0]],
y_vals[:-1] + (np.sign(np.diff(y_vals)) * dy_half),
[y_vals[-1] + np.sign(y_vals[-1] - y_vals[-2]) * dy_half[-1]],
]
)
pc = ax.pcolormesh(
x_edges,
y_edges,
data_c,
cmap=cmap,
shading="flat",
vmin=vmin,
vmax=vmax,
)
_add_colorbar(fig, ax, pc, cb_label)
if n_contours > 0:
ax.contour(
x_vals,
y_vals,
data_c,
levels=n_contours,
colors="k",
linewidths=0.5,
alpha=0.6,
)
# Y axis
ax.set_ylabel(r"$\log_{10}T$ (s)", fontsize=9)
# Make sure higher periods (deeper) are at top (reversed y-axis is NOT
# standard for pseudo-sections — low period = shallow = top)
if (
y_vals[0] > y_vals[-1]
): # frequencies sorted high → low → periods low→high
ax.invert_yaxis()
# Station axis via PYCSAMT_STATION_RENDERING
if show_stations and len(st_x) > 0:
labels = [f"{i + 1}" for i in range(len(st_x))]
xlim = (float(x_edges[0]), float(x_edges[-1]))
sty = PYCSAMT_STATION_RENDERING.style_for(station_preset)
sty.apply(ax, st_x, labels, xlim=xlim)
else:
ax.set_xlabel("Distance (m)", fontsize=9)
mode_label = mode.upper()
qty_label = "Apparent resistivity" if quantity == "rho_a" else "Phase"
ax.set_title(
title or f"2-D MT pseudo-section — {qty_label} ({mode_label})",
fontsize=10,
pad=6,
)
_spine_style(ax)
return ax
# ─────────────────────────────────────────────────────────────────────────────
# Horizontal response profiles
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def plot_response_profiles(
response,
*,
mode: str = "te",
quantity: str = "rho_a",
freq_indices: Sequence[int] | None = None,
n_freqs_shown: int = 5,
lw: float = _UNSET,
alpha: float = _UNSET,
title: str = "",
figsize: tuple[float, float] = (9, 4),
ax: Axes | None = None,
) -> Axes:
"""Plot ρ_a (or phase) vs station distance at selected frequencies.
Each frequency is a separate line coloured by
:attr:`PYCSAMT_STYLE.multiline`, making it easy to see how the lateral
anomaly signature changes with depth (period).
Parameters
----------
response : ForwardResponse2D
mode : {'te', 'tm'}
quantity : {'rho_a', 'phase'}
freq_indices : sequence of int or None
Indices into ``response.freqs`` to display. When ``None``,
*n_freqs_shown* equally spaced indices are chosen automatically.
n_freqs_shown : int
Number of frequency curves when *freq_indices* is ``None``.
lw : float
Line width. Default: ``PYCSAMT_STYLE.multiline.lw``.
alpha : float
Line alpha. Default: ``PYCSAMT_STYLE.multiline.alpha``.
title : str
figsize : (float, float)
ax : Axes or None
Returns
-------
ax : Axes
"""
_ml = PYCSAMT_STYLE.multiline
if lw is _UNSET:
lw = _ml.lw
if alpha is _UNSET:
alpha = _ml.alpha
attr = (
f"rho_a_{mode.lower()}"
if quantity == "rho_a"
else f"phase_{mode.lower()}"
)
data = getattr(response, attr) # (n_freqs, n_stations)
freqs = response.freqs
st_x = response.stations_x
nf = len(freqs)
if freq_indices is None:
freq_indices = np.round(
np.linspace(0, nf - 1, min(n_freqs_shown, nf))
).astype(int)
freq_indices = list(freq_indices)
colors = _ml.colors(len(freq_indices))
if ax is None:
_, ax = plt.subplots(figsize=figsize, constrained_layout=True)
for ki, fi in enumerate(freq_indices):
row = data[fi, :]
if quantity == "rho_a":
y = _rho_vals(row)
else:
y = _phase_vals(row)
per = 1.0 / freqs[fi]
lab = f"T = {per:.3g} s"
ax.plot(
st_x,
y,
color=colors[ki],
lw=lw,
alpha=alpha,
marker=".",
ms=4,
label=lab,
)
ax.set_xlabel("Station distance (m)", fontsize=9)
ylabel = _rho_label() if quantity == "rho_a" else _phase_label()
ax.set_ylabel(f"{ylabel} [{mode.upper()}]", fontsize=9)
ax.legend(fontsize=7.5, framealpha=0.8, ncol=2)
ax.set_title(
title or f"Lateral profiles — {quantity} ({mode.upper()})",
fontsize=10,
pad=6,
)
_spine_style(ax)
return ax
# ─────────────────────────────────────────────────────────────────────────────
# 3-D model — orthogonal slice view
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def plot_model_3d(
grid3d,
*,
cmap: str = "jet_r",
log_scale: bool = True,
clip_core: bool = True,
vmin: float | None = None,
vmax: float | None = None,
show_stations: bool = True,
title: str = "",
figsize: tuple[float, float] = (13, 4.5),
) -> np.ndarray:
"""Three orthogonal slice panels for a 3-D resistivity model.
Displays the XZ (mid-y), YZ (mid-x), and XY (mid-z) cross-sections of
*grid3d* as colour maps. Station positions are overlaid on the XY
(map-view) panel.
Parameters
----------
grid3d : Grid3D
cmap : str
log_scale : bool
clip_core : bool
vmin, vmax : float or None
Colour limits in log₁₀(Ω·m) when *log_scale* is True.
show_stations : bool
title : str
figsize : (float, float)
Returns
-------
axes : ndarray of Axes, shape (3,)
``[ax_xz, ax_yz, ax_xy]``
"""
p = grid3d.n_pad
cx = slice(p, grid3d.nx - p) if (clip_core and p) else slice(None)
cy = slice(p, grid3d.ny - p) if (clip_core and p) else slice(None)
cz = slice(None, grid3d.nz - p) if (clip_core and p) else slice(None)
xn = (
grid3d.x_nodes[p : grid3d.nx + 1 - p]
if (clip_core and p)
else grid3d.x_nodes
)
yn = (
grid3d.y_nodes[p : grid3d.ny + 1 - p]
if (clip_core and p)
else grid3d.y_nodes
)
zn = (
grid3d.z_nodes[: grid3d.nz + 1 - p]
if (clip_core and p)
else grid3d.z_nodes
)
mid_y = p + (grid3d.ny - 2 * p) // 2 if p else grid3d.ny // 2
mid_x = p + (grid3d.nx - 2 * p) // 2 if p else grid3d.nx // 2
mid_z = (grid3d.nz - p) // 2 if p else grid3d.nz // 2
rho = grid3d.resistivity
def _prep(data):
return np.log10(np.maximum(data, 1e-12)) if log_scale else data
slices = [
(
_prep(rho[cz, mid_y, cx]),
xn,
zn,
"x (m)",
"z (m)",
f"XZ (y = {grid3d.y_centers[mid_y]:.0f} m)",
),
(
_prep(rho[cz, cy, mid_x]),
yn,
zn,
"y (m)",
"z (m)",
f"YZ (x = {grid3d.x_centers[mid_x]:.0f} m)",
),
(
_prep(rho[mid_z, cy, cx]),
xn,
yn,
"x (m)",
"y (m)",
f"XY (z = {grid3d.z_centers[mid_z]:.0f} m)",
),
]
clabel = (
r"$\log_{10}\rho$ ($\Omega\cdot$m)"
if log_scale
else r"$\rho$ ($\Omega\cdot$m)"
)
fig, axs = plt.subplots(1, 3, figsize=figsize, constrained_layout=True)
for ax, (data, h_nodes, v_nodes, xlb, ylb, ttl) in zip(axs, slices):
pc = ax.pcolormesh(
h_nodes,
v_nodes,
data,
cmap=cmap,
shading="flat",
vmin=vmin,
vmax=vmax,
)
_add_colorbar(
fig, ax, pc, clabel, fontsize=7.5, pad=0.02, shrink=0.92
)
if ylb == "z (m)":
ax.invert_yaxis()
ax.set_xlabel(xlb, fontsize=8)
ax.set_ylabel(ylb, fontsize=8)
ax.set_title(ttl, fontsize=8.5, pad=4)
_spine_style(ax)
if show_stations and grid3d.n_stations > 0:
axs[2].scatter(
grid3d.stations_xy[:, 0],
grid3d.stations_xy[:, 1],
marker="v",
s=36,
color="k",
zorder=5,
label="stations",
)
axs[2].legend(fontsize=7, framealpha=0.7, loc="upper right")
fig.suptitle(
title or (grid3d.name or "3-D resistivity model"), fontsize=10, y=1.01
)
return np.array(axs)
# ─────────────────────────────────────────────────────────────────────────────
# 3-D response — map view (one frequency)
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def plot_response_map_3d(
response3d,
*,
freq_idx: int = 0,
component: str = "xy",
quantity: str = "rho_a",
cmap=_UNSET,
vmin: float | None = None,
vmax: float | None = None,
show_labels: bool = True,
marker_size: float = 120.0,
title: str = "",
figsize: tuple[float, float] = (7, 6),
ax: Axes | None = None,
) -> Axes:
"""Map-view scatter of ρ_a or phase at one frequency.
Each station is drawn as a coloured symbol at its (x, y) surface
position.
Parameters
----------
response3d : ForwardResponse3D
freq_idx : int
component : {'xy', 'yx', 'xx', 'yy'}
quantity : {'rho_a', 'phase'}
cmap : str or _UNSET
vmin, vmax : float or None
show_labels : bool
marker_size : float
title : str
figsize : (float, float)
ax : Axes or None
Returns
-------
ax : Axes
"""
from ..api.plot import add_colorbar as _add_cb
comp = component.lower()
attr = f"rho_a_{comp}" if quantity == "rho_a" else f"phase_{comp}"
raw = getattr(response3d, attr)[freq_idx, :]
if quantity == "rho_a":
data_c = np.log10(np.maximum(raw, 1e-12))
default_cmap = "jet_r"
cb_label = (
r"$\log_{10}\rho_a$ ($\Omega\cdot$m) "
f"[Z_{comp.upper()}]"
)
else:
data_c = _phase_vals(raw)
default_cmap = "RdBu_r"
cb_label = _phase_label() + f" [Z_{comp.upper()}]"
if cmap is _UNSET:
cmap = default_cmap
if ax is None:
fig, ax = plt.subplots(figsize=figsize, constrained_layout=True)
else:
ax.get_figure()
x_st = response3d.stations_xy[:, 0]
y_st = response3d.stations_xy[:, 1]
sc = ax.scatter(
x_st,
y_st,
c=data_c,
cmap=cmap,
s=marker_size,
vmin=vmin,
vmax=vmax,
edgecolors="0.3",
linewidths=0.6,
zorder=4,
)
_add_cb(
sc, ax, label=cb_label, side="right", size="4%", pad=0.06, max_ticks=6
)
if show_labels:
for i, (xi, yi) in enumerate(zip(x_st, y_st)):
ax.annotate(
str(i + 1),
(xi, yi),
xytext=(4, 4),
textcoords="offset points",
fontsize=6.5,
color="0.3",
)
freq = response3d.freqs[freq_idx]
per = 1.0 / freq
ax.set_xlabel("x (m)", fontsize=9)
ax.set_ylabel("y (m)", fontsize=9)
ax.set_aspect("equal")
ax.set_title(
title
or (f"Map view — {quantity} [Z_{comp.upper()}] T = {per:.3g} s"),
fontsize=10,
pad=6,
)
_spine_style(ax)
return ax
# ─────────────────────────────────────────────────────────────────────────────
# 3-D response — pseudo-section (period × station)
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def plot_response_section_3d(
response3d,
*,
component: str = "xy",
quantity: str = "rho_a",
y_row: int | None = None,
cmap=_UNSET,
vmin: float | None = None,
vmax: float | None = None,
n_contours: int = 0,
show_stations: bool = True,
station_preset: str = "pseudosection",
title: str = "",
figsize: tuple[float, float] = (11, 5),
ax: Axes | None = None,
) -> Axes:
"""Period × station pseudo-section for one 3-D response component.
Stations are sorted along x and projected onto a profile at a selected
y-row (default: mid-y row).
Parameters
----------
response3d : ForwardResponse3D
component : {'xy', 'yx', 'xx', 'yy'}
quantity : {'rho_a', 'phase'}
y_row : int or None
Index of the y-row. ``None`` → midpoint row.
cmap : str or _UNSET
vmin, vmax : float or None
n_contours : int
show_stations : bool
station_preset : str
title : str
figsize : (float, float)
ax : Axes or None
Returns
-------
ax : Axes
"""
comp = component.lower()
attr = f"rho_a_{comp}" if quantity == "rho_a" else f"phase_{comp}"
data = getattr(response3d, attr) # (n_freqs, n_stations)
freqs = response3d.freqs
xy = response3d.stations_xy
# Select y-row
y_unique = np.unique(xy[:, 1])
if y_row is None:
y_row = len(y_unique) // 2
y_sel = y_unique[min(y_row, len(y_unique) - 1)]
tol = 0.5 * (y_unique[1] - y_unique[0]) if len(y_unique) > 1 else 1e9
mask = np.abs(xy[:, 1] - y_sel) < tol
if not mask.any():
mask = np.ones(len(xy), dtype=bool)
st_x = xy[mask, 0]
data_s = data[:, mask]
order = np.argsort(st_x)
st_x, data_s = st_x[order], data_s[:, order]
if quantity == "rho_a":
data_c = np.log10(np.maximum(data_s, 1e-12))
default_cmap = "jet_r"
cb_label = (
r"$\log_{10}\rho_a$ ($\Omega\cdot$m) "
f"[Z_{comp.upper()}]"
)
else:
data_c = _phase_vals(data_s)
default_cmap = "RdBu_r"
cb_label = _phase_label() + f" [Z_{comp.upper()}]"
if cmap is _UNSET:
cmap = default_cmap
y_log = np.log10(1.0 / freqs)
if ax is None:
fig, ax = plt.subplots(figsize=figsize, constrained_layout=True)
else:
fig = ax.get_figure()
if len(st_x) > 1:
dx = np.diff(st_x) / 2.0
x_edges = np.r_[st_x[0] - dx[0], st_x[:-1] + dx, st_x[-1] + dx[-1]]
else:
x_edges = np.r_[st_x[0] - 100.0, st_x[0] + 100.0]
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]
pc = ax.pcolormesh(
x_edges,
y_edges,
data_c,
cmap=cmap,
shading="flat",
vmin=vmin,
vmax=vmax,
)
_add_colorbar(fig, ax, pc, cb_label)
if n_contours > 0 and data_c.shape[1] > 1:
ax.contour(
st_x,
y_log,
data_c,
levels=n_contours,
colors="k",
linewidths=0.5,
alpha=0.6,
)
ax.set_ylabel(r"$\log_{10}T$ (s)", fontsize=9)
if y_log[0] > y_log[-1]:
ax.invert_yaxis()
if show_stations and len(st_x) > 0:
labels = [f"{i + 1}" for i in range(len(st_x))]
xlim = (float(x_edges[0]), float(x_edges[-1]))
sty = PYCSAMT_STATION_RENDERING.style_for(station_preset)
sty.apply(ax, st_x, labels, xlim=xlim)
else:
ax.set_xlabel("x (m)", fontsize=9)
qty_lbl = "Apparent resistivity" if quantity == "rho_a" else "Phase"
ax.set_title(
title
or (
f"3-D pseudo-section — {qty_lbl} [Z_{comp.upper()}]"
f" (y = {y_sel:.0f} m)"
),
fontsize=10,
pad=6,
)
_spine_style(ax)
return ax
# ─────────────────────────────────────────────────────────────────────────────
# 3-D response — full 2 × 2 tensor component panel
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def plot_tensor_components_3d(
response3d,
*,
freq_idx: int = 0,
quantity: str = "rho_a",
cmap=_UNSET,
vmin: float | None = None,
vmax: float | None = None,
marker_size: float = 100.0,
title: str = "",
figsize: tuple[float, float] = (12, 10),
) -> np.ndarray:
"""2 × 2 map panel showing all four impedance tensor components.
Panels are arranged as:
``[[Z_xx, Z_xy], [Z_yx, Z_yy]]``
Parameters
----------
response3d : ForwardResponse3D
freq_idx : int
quantity : {'rho_a', 'phase'}
cmap : str or _UNSET
vmin, vmax : float or None
marker_size : float
title : str
figsize : (float, float)
Returns
-------
axes : ndarray of Axes, shape (2, 2)
"""
fig, axs = plt.subplots(2, 2, figsize=figsize, constrained_layout=True)
for idx, comp in enumerate(["xx", "xy", "yx", "yy"]):
r, c = divmod(idx, 2)
plot_response_map_3d(
response3d,
freq_idx=freq_idx,
component=comp,
quantity=quantity,
cmap=cmap,
vmin=vmin,
vmax=vmax,
show_labels=False,
marker_size=marker_size,
ax=axs[r, c],
)
freq = response3d.freqs[freq_idx]
per = 1.0 / freq
qty_lbl = "Apparent resistivity" if quantity == "rho_a" else "Phase"
fig.suptitle(
title or f"Full impedance tensor — {qty_lbl} T = {per:.3g} s",
fontsize=11,
y=1.01,
)
return axs