Source code for pycsamt.topo.drape

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Terrain-following coordinate transform for 2-D section plots.

A standard 2-D resistivity section stores:

    * ``x`` — along-profile distance (km)
    * ``z`` — depth below a *flat datum* at z = 0 (km, positive downward)

When real surface topography is available, the datum is no longer flat.
This module transforms the flat depth-grid into a *terrain-following*
coordinate frame in which z = 0 at every x position corresponds to the
local surface elevation:

    z_real(x, z) = elev(x) - z                  [both in km]

The result can be fed directly to :func:`matplotlib.pyplot.pcolormesh`
using a 2-D ``Z`` argument (supported since Matplotlib 3.3) so that the
mesh cells drape over the terrain.

Typical usage::

    from pycsamt.topo.drape import interp_elev, drape_section
    elev_at_x = interp_elev(chain_km, elev_km, x_centres_km)
    x_nodes, z_draped, data = drape_section(x_nodes, z_nodes, rho_2d, elev_at_x)
    ax.pcolormesh(x_nodes, z_draped, data, ...)
"""

from __future__ import annotations

import numpy as np

try:
    from scipy.interpolate import interp1d as _scipy_interp1d

    _HAS_SCIPY = True
except ImportError:
    _HAS_SCIPY = False

__all__ = [
    "interp_elev",
    "drape_section",
    "mask_above_topo",
    "station_surface_z",
]


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


[docs] def interp_elev( chainage_km: np.ndarray, elev_km: np.ndarray, x_query_km: np.ndarray, method: str = "linear", ) -> np.ndarray: """Interpolate station elevations to arbitrary profile positions. Clamps extrapolated values to the boundary station elevations so the terrain surface never shoots up unexpectedly at the section edges. Parameters ---------- chainage_km : array_like, shape (n_stations,) Along-profile distances of the stations (km). elev_km : array_like, shape (n_stations,) Terrain elevation at each station (km a.s.l.). x_query_km : array_like, shape (m,) Positions at which to evaluate the interpolated elevation (km). method : {"linear", "cubic", "nearest"} Interpolation method. ``"cubic"`` requires scipy. Returns ------- numpy.ndarray, shape (m,) Interpolated elevation in **km** a.s.l. """ chainage_km = np.asarray(chainage_km, dtype=float).ravel() elev_km = np.asarray(elev_km, dtype=float).ravel() x_query_km = np.asarray(x_query_km, dtype=float).ravel() if len(chainage_km) == 0: return np.zeros_like(x_query_km) if len(chainage_km) == 1: return np.full_like(x_query_km, elev_km[0]) # Remove NaN / Inf elevation values before interpolating valid = np.isfinite(elev_km) c = chainage_km[valid] e = elev_km[valid] if len(c) < 2: return np.full_like(x_query_km, e[0] if len(e) else 0.0) if _HAS_SCIPY and method == "cubic": fn = _scipy_interp1d( c, e, kind="cubic", bounds_error=False, fill_value=(e[0], e[-1]) ) return fn(x_query_km) # numpy linear / nearest if method == "nearest": idx = np.argmin( np.abs(x_query_km[:, np.newaxis] - c[np.newaxis, :]), axis=1 ) return e[idx] # default: linear with clamped extrapolation return np.interp(x_query_km, c, e, left=e[0], right=e[-1])
[docs] def drape_section( x_nodes: np.ndarray, z_nodes: np.ndarray, data: np.ndarray, elev_at_centres: np.ndarray, exaggeration: float = 1.0, clip_above_surface: bool = False, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Transform a flat depth section into terrain-following coordinates. Builds a 2-D ``z_draped`` array (shape ``(nz+1, nx+1)``) where each column is shifted so that ``z_nodes[0]`` (the surface) aligns with the local terrain elevation. The result can be passed directly to :func:`~matplotlib.axes.Axes.pcolormesh` as the ``Y`` argument. Parameters ---------- x_nodes : array_like, shape (nx+1,) Horizontal pcolormesh node positions (km). z_nodes : array_like, shape (nz+1,) Depth node positions (km, positive downward from flat datum). ``z_nodes[0]`` should be 0 (surface) or the shallowest depth. data : array_like, shape (nz, nx) 2-D data values (e.g. log10(rho)). elev_at_centres : array_like, shape (nx,) Terrain elevation at each *cell-centre* x position (km a.s.l.). exaggeration : float Vertical exaggeration applied to both the elevation offset and the depth axis. Values > 1 amplify relief for display purposes. clip_above_surface : bool If ``True``, set cells that are above the terrain surface (unreachable subsurface) to ``NaN``. Returns ------- x_nodes : numpy.ndarray, shape (nx+1,) Unchanged horizontal node positions. z_draped : numpy.ndarray, shape (nz+1, nx+1) 2-D elevation node array. Column *j* equals ``elev_at_nodes[j] - z_nodes * exaggeration``. data_out : numpy.ndarray, shape (nz, nx) Input data, optionally NaN-masked above terrain. Notes ----- The terrain-following coordinate for node ``(k, j)`` is:: z_draped[k, j] = elev_nodes[j] - z_nodes[k] * exaggeration where ``elev_nodes`` is the terrain interpolated to the x *node* positions (``nx+1`` values) from the cell-centre values. """ x_nodes = np.asarray(x_nodes, dtype=float) z_nodes = np.asarray(z_nodes, dtype=float) data = np.asarray(data, dtype=float) elev_at_centres = np.asarray(elev_at_centres, dtype=float) nx = x_nodes.shape[0] - 1 nz = z_nodes.shape[0] - 1 # Interpolate elevation from cell centres to node positions (nx+1) if nx > 0: x_centres = (x_nodes[:-1] + x_nodes[1:]) / 2.0 elev_nodes = np.interp( x_nodes, x_centres, elev_at_centres, left=elev_at_centres[0], right=elev_at_centres[-1], ) else: elev_nodes = np.full( nx + 1, elev_at_centres[0] if len(elev_at_centres) else 0.0 ) # Build 2-D draped z grid (nz+1, nx+1) # Broadcasting: rows = depth nodes, cols = x nodes z_draped = ( elev_nodes[np.newaxis, :] # (1, nx+1) - z_nodes[:, np.newaxis] * exaggeration ) # (nz+1, 1) data_out = data.copy() if clip_above_surface and nx > 0 and nz > 0: data_out = mask_above_topo( x_nodes, z_nodes, data_out, elev_at_centres, exaggeration ) return x_nodes, z_draped, data_out
[docs] def mask_above_topo( x_nodes: np.ndarray, z_nodes: np.ndarray, data: np.ndarray, elev_at_centres: np.ndarray, exaggeration: float = 1.0, ) -> np.ndarray: """Set data cells that lie above the terrain surface to NaN. A cell at column *j* and depth-row *k* has absolute elevation:: cell_elev = elev_at_centres[j] - z_centre[k] * exaggeration where ``z_centre[k] = (z_nodes[k] + z_nodes[k+1]) / 2``. A cell is *above the surface* when ``cell_elev > elev_at_centres[j]``, which simplifies to ``z_centre[k] < 0``. For standard meshes (all z ≥ 0) this never occurs; the masking is only relevant for meshes whose z-origin is below the deepest station (uncommon). The more practically useful masking is for *varying terrain*: station A sits at 800 m, station B sits at 200 m. A cell at depth 500 m below A has absolute elevation 300 m — it is above the surface at B. This function masks such cells so they do not appear in the plot. Parameters ---------- x_nodes : (nx+1,) node positions z_nodes : (nz+1,) depth nodes (km, positive down) data : (nz, nx) data array elev_at_centres : (nx,) elevation at cell centres (km) exaggeration : float Returns ------- numpy.ndarray, shape (nz, nx) Data with cells above the terrain set to NaN. """ z_nodes = np.asarray(z_nodes, dtype=float) elev_at_centres = np.asarray(elev_at_centres, dtype=float) data_out = np.asarray(data, dtype=float).copy() nz = z_nodes.shape[0] - 1 nx = data_out.shape[1] z_centres = (z_nodes[:-1] + z_nodes[1:]) / 2.0 # (nz,) max_elev = np.max(elev_at_centres) if len(elev_at_centres) else 0.0 for j in range(nx): surf = elev_at_centres[j] if j < len(elev_at_centres) else 0.0 for k in range(nz): cell_elev = surf - z_centres[k] * exaggeration if cell_elev > max_elev: data_out[k, j] = np.nan return data_out
[docs] def station_surface_z( chainage_km: np.ndarray, elev_km: np.ndarray, station_x_km: np.ndarray, exaggeration: float = 1.0, ) -> np.ndarray: """Return the terrain-draped z-coordinate for station marker positions. In the draped coordinate frame the station sits at the surface elevation, not at z = 0. Parameters ---------- chainage_km : (n,) station chainage values elev_km : (n,) elevation at each station (km) station_x_km : (m,) x positions where markers should be drawn exaggeration : float Returns ------- numpy.ndarray, shape (m,) z-coordinates (km) at which to place station markers. """ elev_interp = interp_elev(chainage_km, elev_km, station_x_km) # In the draped frame the surface is at elev; z_nodes[0] = 0 maps to # elev, so the marker sits exactly at the surface when exaggeration=1. # Note this scales the *absolute* elevation, unlike drape_section's # z_draped[0] row (the mesh's own surface), which is left unscaled by # exaggeration -- only the depth term below it is stretched. Markers # placed with exaggeration != 1 will therefore not sit exactly on a # drape_section-drawn surface line at the same exaggeration. return elev_interp * exaggeration if exaggeration != 1.0 else elev_interp