# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""One-call topography-embedded 2-D resistivity section plots.
This module generalizes the terrain-following pipeline in
:mod:`pycsamt.topo.extract`/:mod:`pycsamt.topo.drape`/
:mod:`pycsamt.topo.overlay` into a single entry point that accepts
*any* pycsamt resistivity model or inversion result and *any* source
of station topography, then renders a colour section that drapes
correctly over real terrain.
Two rendering modes are supported, mirroring the two modes already
provided by :mod:`pycsamt.topo.overlay`:
``kind="pcolormesh"`` (default)
A profile-distance vs. elevation section. The flat depth grid is
warped into terrain-following coordinates with
:func:`~pycsamt.topo.drape.drape_section` and the terrain surface
is drawn with :func:`~pycsamt.topo.overlay.draw_topo_section`.
``kind="imshow"``
A station-index vs. depth pseudosection. The colour grid is left
flat (depth increasing downward) and a compact elevation profile
strip is inserted above the axes with
:func:`~pycsamt.topo.overlay.draw_topo_strip`.
Accepted ``model`` inputs
--------------------------
* ``(x_centers, z_centers, rho_2d)`` — a plain array triple, metres by
default (see *model_unit*).
* :class:`pycsamt.interp.ResistivityModel` — the package's unified
method-agnostic 2-D model container.
* :class:`pycsamt.inversion.results.InversionResult` — the
backend-neutral result (converted via ``result.to_resistivity_model()``).
* A native :class:`pycsamt.models.occam2d.results.InversionResult`
(``rho_2d`` + ``mesh``) — converted via
:meth:`~pycsamt.interp.ResistivityModel.from_occam2d`.
* A native 2-D :class:`pycsamt.models.modem.results.InversionResult`
(``mode == "2d"``) — built from ``model_final``/``model_initial``.
* An AI agent-style result exposing ``pred_rho`` (dict, or
:class:`pycsamt.agents._base.AgentResult`), e.g. the output of
:class:`pycsamt.agents.inv3d_agent.Inv3DAgent`.
Accepted topography inputs
---------------------------
* ``sites`` — any Sites / EDI collection accepted by
:func:`~pycsamt.topo.extract.extract_elevation`.
* ``elevation`` (+ optional ``chainage``) — explicit arrays.
* Model-derived — inferred from air-like cells (``log10(rho)`` above
*air_log10_threshold*) when neither of the above is given. This is
a *relative* terrain profile (no absolute vertical datum) unless
``sites``/``elevation`` supply one.
Examples
--------
Plot a backend-neutral inversion result with topography from its
source EDI collection::
>>> from pycsamt.topo import plot_topo_section
>>> ax = plot_topo_section(result, sites=sites, depth_max=1500.0) # doctest: +SKIP
Plot an AI 3-D inversion result, depth-cropped to 1.5 km, as a
pseudosection with an elevation strip::
>>> ax = plot_topo_section(
... agent_result, sites=sites, kind="imshow",
... model_unit="km", depth_max=1.5,
... ) # doctest: +SKIP
Build the data without plotting (e.g. for a custom figure)::
>>> from pycsamt.topo import build_topo_section
>>> section = build_topo_section(model, sites=sites) # doctest: +SKIP
>>> section.values.shape # doctest: +SKIP
"""
from __future__ import annotations
import warnings
from dataclasses import dataclass, field
from typing import Any, Sequence
import numpy as np
from .config import TopoConfig
from .drape import drape_section, interp_elev
__all__ = ["TopoSection", "build_topo_section", "plot_topo_section"]
# log10(rho) threshold above which a cell is treated as "air" when
# inferring terrain directly from a model grid. Matches the ModEM
# convention (~10**5 ohm.m, i.e. ln(rho) > 11.5) expressed in log10.
_AIR_LOG10_THRESHOLD = 5.0
# ---------------------------------------------------------------------------
# Result container
# ---------------------------------------------------------------------------
[docs]
@dataclass
class TopoSection:
"""Resolved, terrain-embedded 2-D resistivity section.
Returned by :func:`build_topo_section`. Carries both the
terrain-draped grid (for ``pcolormesh``) and the flat cell-centre
grid plus raw topography arrays (for ``imshow`` / custom plots).
Attributes
----------
x_nodes_km : ndarray, shape (n_x+1,)
Profile-distance pcolormesh node positions (km).
z_draped_km : ndarray, shape (n_z+1, n_x+1)
Terrain-following elevation node grid (km a.s.l.), ready for
``ax.pcolormesh(x_nodes_km, z_draped_km, values)``.
values : ndarray, shape (n_z, n_x)
Cell values — ``log10(rho)`` when ``log_rho=True``, linear
resistivity otherwise. NaN-masked above terrain when
``clip_above_surface=True``.
x_centers_km, z_centers_km : ndarray
Flat (undraped) cell-centre coordinates (km), post depth-crop.
z_nodes_km : ndarray, shape (n_z+1,)
Flat (undraped) depth node positions (km), post depth-crop.
chainage_km, elev_km : ndarray
Resolved topography source arrays (one value per topography
sample point — station count, which may differ from ``n_x``).
surface_km : ndarray, shape (n_x+1,)
Terrain elevation interpolated to ``x_nodes_km`` (km a.s.l.).
station_x_km : ndarray
Marker x-positions (km) for station pins.
station_names : list of str
Station labels, aligned with ``station_x_km``.
depth_min_km, depth_max_km : float
Effective (post-crop) depth range, km below the flat datum.
exaggeration : float
Vertical exaggeration applied while draping.
log_rho : bool
Whether ``values`` is log10(rho) (``True``) or linear rho.
method : str
Source model tag (``"occam2d"``, ``"modem"``, ``"ai"``, ...).
rms : float
Inversion RMS misfit, if available; ``nan`` otherwise.
topo_source : str
Which topography source was actually used: ``"sites"``,
``"array"``, ``"model"``, or ``"flat"``.
"""
x_nodes_km: np.ndarray
z_draped_km: np.ndarray
values: np.ndarray
x_centers_km: np.ndarray
z_centers_km: np.ndarray
z_nodes_km: np.ndarray
chainage_km: np.ndarray
elev_km: np.ndarray
surface_km: np.ndarray
station_x_km: np.ndarray
station_names: list[str]
depth_min_km: float
depth_max_km: float
exaggeration: float
log_rho: bool
method: str
rms: float
topo_source: str
@dataclass
class _GridInfo:
"""Internal adapter output — a flat cell-centre resistivity grid."""
x_centers: np.ndarray
z_centers: np.ndarray
rho_log10: np.ndarray
station_x: np.ndarray
station_names: list[str]
method: str
rms: float
unit: str # "m" or "km"
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
[docs]
def build_topo_section(
model: Any,
*,
sites: Any = None,
elevation: Any = None,
chainage: Any = None,
station_names: Sequence[str] | None = None,
station_x: Any = None,
topo_source: str = "auto",
model_unit: str = "m",
depth_min: float = 0.0,
depth_max: float | None = None,
exaggeration: float = 1.0,
log_rho: bool = True,
interp_method: str = "linear",
clip_above_surface: bool = True,
smooth_sigma: float | tuple[float, float] | None = None,
air_log10_threshold: float = _AIR_LOG10_THRESHOLD,
) -> TopoSection:
"""Resolve a model + topography source into a terrain-draped section.
This is the data-building half of :func:`plot_topo_section` — use
it directly when you want the resolved arrays without a figure.
Parameters
----------
model : object
Any of the input forms documented in the module docstring:
a ``(x_centers, z_centers, rho_2d)`` tuple, a
:class:`pycsamt.interp.ResistivityModel`, a backend-neutral or
native Occam2D/ModEM ``InversionResult``, or an AI agent
result exposing ``pred_rho``.
sites : object, optional
Station/EDI collection to extract chainage + elevation from
(see :mod:`pycsamt.topo.extract`). Takes priority over
*elevation* when ``topo_source="auto"``.
elevation : array_like, optional
Explicit per-station elevation (m a.s.l.). Paired with
*chainage* (km); if *chainage* is omitted, the model's own
station positions are used.
chainage : array_like, optional
Explicit per-station along-profile distance (km). Only used
together with *elevation*.
station_names : sequence of str, optional
Overrides the station labels carried by *model* / *sites*.
station_x : array_like, optional
Overrides the marker x-positions carried by *model*.
topo_source : {"auto", "sites", "array", "model"}
Which topography source to use. ``"auto"`` prefers *sites*,
then *elevation*, then model-derived (air-cell) inference,
then falls back to a flat datum with a warning.
model_unit : {"m", "km"}
Unit of *model*'s coordinate arrays (and of *depth_min* /
*depth_max*). Ignored for AI-style results, whose
``depths_km``/coordinates are always treated as km.
depth_min, depth_max : float, optional
Depth window to display, in *model_unit* units. ``None``
keeps the full depth range.
exaggeration : float, default 1.0
Vertical exaggeration applied to the terrain relief.
log_rho : bool, default True
Keep values as log10(rho). ``False`` converts to linear
Ω·m (``10 ** log10_rho``).
interp_method : {"linear", "cubic", "nearest"}
Elevation interpolation method, forwarded to
:func:`~pycsamt.topo.drape.interp_elev`.
clip_above_surface : bool, default True
NaN-mask cells that lie above the local terrain surface.
smooth_sigma : float or (float, float), optional
Gaussian-smoothing sigma (depth, distance), applied to
*values* before draping. Requires scipy; silently skipped
with a warning when scipy is unavailable.
air_log10_threshold : float, default 5.0
log10(rho) threshold used for model-derived terrain inference
(``topo_source in {"auto", "model"}``).
Returns
-------
TopoSection
Raises
------
TypeError
If *model* is not a recognised input form.
ValueError
If *topo_source* is invalid, or is forced to a source that
cannot be resolved (e.g. ``"sites"`` without *sites*).
"""
if model_unit not in ("m", "km"):
raise ValueError("model_unit must be 'm' or 'km'.")
grid = _extract_grid(
model, station_x=station_x, station_names=station_names, unit=model_unit
)
x_centers_km = _to_km(grid.x_centers, grid.unit)
z_centers_km = _to_km(grid.z_centers, grid.unit)
station_x_km = _to_km(grid.station_x, grid.unit)
chain_km, elev_m, names, topo_src_used = _resolve_topography(
grid,
sites=sites,
elevation=elevation,
chainage=chainage,
station_names=station_names,
topo_source=topo_source,
air_log10_threshold=air_log10_threshold,
)
elev_km = np.asarray(elev_m, dtype=float) / 1000.0
values = np.asarray(grid.rho_log10, dtype=float).copy()
depth_min_km = _to_km(depth_min, model_unit)
if depth_max is None:
depth_max_km = float(z_centers_km.max()) if z_centers_km.size else 0.0
else:
depth_max_km = _to_km(depth_max, model_unit)
mask = (z_centers_km >= depth_min_km) & (z_centers_km <= depth_max_km)
if not np.any(mask):
warnings.warn(
f"depth range [{depth_min_km}, {depth_max_km}] km selects no "
"layers; showing the full depth range instead.",
UserWarning,
stacklevel=2,
)
mask = np.ones_like(z_centers_km, dtype=bool)
z_centers_km = z_centers_km[mask]
values = values[mask, :]
if smooth_sigma is not None:
try:
from scipy.ndimage import gaussian_filter
values = gaussian_filter(values, sigma=smooth_sigma)
except ImportError:
warnings.warn(
"smooth_sigma was given but scipy is not installed; "
"skipping smoothing.",
UserWarning,
stacklevel=2,
)
if not log_rho:
values = 10.0**values
x_nodes_km = _cell_edges(x_centers_km)
z_nodes_km = _cell_edges(z_centers_km)
if z_nodes_km.size:
z_nodes_km[0] = max(0.0, float(z_nodes_km[0]))
elev_at_centres_km = interp_elev(
chain_km, elev_km, x_centers_km, method=interp_method
)
x_nodes_km, z_draped_km, values_draped = drape_section(
x_nodes_km,
z_nodes_km,
values,
elev_at_centres_km,
exaggeration=exaggeration,
clip_above_surface=clip_above_surface,
)
surface_km = interp_elev(chain_km, elev_km, x_nodes_km, method=interp_method)
return TopoSection(
x_nodes_km=x_nodes_km,
z_draped_km=z_draped_km,
values=values_draped,
x_centers_km=x_centers_km,
z_centers_km=z_centers_km,
z_nodes_km=z_nodes_km,
chainage_km=chain_km,
elev_km=elev_km,
surface_km=surface_km,
station_x_km=station_x_km,
station_names=names,
depth_min_km=depth_min_km,
depth_max_km=(
float(z_centers_km.max()) if z_centers_km.size else depth_min_km
),
exaggeration=float(exaggeration),
log_rho=bool(log_rho),
method=grid.method,
rms=grid.rms,
topo_source=topo_src_used,
)
[docs]
def plot_topo_section(
model: Any,
*,
ax: Any = None,
kind: str = "pcolormesh",
sites: Any = None,
elevation: Any = None,
chainage: Any = None,
station_names: Sequence[str] | None = None,
station_x: Any = None,
topo_source: str = "auto",
model_unit: str = "m",
depth_min: float = 0.0,
depth_max: float | None = None,
exaggeration: float = 1.0,
log_rho: bool = True,
interp_method: str = "linear",
clip_above_surface: bool = True,
smooth_sigma: float | tuple[float, float] | None = None,
air_log10_threshold: float = _AIR_LOG10_THRESHOLD,
cmap: str = "jet_r",
vmin: float | None = None,
vmax: float | None = None,
vmin_percentile: float = 2.0,
vmax_percentile: float = 98.0,
colorbar: bool = True,
section: Any = "inversion",
show_stations: bool = True,
show_station_names: bool = True,
topo_cfg: TopoConfig | None = None,
station_marker: Any = None,
dark: bool = False,
title: str | None = None,
figsize: tuple[float, float] | None = None,
savepath: str | None = None,
savefig_kw: dict[str, Any] | None = None,
return_data: bool = False,
):
"""Plot a resistivity model or inversion result draped over topography.
A single entry point that accepts any of the model forms described
in the :mod:`pycsamt.topo.section` module docstring (raw arrays,
:class:`pycsamt.interp.ResistivityModel`, backend-neutral or native
Occam2D/ModEM ``InversionResult``, AI agent results) together with
any topography source (``sites``, explicit arrays, or model-derived
inference), and renders a publication-style section using the
shared :mod:`pycsamt.api` styling (:data:`pycsamt.api.section.PYCSAMT_SECTION`,
:data:`pycsamt.api.station.PYCSAMT_STATION_RENDERING`).
Parameters
----------
model : object
See the module docstring for accepted forms.
ax : matplotlib Axes, optional
Existing axes to draw into. A new figure/axes is created when
omitted, sized via the selected *section* style.
kind : {"pcolormesh", "imshow"}
``"pcolormesh"`` drapes the grid over real terrain (profile
distance vs. elevation). ``"imshow"`` keeps a flat
station-index vs. depth pseudosection with a compact elevation
strip inserted above it.
sites, elevation, chainage, station_names, station_x :
Topography source, forwarded to :func:`build_topo_section`.
topo_source, model_unit :
Topography-resolution and unit controls, forwarded to
:func:`build_topo_section`.
depth_min, depth_max, exaggeration :
Depth window and vertical exaggeration, forwarded to
:func:`build_topo_section`.
log_rho, interp_method, clip_above_surface, smooth_sigma, air_log10_threshold :
Value scaling and grid-building controls, forwarded to
:func:`build_topo_section` — see its docstring for details.
cmap : str, default "jet_r"
Matplotlib colormap.
vmin, vmax : float, optional
Explicit colour-scale limits. When omitted, computed from
*vmin_percentile* / *vmax_percentile* of the visible values.
vmin_percentile, vmax_percentile : float, default 2.0, 98.0
Percentile bounds used to auto-scale the colour map.
colorbar : bool, default True
Draw a colorbar using the shared section colorbar style.
section : str or pycsamt.api.section.SectionStyle, default "inversion"
Section style preset name or explicit style object.
show_stations : bool, default True
Draw station markers (pins for ``pcolormesh``, the elevation
strip's markers for ``imshow``).
show_station_names : bool, default True
Draw station name labels alongside the markers.
topo_cfg : pycsamt.topo.config.TopoConfig, optional
Full terrain-rendering style override (fill colour/alpha, line
style, marker pad, ...). Defaults to a config that only
toggles ``station_pins_at_surface`` from *show_stations* and
turns off the above-surface fill (see *Notes*).
station_marker : pycsamt.api.station.StationMarkerStyle, optional
Station-pin style override, forwarded to
:func:`~pycsamt.topo.overlay.draw_topo_section` /
:func:`~pycsamt.topo.overlay.draw_topo_strip`. Defaults to a
white-filled, black-edged marker sized for legibility against
a busy ``cmap`` background; pass a
:class:`~pycsamt.api.station.StationMarkerStyle` to override.
dark : bool, default False
Use light-on-dark label colours for the terrain overlay.
title : str, optional
Axes title. Defaults to a method/rms/topo-source summary.
figsize : (float, float), optional
Explicit figure size, overriding the section style's sizing.
savepath : str, optional
Save the figure via :func:`pycsamt.api.plot.save_fig`.
savefig_kw : dict, optional
Extra keyword arguments forwarded to ``save_fig``.
return_data : bool, default False
When ``True``, return ``(ax, TopoSection)`` instead of ``ax``.
Returns
-------
matplotlib.axes.Axes
Or ``(ax, TopoSection)`` when ``return_data=True``.
Examples
--------
>>> from pycsamt.topo import plot_topo_section
>>> ax = plot_topo_section(result, sites=sites) # doctest: +SKIP
Cropped to the shallow 1.5 km and rendered as a pseudosection:
>>> ax = plot_topo_section(
... result, sites=sites, kind="imshow", depth_max=1500.0,
... ) # doctest: +SKIP
"""
import matplotlib.pyplot as plt
from ..api.plot import save_fig
from ..api.section import PYCSAMT_SECTION, SectionStyle
from .overlay import draw_topo_section, draw_topo_strip
kind = str(kind).lower()
if kind not in ("pcolormesh", "imshow"):
raise ValueError("kind must be 'pcolormesh' or 'imshow'.")
section_style = (
section.copy()
if isinstance(section, SectionStyle)
else PYCSAMT_SECTION.style_for(str(section)).copy()
)
data = build_topo_section(
model,
sites=sites,
elevation=elevation,
chainage=chainage,
station_names=station_names,
station_x=station_x,
topo_source=topo_source,
model_unit=model_unit,
depth_min=depth_min,
depth_max=depth_max,
exaggeration=exaggeration,
log_rho=log_rho,
interp_method=interp_method,
clip_above_surface=clip_above_surface,
smooth_sigma=smooth_sigma,
air_log10_threshold=air_log10_threshold,
)
finite_values = data.values[np.isfinite(data.values)]
if vmin is None:
vmin = (
float(np.nanpercentile(finite_values, vmin_percentile))
if finite_values.size
else 0.0
)
if vmax is None:
vmax = (
float(np.nanpercentile(finite_values, vmax_percentile))
if finite_values.size
else 1.0
)
if ax is None:
fsize = figsize or section_style.figsize_for(
n_stations=max(len(data.station_names), data.x_centers_km.size, 1),
n_y=max(data.z_centers_km.size, 1),
labels=data.station_names,
colorbar=colorbar,
)
fig, ax = plt.subplots(
figsize=fsize, constrained_layout=section_style.figure.constrained
)
else:
fig = ax.get_figure()
cbar_label = (
r"$\log_{10}\rho$ ($\Omega\cdot$m)" if log_rho else r"$\rho$ ($\Omega\cdot$m)"
)
if topo_cfg is None:
# Deliberately not forwarding `exaggeration` here: drape_section
# leaves its surface row unscaled by exaggeration (only the depth
# term is stretched), while draw_topo_section scales absolute
# elevation by cfg.exaggeration. Leaving this at the TopoConfig
# default (1.0) keeps the overlay's terrain line/pins aligned with
# the mesh surface at any `exaggeration` value; wiring the two
# together would reintroduce the mismatch.
#
# fill_alpha=0.0: the space above the terrain line has no data in
# it (it is literally air), so it is left as plain white/background
# rather than tinted with the fill colour. clip_below_surface stays
# True so mismatched-column gaps (see docs/user_guide/topo/concepts)
# blend into that same white instead of standing out separately.
topo_cfg = TopoConfig(
station_pins_at_surface=show_stations, fill_alpha=0.0
)
if station_marker is None:
from ..api.station import PYCSAMT_STATION_RENDERING, StationMarkerStyle
_base = PYCSAMT_STATION_RENDERING.inversion.marker
station_marker = StationMarkerStyle(
marker=_base.marker,
size=_base.size,
facecolor="white",
edgecolor="black",
linewidth=_base.linewidth,
alpha=_base.alpha,
offset=_base.offset,
zorder=_base.zorder,
)
if kind == "pcolormesh":
im = ax.pcolormesh(
data.x_nodes_km,
data.z_draped_km,
data.values,
shading="auto",
cmap=cmap,
vmin=vmin,
vmax=vmax,
)
ax.set_xlabel("Profile distance (km)")
ax.set_ylabel("Elevation (km)")
if section_style.figure.title:
ax.set_title(_title(data, title))
ax.grid(bool(section_style.axis.grid))
if ax.yaxis_inverted():
ax.invert_yaxis()
top = float(np.nanmax(data.surface_km))
bottom = float(np.nanmin(data.z_draped_km))
margin = 0.04 * max(top - bottom, 1e-6)
ax.set_ylim(bottom - margin * 0.25, top + margin)
ax.set_xlim(float(data.x_nodes_km.min()), float(data.x_nodes_km.max()))
if colorbar:
section_style.add_colorbar(im, ax, label=cbar_label)
draw_topo_section(
ax,
data.chainage_km,
data.elev_km * 1000.0,
data.station_names if show_station_names else None,
station_x_km=data.station_x_km,
cfg=topo_cfg,
dark=dark,
marker_style=station_marker,
)
else: # imshow
n_x = data.x_centers_km.size
extent = [
-0.5,
n_x - 0.5,
float(data.z_nodes_km[-1]) if data.z_nodes_km.size else 1.0,
float(data.z_nodes_km[0]) if data.z_nodes_km.size else 0.0,
]
im = ax.imshow(
data.values,
extent=extent,
aspect="auto",
cmap=cmap,
vmin=vmin,
vmax=vmax,
interpolation="nearest",
)
section_style.apply_axis(
ax, xlabel="Station", ylabel="Depth (km)", title=_title(data, title)
)
ax.set_xlim(-0.5, n_x - 0.5)
if colorbar:
section_style.add_colorbar(im, ax, label=cbar_label)
if show_stations:
elev_strip_m = (
interp_elev(
data.chainage_km,
data.elev_km,
data.x_centers_km,
method=interp_method,
)
* 1000.0
)
strip_names = (
data.station_names
if show_station_names and len(data.station_names) == n_x
else None
)
draw_topo_strip(
fig,
ax,
np.arange(n_x, dtype=float),
elev_strip_m,
strip_names,
cfg=topo_cfg,
dark=dark,
marker_style=station_marker,
)
if section_style.figure.tight:
try:
fig.tight_layout()
except Exception:
pass
if savepath:
save_fig(ax, savepath, **(savefig_kw or {}))
if return_data:
return ax, data
return ax
# ---------------------------------------------------------------------------
# Internal: model adapters
# ---------------------------------------------------------------------------
def _to_km(value: Any, unit: str) -> Any:
"""Convert a scalar or array from *unit* ("m"/"km") to km."""
if isinstance(value, np.ndarray):
return value / 1000.0 if unit == "m" else value.astype(float, copy=True)
return float(value) / 1000.0 if unit == "m" else float(value)
def _cell_edges(centres: np.ndarray) -> np.ndarray:
"""Return node/edge positions bracketing 1-D cell-centre values."""
centres = np.asarray(centres, dtype=float).ravel()
if centres.size == 0:
return np.asarray([0.0, 1.0])
if centres.size == 1:
width = max(abs(float(centres[0])) * 0.1, 0.05)
return np.asarray([centres[0] - width, centres[0] + width])
edges = np.empty(centres.size + 1, dtype=float)
edges[1:-1] = 0.5 * (centres[:-1] + centres[1:])
edges[0] = centres[0] - (edges[1] - centres[0])
edges[-1] = centres[-1] + (centres[-1] - edges[-2])
return edges
def _infer_terrain_km(
z_centers_km: np.ndarray,
rho_log10_2d: np.ndarray,
threshold: float,
) -> np.ndarray | None:
"""Infer a *relative* terrain profile from air-like cells in a grid.
Returns ``None`` when no column contains a cell above *threshold*,
signalling that model-derived topography is not available.
"""
rho_log10_2d = np.asarray(rho_log10_2d, dtype=float)
z_centers_km = np.asarray(z_centers_km, dtype=float)
nz, nx = rho_log10_2d.shape
if nz == 0 or nx == 0:
return None
is_earth = rho_log10_2d < threshold
any_earth = is_earth.any(axis=0)
if not np.any(any_earth):
return None
first_idx = np.argmax(is_earth, axis=0)
first_idx = np.where(any_earth, first_idx, 0)
if not np.any(first_idx > 0):
# No column actually starts with an air cap above threshold —
# there is no real air layer to infer terrain relief from.
return None
return -z_centers_km[first_idx]
def _extract_grid(
model: Any,
*,
station_x: Any = None,
station_names: Sequence[str] | None = None,
unit: str = "m",
) -> _GridInfo:
"""Adapt any supported *model* input into a flat :class:`_GridInfo`."""
# 1. Raw (x_centers, z_centers, rho_2d) triple.
if isinstance(model, (tuple, list)) and len(model) == 3:
x_c, z_c, rho = model
x_c = np.asarray(x_c, dtype=float)
z_c = np.asarray(z_c, dtype=float)
rho = np.asarray(rho, dtype=float)
sx = np.asarray(station_x, dtype=float) if station_x is not None else x_c
names = (
list(station_names)
if station_names is not None
else [f"S{i:03d}" for i in range(x_c.size)]
)
return _GridInfo(
x_centers=x_c,
z_centers=z_c,
rho_log10=rho,
station_x=sx,
station_names=names,
method="array",
rms=float("nan"),
unit=unit,
)
if isinstance(model, np.ndarray):
raise TypeError(
"A bare 2-D array must be paired with coordinates; pass "
"model=(x_centers, z_centers, rho_2d) instead."
)
# 2. pycsamt.interp.ResistivityModel (or lookalike).
if (
hasattr(model, "rho_2d")
and hasattr(model, "x_centers")
and hasattr(model, "z_centers")
):
x_c = np.asarray(model.x_centers, dtype=float)
sx_default = getattr(model, "station_x", None)
sx = (
np.asarray(station_x, dtype=float)
if station_x is not None
else (
np.asarray(sx_default, dtype=float)
if sx_default is not None and len(sx_default)
else x_c
)
)
names_default = list(getattr(model, "station_names", None) or [])
names = (
list(station_names)
if station_names is not None
else (names_default or [f"S{i:03d}" for i in range(x_c.size)])
)
return _GridInfo(
x_centers=x_c,
z_centers=np.asarray(model.z_centers, dtype=float),
rho_log10=np.asarray(model.rho_2d, dtype=float),
station_x=sx,
station_names=names,
method=str(getattr(model, "method", "generic")),
rms=float(getattr(model, "rms", float("nan"))),
unit=unit,
)
# 3. Backend-neutral pycsamt.inversion.results.InversionResult.
to_rm = getattr(model, "to_resistivity_model", None)
if callable(to_rm):
rm = to_rm()
info = _extract_grid(
rm, station_x=station_x, station_names=station_names, unit="m"
)
backend = getattr(model, "backend", "")
method = getattr(model, "method", info.method)
info.method = f"{backend}:{method}".strip(":") or info.method
info.rms = float(getattr(model, "rms", info.rms))
return info
# 4. Native Occam2D InversionResult (rho_2d + mesh, no x_centers).
if (
hasattr(model, "rho_2d")
and hasattr(model, "mesh")
and getattr(model, "rho_2d") is not None
):
from ..interp import ResistivityModel
rm = ResistivityModel.from_occam2d(model)
return _extract_grid(
rm, station_x=station_x, station_names=station_names, unit="m"
)
# 5. Native 2-D ModEM InversionResult (mode + model_final/initial).
if hasattr(model, "mode") and (
getattr(model, "model_final", None) is not None
or getattr(model, "model_initial", None) is not None
):
mm = (
model.model_final
if getattr(model, "model_final", None) is not None
else model.model_initial
)
if str(model.mode).lower() != "2d":
raise ValueError(
"Native 3-D ModEM InversionResult objects hold a full "
"volume, not a single profile. Extract a 2-D cut first "
"(e.g. pycsamt.models.modem.section.station_curtain or "
"pycsamt.models.modem.plot.PlotSection) and pass the "
"resulting (x_centers, z_centers, rho_2d) grid, or a "
"pycsamt.interp.ResistivityModel."
)
x_widths = np.asarray(mm.x_widths, dtype=float)
z_widths = np.asarray(mm.z_widths, dtype=float)
x_nodes = np.concatenate([[0.0], np.cumsum(x_widths)])
z_nodes = np.concatenate([[0.0], np.cumsum(z_widths)])
x_c = 0.5 * (x_nodes[:-1] + x_nodes[1:])
z_c = 0.5 * (z_nodes[:-1] + z_nodes[1:])
rho_log10 = np.asarray(mm.rho_loge, dtype=float) / np.log(10.0)
sx = np.asarray(station_x, dtype=float) if station_x is not None else x_c
names = (
list(station_names)
if station_names is not None
else [f"S{i:03d}" for i in range(x_c.size)]
)
return _GridInfo(
x_centers=x_c,
z_centers=z_c,
rho_log10=rho_log10,
station_x=sx,
station_names=names,
method="modem",
rms=float("nan"),
unit=unit,
)
# 6. AI agent-style result: dict, or AgentResult (both support
# `in` / `.get`), exposing `pred_rho` (n_sta, n_layers), log10 rho.
getter = getattr(model, "get", None)
if callable(getter):
try:
has_pred_rho = "pred_rho" in model
except TypeError:
has_pred_rho = False
if has_pred_rho:
pred_rho = np.asarray(model["pred_rho"], dtype=float)
depths_km = model.get("depths_km")
depths_km = (
np.asarray(depths_km, dtype=float)
if depths_km is not None
else np.arange(pred_rho.shape[1], dtype=float)
)
names = list(
station_names
or model.get("station_names")
or [f"S{i:03d}" for i in range(pred_rho.shape[0])]
)
coords = model.get("station_coords")
if station_x is not None:
x_c = np.asarray(station_x, dtype=float)
elif coords is not None:
coords = np.asarray(coords, dtype=float)
if coords.shape[0] > 1:
seg = np.sqrt((np.diff(coords, axis=0) ** 2).sum(axis=1))
x_c = np.concatenate([[0.0], np.cumsum(seg)]) / 1000.0
else:
x_c = np.zeros(coords.shape[0])
else:
x_c = np.arange(pred_rho.shape[0], dtype=float)
rms = model.get("rms_global", float("nan"))
return _GridInfo(
x_centers=x_c,
z_centers=depths_km,
rho_log10=pred_rho.T,
station_x=x_c,
station_names=names,
method="ai",
rms=float(rms) if rms is not None else float("nan"),
unit="km",
)
# 7. MARE2DEM triangular mesh — genuinely unsupported (no regular grid).
mod_module = type(model).__module__
if "mare2dem" in mod_module:
raise NotImplementedError(
"Native MARE2DEM results use an unstructured triangular mesh "
"and are not natively supported by plot_topo_section(). "
"Regrid the mesh onto a regular (x_centers, z_centers) grid "
"first (e.g. with scipy.interpolate.griddata against the "
".poly node coordinates), or build a "
"pycsamt.interp.ResistivityModel manually, then pass that "
"instead."
)
raise TypeError(
f"Unsupported model type: {type(model)!r}. Expected a "
"(x_centers, z_centers, rho_2d) tuple, a "
"pycsamt.interp.ResistivityModel, an object exposing "
"to_resistivity_model() (pycsamt.inversion.results.InversionResult), "
"a native Occam2D or 2-D ModEM InversionResult, or an AI agent "
"result exposing 'pred_rho'."
)
def _resolve_topography(
grid: _GridInfo,
*,
sites: Any = None,
elevation: Any = None,
chainage: Any = None,
station_names: Sequence[str] | None = None,
topo_source: str = "auto",
air_log10_threshold: float = _AIR_LOG10_THRESHOLD,
) -> tuple[np.ndarray, np.ndarray, list[str], str]:
"""Resolve (chainage_km, elev_m, names, source_used) for *grid*."""
valid_sources = {"auto", "sites", "array", "model"}
if topo_source not in valid_sources:
raise ValueError(f"topo_source must be one of {sorted(valid_sources)}.")
names = list(station_names) if station_names is not None else list(
grid.station_names
)
if topo_source == "sites" or (topo_source == "auto" and sites is not None):
if sites is None:
raise ValueError("topo_source='sites' requires the `sites` argument.")
from .extract import extract_chainage, extract_elevation, extract_station_names
chain_km = extract_chainage(sites)
elev_m = extract_elevation(sites)
if not names:
names = extract_station_names(sites)
return chain_km, elev_m, names, "sites"
if topo_source == "array" or (topo_source == "auto" and elevation is not None):
if elevation is None:
raise ValueError("topo_source='array' requires the `elevation` argument.")
elev_m = np.asarray(elevation, dtype=float)
if chainage is not None:
chain_km = np.asarray(chainage, dtype=float)
else:
chain_km = _to_km(grid.station_x, grid.unit)
return chain_km, elev_m, names, "array"
if topo_source in ("model", "auto"):
z_km = _to_km(grid.z_centers, grid.unit)
terrain_km = _infer_terrain_km(z_km, grid.rho_log10, air_log10_threshold)
if terrain_km is not None:
x_km = _to_km(grid.x_centers, grid.unit)
return x_km, terrain_km * 1000.0, names, "model"
if topo_source == "model":
raise ValueError(
"topo_source='model' requested but no air-like cells "
f"(log10 rho > {air_log10_threshold}) were found to "
"infer terrain from."
)
warnings.warn(
"No topography source resolved (no sites/elevation given, and "
"the model carries no detectable air layer); rendering with a "
"flat datum. Pass `sites=` or `elevation=` to embed real terrain.",
UserWarning,
stacklevel=3,
)
x_km = _to_km(grid.x_centers, grid.unit)
return x_km, np.zeros_like(x_km), names, "flat"
def _title(data: TopoSection, title: str | None) -> str | None:
if title is not None:
return title
rms = "" if not np.isfinite(data.rms) else f", rms={data.rms:.3g}"
return f"{data.method} section (topo: {data.topo_source}){rms}"