# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
3-D resistivity grid for MT forward modelling.
:class:`Grid3D` stores the subsurface resistivity as a regular
(non-uniform) finite-difference grid in x–y–z together with the
surface station positions needed by :class:`~pycsamt.forward.em3d.MT3DForward`.
Coordinate convention
---------------------
* **x** — easting [m], increasing to the right.
* **y** — northing [m], increasing into the page.
* **z** — depth [m], increasing downward.
* ``resistivity[iz, iy, ix]`` is the resistivity of the cell whose
top-left-front corner is at ``(x_nodes[ix], y_nodes[iy], z_nodes[iz])``.
Padding
-------
The quasi-3D solver extracts 2-D XZ and YZ slices and runs
:class:`~pycsamt.forward.em2d.MT2DForward` on each. Those 2-D solvers
need padding to push Dirichlet BCs away from the model region.
:attr:`n_pad` records the padding count added on **each** side in x and y
and at the **bottom** in z, consistent with :class:`~pycsamt.forward.grid2d.Grid2D`.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from .grid2d import Grid2D, make_padding
__all__ = ["Grid3D"]
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
def _ensure_rng(seed) -> np.random.Generator:
if isinstance(seed, np.random.Generator):
return seed
return np.random.default_rng(seed)
# ─────────────────────────────────────────────────────────────────────────────
# Grid3D
# ─────────────────────────────────────────────────────────────────────────────
[docs]
@dataclass
class Grid3D:
"""Non-uniform 3-D finite-difference grid for MT forward modelling.
Parameters
----------
dx : array-like, shape (nx,)
Cell widths in x (easting) [m], including padding.
dy : array-like, shape (ny,)
Cell widths in y (northing) [m], including padding.
dz : array-like, shape (nz,)
Cell heights in z (depth) [m], including padding.
resistivity : array-like, shape (nz, ny, nx)
Cell resistivities [Ω·m], top→bottom, front→back, left→right.
stations_xy : array-like, shape (n_stations, 2)
Surface station (x, y) coordinates [m]. Must lie within the
grid extent.
n_pad : int
Padding cells added on each side in x and y and at the bottom
in z during construction.
name : str
Optional label.
Examples
--------
Uniform halfspace::
>>> g = Grid3D.halfspace(rho=100.0, nx=20, ny=20, nz=15,
... x_max=8_000.0, y_max=8_000.0, z_max=4_000.0,
... nx_stations=5, ny_stations=5)
>>> g.resistivity.shape # includes padding
(23, 28, 28)
3-D conductive block::
>>> g = Grid3D.block_anomaly(
... bg_rho=500.0, anomaly_rho=5.0,
... bounds=(2_000.0, 6_000.0, 2_000.0, 6_000.0, 300.0, 1_500.0),
... nx=25, ny=25, nz=18,
... x_max=8_000.0, y_max=8_000.0, z_max=5_000.0,
... nx_stations=5, ny_stations=5)
"""
dx: np.ndarray
dy: np.ndarray
dz: np.ndarray
resistivity: np.ndarray
stations_xy: np.ndarray
n_pad: int = 0
name: str = ""
def __post_init__(self):
self.dx = np.asarray(self.dx, dtype=float)
self.dy = np.asarray(self.dy, dtype=float)
self.dz = np.asarray(self.dz, dtype=float)
self.resistivity = np.asarray(self.resistivity, dtype=float)
self.stations_xy = np.asarray(self.stations_xy, dtype=float)
if self.stations_xy.ndim == 1:
self.stations_xy = self.stations_xy.reshape(-1, 2)
if self.resistivity.shape != (self.nz, self.ny, self.nx):
raise ValueError(
f"resistivity shape {self.resistivity.shape} does not match "
f"(nz={self.nz}, ny={self.ny}, nx={self.nx})."
)
if np.any(self.resistivity <= 0):
raise ValueError("All resistivities must be strictly positive.")
xlo, xhi = self.x_nodes[0], self.x_nodes[-1]
ylo, yhi = self.y_nodes[0], self.y_nodes[-1]
bad_x = (self.stations_xy[:, 0] < xlo) | (
self.stations_xy[:, 0] > xhi
)
bad_y = (self.stations_xy[:, 1] < ylo) | (
self.stations_xy[:, 1] > yhi
)
if bad_x.any() or bad_y.any():
raise ValueError(
f"{(bad_x | bad_y).sum()} station(s) outside the grid extent."
)
# ── shape ────────────────────────────────────────────────────────────────
[docs]
@property
def nx(self) -> int:
return len(self.dx)
[docs]
@property
def ny(self) -> int:
return len(self.dy)
[docs]
@property
def nz(self) -> int:
return len(self.dz)
[docs]
@property
def n_stations(self) -> int:
return len(self.stations_xy)
# ── coordinates ──────────────────────────────────────────────────────────
[docs]
@property
def x_nodes(self) -> np.ndarray:
return np.concatenate([[0.0], np.cumsum(self.dx)])
[docs]
@property
def y_nodes(self) -> np.ndarray:
return np.concatenate([[0.0], np.cumsum(self.dy)])
[docs]
@property
def z_nodes(self) -> np.ndarray:
return np.concatenate([[0.0], np.cumsum(self.dz)])
[docs]
@property
def x_centers(self) -> np.ndarray:
xn = self.x_nodes
return 0.5 * (xn[:-1] + xn[1:])
[docs]
@property
def y_centers(self) -> np.ndarray:
yn = self.y_nodes
return 0.5 * (yn[:-1] + yn[1:])
[docs]
@property
def z_centers(self) -> np.ndarray:
zn = self.z_nodes
return 0.5 * (zn[:-1] + zn[1:])
[docs]
@property
def x_extent(self) -> float:
return float(self.dx.sum())
[docs]
@property
def y_extent(self) -> float:
return float(self.dy.sum())
[docs]
@property
def z_extent(self) -> float:
return float(self.dz.sum())
# ── station cell lookup ───────────────────────────────────────────────────
def _station_x_cells(self) -> np.ndarray:
"""x-cell index for every station."""
idx = (
np.searchsorted(
self.x_nodes, self.stations_xy[:, 0], side="right"
)
- 1
)
return np.clip(idx, 0, self.nx - 1)
def _station_y_cells(self) -> np.ndarray:
"""y-cell index for every station."""
idx = (
np.searchsorted(
self.y_nodes, self.stations_xy[:, 1], side="right"
)
- 1
)
return np.clip(idx, 0, self.ny - 1)
# ── 2-D slice extraction (used by the quasi-3D solver) ───────────────────
[docs]
def xz_slice(self, yi: int) -> tuple[Grid2D, np.ndarray]:
"""Extract an XZ (east-west) 2-D slice at y-cell *yi*.
Returns a :class:`~pycsamt.forward.grid2d.Grid2D` whose horizontal
axis is x (easting) and vertical axis is z (depth). The station
x-positions are those of all stations whose y-coordinate falls in
cell *yi*.
Returns
-------
grid2d : Grid2D
station_indices : ndarray of int
Global indices (into :attr:`stations_xy`) of stations in this
y-row. ``grid2d.x_stations[k]`` is the x-coordinate of
``stations_xy[station_indices[k]]``.
"""
y_cells = self._station_y_cells()
mask = y_cells == yi
indices = np.where(mask)[0]
x_st = (
self.stations_xy[indices, 0]
if len(indices) > 0
else np.array([self.x_nodes[self.n_pad + self.nx // 2]])
)
rho_xz = self.resistivity[:, yi, :] # (nz, nx)
g2d = Grid2D(
dx=self.dx,
dz=self.dz,
resistivity=rho_xz,
x_stations=x_st,
n_pad=self.n_pad,
)
return g2d, indices
[docs]
def yz_slice(self, xi: int) -> tuple[Grid2D, np.ndarray]:
"""Extract a YZ (north-south) 2-D slice at x-cell *xi*.
The ``Grid2D`` horizontal axis is mapped to y (northing); its
``dx`` array contains :attr:`dy` and its resistivity is
``resistivity[:, :, xi]``.
Returns
-------
grid2d : Grid2D
station_indices : ndarray of int
Global indices of stations in this x-column.
``grid2d.x_stations[k]`` is the **y**-coordinate of the
station (used as the horizontal axis in the yz-plane solver).
"""
x_cells = self._station_x_cells()
mask = x_cells == xi
indices = np.where(mask)[0]
y_st = (
self.stations_xy[indices, 1]
if len(indices) > 0
else np.array([self.y_nodes[self.n_pad + self.ny // 2]])
)
rho_yz = self.resistivity[:, :, xi] # (nz, ny)
g2d = Grid2D(
dx=self.dy,
dz=self.dz,
resistivity=rho_yz,
x_stations=y_st,
n_pad=self.n_pad,
)
return g2d, indices
[docs]
def column_profile_3d(
self, xi: int, yi: int
) -> tuple[np.ndarray, np.ndarray]:
"""Return the 1-D resistivity/thickness profile at cell (xi, yi).
Parameters
----------
xi : int Column index (x direction).
yi : int Row index (y direction).
Returns
-------
rho : ndarray, shape (nz,)
thick : ndarray, shape (nz-1,)
"""
return self.resistivity[:, yi, xi], self.dz[:-1]
# ── conductivity helpers ──────────────────────────────────────────────────
[docs]
@property
def conductivity(self) -> np.ndarray:
"""Cell conductivities [S/m], shape (nz, ny, nx)."""
return 1.0 / self.resistivity
# ── core slices (clip padding for visualisation) ──────────────────────────
@property
def _cx(self) -> slice:
p = self.n_pad
return slice(p, self.nx - p) if p else slice(None)
@property
def _cy(self) -> slice:
p = self.n_pad
return slice(p, self.ny - p) if p else slice(None)
@property
def _cz(self) -> slice:
p = self.n_pad
return slice(None, self.nz - p) if p else slice(None)
# ── visualisation ────────────────────────────────────────────────────────
[docs]
def plot(
self,
*,
cmap: str = "jet_r",
log_scale: bool = True,
clip_core: bool = True,
show_stations: bool = True,
figsize: tuple[float, float] = (13, 4.5),
vmin: float | None = None,
vmax: float | None = None,
):
"""Plot orthogonal slices: XZ (mid-y), YZ (mid-x), XY (mid-z).
Parameters
----------
cmap : str
log_scale : bool
clip_core : bool
Clip to non-padding region.
show_stations : bool
figsize : (float, float)
vmin, vmax : float or None
Returns
-------
fig : matplotlib.figure.Figure
axes : ndarray of Axes, shape (3,)
"""
import matplotlib.pyplot as plt
cx, cy, cz = self._cx, self._cy, self._cz
rho = self.resistivity
xn = (
self.x_nodes[self.n_pad : self.nx + 1 - self.n_pad]
if clip_core and self.n_pad
else self.x_nodes
)
yn = (
self.y_nodes[self.n_pad : self.ny + 1 - self.n_pad]
if clip_core and self.n_pad
else self.y_nodes
)
zn = (
self.z_nodes[: self.nz + 1 - self.n_pad]
if clip_core and self.n_pad
else self.z_nodes
)
# midpoint indices in core
mid_y = (
self.n_pad + (self.ny - 2 * self.n_pad) // 2
if self.n_pad
else self.ny // 2
)
mid_x = (
self.n_pad + (self.nx - 2 * self.n_pad) // 2
if self.n_pad
else self.nx // 2
)
mid_z = (self.nz - self.n_pad) // 2 if self.n_pad else self.nz // 2
slices = [
(
rho[cz, mid_y, cx],
xn,
zn,
"x (m)",
"z (m)",
f"XZ slice (y = {self.y_centers[mid_y]:.0f} m)",
),
(
rho[cz, cy, mid_x],
yn,
zn,
"y (m)",
"z (m)",
f"YZ slice (x = {self.x_centers[mid_x]:.0f} m)",
),
(
rho[mid_z, cy, cx],
xn,
yn,
"x (m)",
"y (m)",
f"XY slice (z = {self.z_centers[mid_z]:.0f} 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):
d = np.log10(np.maximum(data, 1e-12)) if log_scale else data
pc = ax.pcolormesh(
h_nodes,
v_nodes,
d,
cmap=cmap,
shading="flat",
vmin=vmin,
vmax=vmax,
)
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, pad=4)
cb = fig.colorbar(pc, ax=ax, pad=0.02, shrink=0.95, aspect=22)
cb.ax.tick_params(labelsize=7)
lbl = r"$\log_{10}\rho$ (Ω·m)" if log_scale else r"$\rho$ (Ω·m)"
cb.set_label(lbl, fontsize=7)
if show_stations and self.n_stations > 0:
# Draw stations on the top XY-slice panel (axs[2])
axs[2].plot(
self.stations_xy[:, 0],
self.stations_xy[:, 1],
"v",
ms=5,
color="k",
label="stations",
zorder=5,
)
fig.suptitle(
self.name or "3-D resistivity model", fontsize=10, y=1.01
)
return fig, axs
def __repr__(self) -> str:
return (
f"Grid3D(nx={self.nx}, ny={self.ny}, nz={self.nz}, "
f"x_ext={self.x_extent:.0f} m, y_ext={self.y_extent:.0f} m, "
f"z_ext={self.z_extent:.0f} m, "
f"n_stations={self.n_stations}, n_pad={self.n_pad}"
+ (f", name={self.name!r}" if self.name else "")
+ ")"
)
# ─────────────────────────────────────────────────────────────────────────
# Constructors
# ─────────────────────────────────────────────────────────────────────────
[docs]
@classmethod
def halfspace(
cls,
rho: float = 100.0,
*,
nx: int = 20,
ny: int = 20,
nz: int = 15,
x_max: float = 8_000.0,
y_max: float = 8_000.0,
z_max: float = 4_000.0,
n_pad: int = 8,
pad_factor: float = 1.3,
nx_stations: int = 5,
ny_stations: int = 5,
name: str = "halfspace",
) -> Grid3D:
"""Create a uniform resistivity 3-D halfspace with a regular station grid.
Parameters
----------
rho : float
Background resistivity [Ω·m].
nx, ny, nz : int
Core cell counts (padding added automatically).
x_max, y_max : float
Core horizontal extents [m].
z_max : float
Core depth extent [m].
n_pad : int
Padding cells per side (x, y) and at the bottom (z).
pad_factor : float
Exponential growth factor for padding cells.
nx_stations, ny_stations : int
Regular station grid dimensions.
name : str
Returns
-------
Grid3D
"""
dx_core = np.full(nx, x_max / nx)
dy_core = np.full(ny, y_max / ny)
dz_core = np.full(nz, z_max / nz)
dx_pad = make_padding(dx_core[0], n_pad, pad_factor)
dy_pad = make_padding(dy_core[0], n_pad, pad_factor)
dz_pad = make_padding(dz_core[-1], n_pad, pad_factor)
dx_full = np.concatenate([dx_pad[::-1], dx_core, dx_pad])
dy_full = np.concatenate([dy_pad[::-1], dy_core, dy_pad])
dz_full = np.concatenate([dz_core, dz_pad])
nx_tot, ny_tot, nz_tot = len(dx_full), len(dy_full), len(dz_full)
rho_grid = np.full((nz_tot, ny_tot, nx_tot), rho)
x_pad_off = dx_pad[::-1].sum()
y_pad_off = dy_pad[::-1].sum()
xs = np.linspace(0.0, x_max, nx_stations) + x_pad_off
ys = np.linspace(0.0, y_max, ny_stations) + y_pad_off
gx, gy = np.meshgrid(xs, ys)
stations = np.column_stack([gx.ravel(), gy.ravel()])
return cls(
dx=dx_full,
dy=dy_full,
dz=dz_full,
resistivity=rho_grid,
stations_xy=stations,
n_pad=n_pad,
name=name,
)
[docs]
@classmethod
def block_anomaly(
cls,
bg_rho: float = 100.0,
anomaly_rho: float = 5.0,
bounds: tuple[float, float, float, float, float, float] = (
2_000.0,
6_000.0,
2_000.0,
6_000.0,
300.0,
1_500.0,
),
*,
nx: int = 25,
ny: int = 25,
nz: int = 18,
x_max: float = 8_000.0,
y_max: float = 8_000.0,
z_max: float = 5_000.0,
n_pad: int = 8,
pad_factor: float = 1.3,
nx_stations: int = 5,
ny_stations: int = 5,
name: str = "",
) -> Grid3D:
"""Create a background halfspace with one rectangular 3-D anomaly.
Parameters
----------
bg_rho : float
Background resistivity [Ω·m].
anomaly_rho : float
Anomaly resistivity [Ω·m].
bounds : (x_lo, x_hi, y_lo, y_hi, z_lo, z_hi)
Anomaly extents in core coordinates [m].
nx, ny, nz : int Core cell counts.
x_max, y_max, z_max : float Core extents [m].
n_pad, pad_factor : int, float
nx_stations, ny_stations : int
name : str
Returns
-------
Grid3D
Examples
--------
3-D conductive fault zone::
>>> g = Grid3D.block_anomaly(
... bg_rho=500.0, anomaly_rho=3.0,
... bounds=(2_500., 5_500., 2_500., 5_500., 200., 1_800.))
"""
g = cls.halfspace(
rho=bg_rho,
nx=nx,
ny=ny,
nz=nz,
x_max=x_max,
y_max=y_max,
z_max=z_max,
n_pad=n_pad,
pad_factor=pad_factor,
nx_stations=nx_stations,
ny_stations=ny_stations,
name=name or f"bg={bg_rho} + block={anomaly_rho} Ω·m",
)
x_pad_off = make_padding(x_max / nx, n_pad, pad_factor).sum()
y_pad_off = make_padding(y_max / ny, n_pad, pad_factor).sum()
xc = g.x_centers - x_pad_off
yc = g.y_centers - y_pad_off
zc = g.z_centers
x_lo, x_hi, y_lo, y_hi, z_lo, z_hi = bounds
col_mask = (xc >= x_lo) & (xc <= x_hi)
row_mask = (yc >= y_lo) & (yc <= y_hi)
dep_mask = (zc >= z_lo) & (zc <= z_hi)
g.resistivity[np.ix_(dep_mask, row_mask, col_mask)] = anomaly_rho
return g
[docs]
@classmethod
def random_layered(
cls,
*,
n_layers: int = 4,
nx: int = 20,
ny: int = 20,
nz: int = 15,
x_max: float = 8_000.0,
y_max: float = 8_000.0,
z_max: float = 4_000.0,
n_pad: int = 8,
pad_factor: float = 1.3,
nx_stations: int = 5,
ny_stations: int = 5,
rho_min: float = 1.0,
rho_max: float = 10_000.0,
lateral_variation: bool = True,
corr_length: float = 2_000.0,
seed=None,
name: str = "random_3d",
) -> Grid3D:
"""Generate a random layered 3-D model with optional lateral variation.
Resistivities for *n_layers* horizontal layers are drawn from a
log-uniform prior. When *lateral_variation* is ``True``, each
column's resistivity is smoothly perturbed using a Gaussian random
field with correlation length *corr_length*.
Parameters
----------
n_layers : int Number of horizontal layers.
nx, ny, nz, x_max, y_max, z_max, n_pad, pad_factor : as above.
rho_min, rho_max : float Resistivity bounds [Ω·m].
lateral_variation : bool
corr_length : float GRF correlation length [m].
seed : int or Generator or None.
name : str
Returns
-------
Grid3D
"""
rng = _ensure_rng(seed)
g = cls.halfspace(
rho=100.0,
nx=nx,
ny=ny,
nz=nz,
x_max=x_max,
y_max=y_max,
z_max=z_max,
n_pad=n_pad,
pad_factor=pad_factor,
nx_stations=nx_stations,
ny_stations=ny_stations,
name=name,
)
nx_tot, ny_tot, nz_tot = g.nx, g.ny, g.nz
# Background layers
layer_bounds = np.round(np.linspace(0, nz_tot, n_layers + 1)).astype(
int
)
log_lo, log_hi = np.log10(rho_min), np.log10(rho_max)
rho_3d = np.ones((nz_tot, ny_tot, nx_tot))
for k in range(n_layers):
rho_k = 10.0 ** rng.uniform(log_lo, log_hi)
rho_3d[layer_bounds[k] : layer_bounds[k + 1], :, :] = rho_k
if lateral_variation:
# 2-D Gaussian random field in xy for each depth layer
for iz in range(nz_tot):
# Fast approximation: convolve white noise with a Gaussian kernel
noise = rng.standard_normal((ny_tot, nx_tot))
# Gaussian smoothing kernel width (cells)
dx_mean = float(g.dx.mean())
dy_mean = float(g.dy.mean())
sx = max(1.0, corr_length / dx_mean)
sy = max(1.0, corr_length / dy_mean)
from scipy.ndimage import gaussian_filter
smooth = gaussian_filter(
noise, sigma=[sy, sx], mode="reflect"
)
smooth /= max(smooth.std(), 1e-10) # normalise to unit std
log_perturb = 0.3 * smooth # ±30% log perturbation
rho_3d[iz] = np.clip(
rho_3d[iz] * 10.0**log_perturb, rho_min, rho_max
)
g.resistivity = rho_3d
return g