# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
2-D resistivity section and pseudo-section visualisation.
All functions respect the :class:`~pycsamt.ai.plot._style.EMStyle`
publication conventions and return :class:`~matplotlib.figure.Figure`
or :class:`~matplotlib.axes.Axes` objects for downstream composition.
"""
from __future__ import annotations
from typing import Any
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.axes import Axes
from matplotlib.figure import Figure
from ._style import (
EM_CMAPS,
EM_COLORS,
EM_FIGSIZE,
EMStyle,
add_colorbar,
)
__all__ = [
"plot_section",
"plot_section_pair",
"plot_pseudo_section",
]
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
def _default_depths(n_depth: int, depth_max: float = 2000.0) -> np.ndarray:
return np.linspace(0.0, depth_max, n_depth + 1)
def _default_stations(n_stations: int, spacing: float = 1.0) -> np.ndarray:
return np.arange(n_stations) * spacing
def _section_im(
ax: Axes,
rho_2d: np.ndarray,
depths: np.ndarray,
stations: np.ndarray,
*,
log_scale: bool = True,
vmin: float | None = None,
vmax: float | None = None,
cmap: str = "RdYlBu_r",
aspect: str = "auto",
) -> Any:
"""Draw a filled 2-D resistivity section on *ax*."""
data = rho_2d.copy()
if log_scale:
data = np.log10(np.maximum(data, 1e-6))
cbar_label = r"$\log_{10}(\rho)$ (Ω·m)"
else:
cbar_label = r"$\rho$ (Ω·m)"
if vmin is None:
vmin = np.nanpercentile(data, 2)
if vmax is None:
vmax = np.nanpercentile(data, 98)
# pcolormesh: (n_depth+1, n_sta+1) edges, (n_depth, n_sta) values
X, Y = np.meshgrid(
stations,
depths[:-1] if len(depths) == rho_2d.shape[0] + 1 else depths,
)
im = ax.pcolormesh(
X,
Y,
data,
cmap=cmap,
vmin=vmin,
vmax=vmax,
shading="auto",
)
ax.set_aspect(aspect)
return im, cbar_label
# ─────────────────────────────────────────────────────────────────────────────
# plot_section
# ─────────────────────────────────────────────────────────────────────────────
[docs]
@EMStyle()
def plot_section(
rho_2d: np.ndarray,
*,
depths: np.ndarray | None = None,
stations: np.ndarray | None = None,
depth_max: float = 2000.0,
station_spacing: float = 1.0,
log_scale: bool = True,
vmin: float | None = None,
vmax: float | None = None,
cmap: str | None = None,
title: str = "",
xlabel: str = "Station",
ylabel: str = "Depth (m)",
show_sites: bool = True,
figsize: tuple[float, float] | None = None,
ax: Axes | None = None,
style: bool = True,
) -> Figure:
"""
Plot a 2-D resistivity section.
Parameters
----------
rho_2d : ndarray, shape (n_depth, n_stations)
Resistivity or log₁₀(ρ) 2-D model.
depths : ndarray (n_depth,) or None
Depth values in metres. Default: linear 0 → ``depth_max``.
stations : ndarray (n_stations,) or None
Station positions (arbitrary units).
depth_max : float
Maximum depth used when ``depths`` is ``None``.
station_spacing : float
Station interval when ``stations`` is ``None``.
log_scale : bool
Apply log₁₀ transform before plotting. Set ``False`` if
``rho_2d`` already contains log₁₀(ρ).
vmin, vmax : float or None
Colour scale limits.
cmap : str or None
Matplotlib colormap name. Defaults to ``'RdYlBu_r'``.
title : str
xlabel, ylabel : str
show_sites : bool
Draw site-marker triangles at the surface.
figsize : (width, height) or None
ax : Axes or None
style : bool
Apply :class:`~pycsamt.ai.plot._style.EMStyle` context.
Returns
-------
fig : Figure
"""
n_depth, n_stations = rho_2d.shape
if depths is None:
depths = _default_depths(n_depth, depth_max)
if stations is None:
stations = _default_stations(n_stations, station_spacing)
if cmap is None:
cmap = EM_CMAPS["resistivity"]
if figsize is None:
figsize = EM_FIGSIZE["wide"]
if ax is None:
fig, ax = plt.subplots(figsize=figsize)
else:
fig = ax.get_figure()
im, cbar_label = _section_im(
ax,
rho_2d,
depths,
stations,
log_scale=log_scale,
vmin=vmin,
vmax=vmax,
cmap=cmap,
)
if show_sites:
ax.plot(
stations,
np.full_like(stations, depths.min()),
"v",
ms=4,
color=EM_COLORS["text"],
zorder=5,
)
ax.invert_yaxis()
ax.set_xlabel(xlabel)
ax.set_ylabel(ylabel)
if title:
ax.set_title(title, fontsize=10)
add_colorbar(im, ax, label=cbar_label)
fig.tight_layout()
return fig
# ─────────────────────────────────────────────────────────────────────────────
# plot_section_pair
# ─────────────────────────────────────────────────────────────────────────────
[docs]
@EMStyle()
def plot_section_pair(
true_2d: np.ndarray,
pred_2d: np.ndarray,
*,
depths: np.ndarray | None = None,
stations: np.ndarray | None = None,
depth_max: float = 2000.0,
station_spacing: float = 1.0,
log_scale: bool = True,
vmin: float | None = None,
vmax: float | None = None,
cmap: str | None = None,
show_difference: bool = True,
figsize: tuple[float, float] | None = None,
style: bool = True,
) -> Figure:
"""
Side-by-side comparison of true and predicted 2-D resistivity sections.
Optionally shows the absolute difference (``show_difference=True``).
Parameters
----------
true_2d : ndarray (n_depth, n_stations)
pred_2d : ndarray (n_depth, n_stations)
depths, stations, depth_max, station_spacing, log_scale : see :func:`plot_section`
vmin, vmax : float or None
Shared colour scale. When ``None``, computed from *true_2d*.
cmap : str or None
show_difference : bool
Add a third panel with the signed difference.
figsize : (width, height) or None
style : bool
Returns
-------
fig : Figure
"""
n_depth, n_stations = true_2d.shape
if depths is None:
depths = _default_depths(n_depth, depth_max)
if stations is None:
stations = _default_stations(n_stations, station_spacing)
if cmap is None:
cmap = EM_CMAPS["resistivity"]
# Compute in log space for consistent comparison
true_log = np.log10(np.maximum(true_2d, 1e-6)) if log_scale else true_2d
pred_log = np.log10(np.maximum(pred_2d, 1e-6)) if log_scale else pred_2d
cbar_label = r"$\log_{10}(\rho)$ (Ω·m)" if log_scale else r"$\rho$ (Ω·m)"
if vmin is None:
vmin = np.nanpercentile(true_log, 2)
if vmax is None:
vmax = np.nanpercentile(true_log, 98)
n_cols = 3 if show_difference else 2
if figsize is None:
figsize = (n_cols * 5.0, 4.0)
fig, axes = plt.subplots(1, n_cols, figsize=figsize, sharey=True)
for ax, data, ttl in zip(
axes[:2],
[true_log, pred_log],
["True model", "Predicted model"],
):
X, Y = np.meshgrid(stations, depths[:n_depth])
im = ax.pcolormesh(
X, Y, data, cmap=cmap, vmin=vmin, vmax=vmax, shading="auto"
)
ax.invert_yaxis()
ax.set_title(ttl, fontsize=9)
ax.set_xlabel("Station")
axes[0].set_ylabel("Depth (m)")
add_colorbar(im, axes[1], label=cbar_label)
if show_difference:
diff = pred_log - true_log
dmax = np.nanpercentile(np.abs(diff), 95)
X, Y = np.meshgrid(stations, depths[:n_depth])
im_d = axes[2].pcolormesh(
X,
Y,
diff,
cmap=EM_CMAPS["misfit"],
vmin=-dmax,
vmax=dmax,
shading="auto",
)
axes[2].invert_yaxis()
axes[2].set_title("Difference", fontsize=9)
axes[2].set_xlabel("Station")
add_colorbar(im_d, axes[2], label=r"$\Delta\log_{10}(\rho)$")
fig.tight_layout()
return fig
# ─────────────────────────────────────────────────────────────────────────────
# plot_pseudo_section
# ─────────────────────────────────────────────────────────────────────────────
[docs]
@EMStyle()
def plot_pseudo_section(
rho_a_2d: np.ndarray,
*,
freqs: np.ndarray | None = None,
stations: np.ndarray | None = None,
station_spacing: float = 1.0,
log_freq: bool = True,
log_rho: bool = True,
vmin: float | None = None,
vmax: float | None = None,
cmap: str | None = None,
component: str = "xy",
title: str = "",
figsize: tuple[float, float] | None = None,
ax: Axes | None = None,
style: bool = True,
) -> Figure:
"""
Apparent-resistivity or phase pseudo-section plot.
Parameters
----------
rho_a_2d : ndarray (n_freqs, n_stations)
Apparent resistivity or phase values.
freqs : ndarray (n_freqs,) or None
Frequencies in Hz.
stations : ndarray (n_stations,) or None
Station positions.
station_spacing : float
log_freq : bool
Use log₁₀(period) for the y-axis.
log_rho : bool
Apply log₁₀ transform to the data.
vmin, vmax : float or None
cmap : str or None
component : str
Label for the colour bar (e.g. ``'xy'``, ``'yx'``, ``'phase_xy'``).
title : str
figsize : (width, height) or None
ax : Axes or None
style : bool
Returns
-------
fig : Figure
"""
n_freqs, n_stations = rho_a_2d.shape
if stations is None:
stations = _default_stations(n_stations, station_spacing)
if freqs is None:
freqs = np.logspace(-3, 3, n_freqs)
if cmap is None:
cmap = EM_CMAPS["resistivity"]
if figsize is None:
figsize = EM_FIGSIZE["wide"]
y_axis = np.log10(1.0 / freqs) if log_freq else freqs
data = rho_a_2d.copy()
if log_rho:
data = np.log10(np.maximum(data, 1e-6))
cbar_label = rf"$\log_{{10}}(\rho_{{a,{component}}})$ (Ω·m)"
else:
cbar_label = rf"$\rho_{{a,{component}}}$ (Ω·m)"
if vmin is None:
vmin = np.nanpercentile(data, 2)
if vmax is None:
vmax = np.nanpercentile(data, 98)
if ax is None:
fig, ax = plt.subplots(figsize=figsize)
else:
fig = ax.get_figure()
X, Y = np.meshgrid(stations, y_axis)
im = ax.pcolormesh(
X, Y, data, cmap=cmap, vmin=vmin, vmax=vmax, shading="auto"
)
ax.set_xlabel("Station")
ax.set_ylabel(r"$\log_{10}(T)$ (s)" if log_freq else "Frequency (Hz)")
if title:
ax.set_title(title, fontsize=10)
add_colorbar(im, ax, label=cbar_label)
fig.tight_layout()
return fig