# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
2-D resistivity grid for MT forward modelling.
The :class:`Grid2D` dataclass is the central data object shared by the 2-D
MT forward solver and any downstream processing that needs the subsurface
resistivity structure. It stores the model as a regular (but non-uniform)
finite-difference grid together with station positions and padding metadata.
Coordinate convention
---------------------
* **x** — horizontal distance along the profile [m], increasing to the right.
* **z** — depth below surface [m], increasing downward.
* Cell *(i, j)* has its top-left corner at ``(x_nodes[j], z_nodes[i])``
where *i* is the row index (depth) and *j* is the column index (x).
* ``resistivity[i, j]`` is the resistivity of cell *(i, j)* [Ω·m].
Grid layout (``nx = 4``, ``nz = 3``, no padding shown)::
x_nodes: 0 dx[0] dx[0]+dx[1] …
┌────┬────┬────┬────┐
z_nodes[0] │(0,0)│(0,1)│(0,2)│(0,3)│ ← row 0 (shallowest)
├────┼────┼────┼────┤
z_nodes[1] │(1,0)│(1,1)│(1,2)│(1,3)│
├────┼────┼────┼────┤
z_nodes[2] │(2,0)│(2,1)│(2,2)│(2,3)│ ← row nz-1 (deepest)
└────┴────┴────┴────┘
Padding
-------
The FD solver requires padding cells on both horizontal sides and at the
bottom to push the Dirichlet boundaries far enough from the model region
that edge effects are negligible. Padding cells are constructed with
exponentially growing widths/depths. The resistivity in padding cells is
copied from the nearest edge column (horizontal) or deepest row (vertical).
The :attr:`n_pad` attribute records how many padding cells were added on
each side so that station positions and visualisation can clip to the
*core* region (:attr:`core_slice`).
References
----------
Wannamaker, P.E., Stodt, J.A., Rijo, L. (1987). A stable finite element
solution for two-dimensional magnetotelluric modelling. *Geophys. J. Int.*,
88(1), 277-296.
de Groot-Hedlin, C., Constable, S. (1990). Occam's inversion to generate
smooth, two-dimensional models from magnetotelluric data. *Geophysics*,
55(12), 1613-1624.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
__all__ = [
"Grid2D",
"make_padding",
]
# ─────────────────────────────────────────────────────────────────────────────
# Helper
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def make_padding(
core_spacing: float,
n_pad: int,
factor: float = 1.3,
) -> np.ndarray:
"""Return cell widths for one padding strip.
The first padding cell has width ``core_spacing * factor`` and each
subsequent cell is ``factor`` times wider.
Parameters
----------
core_spacing : float
Width/depth of the last core cell adjacent to the padding strip [m].
n_pad : int
Number of padding cells.
factor : float
Growth factor between successive padding cells. Values in [1.2, 1.5]
give good results for MT; 1.3 is a safe default.
Returns
-------
numpy.ndarray, shape (n_pad,)
Cell widths ordered from the core edge outward.
"""
return core_spacing * (factor ** np.arange(1, n_pad + 1))
def _ensure_rng(seed) -> np.random.Generator:
if isinstance(seed, np.random.Generator):
return seed
return np.random.default_rng(seed)
# ─────────────────────────────────────────────────────────────────────────────
# Grid2D
# ─────────────────────────────────────────────────────────────────────────────
[docs]
@dataclass
class Grid2D:
"""
Non-uniform 2-D finite-difference grid for MT forward modelling.
Parameters
----------
dx : array-like, shape (nx,)
Cell widths in the horizontal (x) direction [m].
dz : array-like, shape (nz,)
Cell heights in the vertical (z, depth) direction [m].
resistivity : array-like, shape (nz, nx)
Cell resistivities [Ω·m], top-to-bottom, left-to-right.
x_stations : array-like, shape (n_stations,)
Horizontal positions of MT stations [m]. Must lie within
``[x_nodes[0], x_nodes[-1]]``.
n_pad : int
Number of padding cells on each side (left, right) and at the
bottom that were added during construction. Used internally to
identify the core model region. Set to 0 if no padding is
present.
name : str
Optional label for the model.
Attributes
----------
nx : int
Total number of cells in x (includes padding).
nz : int
Total number of cells in z (includes padding).
x_nodes : ndarray, shape (nx+1,)
x-coordinates of vertical node lines [m].
z_nodes : ndarray, shape (nz+1,)
z-coordinates of horizontal node lines (depth) [m].
x_centers : ndarray, shape (nx,)
x-coordinates of cell centres [m].
z_centers : ndarray, shape (nz,)
z-coordinates (depth) of cell centres [m].
core_x_slice : slice
Slice that selects core (non-padding) columns from any ``[:, j]``
array.
core_z_slice : slice
Slice that selects core (non-padding) rows from any ``[i, :]``
array.
Examples
--------
Uniform halfspace::
>>> g = Grid2D.halfspace(rho=100.0, nx=30, nz=20,
... x_max=5_000.0, z_max=3_000.0,
... n_stations=10, station_x_max=4_000.0)
>>> g.resistivity.shape
(20, 30)
One conductive anomaly::
>>> g = Grid2D.with_anomaly(
... bg_rho=500.0,
... anomaly_rho=5.0,
... anomaly_bounds=(1_000.0, 3_000.0, 200.0, 800.0),
... nx=40, nz=25,
... x_max=6_000.0, z_max=4_000.0,
... n_stations=12,
... )
1-D layered model extended horizontally::
>>> from pycsamt.forward.synthetic import LayeredModel
>>> m = LayeredModel([100, 10, 500], [300, 800])
>>> g = Grid2D.from_1d_layers(m, nx=40, x_max=6_000.0, n_stations=12)
"""
dx: np.ndarray
dz: np.ndarray
resistivity: np.ndarray
x_stations: np.ndarray
n_pad: int = 0
name: str = ""
# ─── post-init normalisation ──────────────────────────────────────────
def __post_init__(self):
self.dx = np.asarray(self.dx, dtype=float)
self.dz = np.asarray(self.dz, dtype=float)
self.resistivity = np.asarray(self.resistivity, dtype=float)
self.x_stations = np.asarray(self.x_stations, dtype=float)
if self.resistivity.shape != (self.nz, self.nx):
raise ValueError(
f"resistivity shape {self.resistivity.shape} does not match "
f"(nz={self.nz}, nx={self.nx})."
)
if np.any(self.resistivity <= 0.0):
raise ValueError("All resistivities must be strictly positive.")
if np.any(self.dx <= 0.0) or np.any(self.dz <= 0.0):
raise ValueError("All cell spacings must be strictly positive.")
x_lo, x_hi = self.x_nodes[0], self.x_nodes[-1]
out = (self.x_stations < x_lo) | (self.x_stations > x_hi)
if np.any(out):
raise ValueError(
f"{out.sum()} station(s) lie outside the grid "
f"[{x_lo:.1f}, {x_hi:.1f}] m."
)
# ─── shape properties ─────────────────────────────────────────────────
[docs]
@property
def nx(self) -> int:
"""Number of cells in x."""
return len(self.dx)
[docs]
@property
def nz(self) -> int:
"""Number of cells in z (depth)."""
return len(self.dz)
[docs]
@property
def n_stations(self) -> int:
"""Number of MT stations."""
return len(self.x_stations)
[docs]
@property
def n_nodes(self) -> int:
"""Total FD node count ``(nx+1) × (nz+1)``."""
return (self.nx + 1) * (self.nz + 1)
# ─── coordinate arrays ────────────────────────────────────────────────
[docs]
@property
def x_nodes(self) -> np.ndarray:
"""x-coordinates of vertical node lines [m], shape (nx+1,)."""
return np.concatenate([[0.0], np.cumsum(self.dx)])
[docs]
@property
def z_nodes(self) -> np.ndarray:
"""Depth coordinates of horizontal node lines [m], shape (nz+1,)."""
return np.concatenate([[0.0], np.cumsum(self.dz)])
[docs]
@property
def x_centers(self) -> np.ndarray:
"""x-coordinates of cell centres [m], shape (nx,)."""
xn = self.x_nodes
return 0.5 * (xn[:-1] + xn[1:])
[docs]
@property
def z_centers(self) -> np.ndarray:
"""Depth coordinates of cell centres [m], shape (nz,)."""
zn = self.z_nodes
return 0.5 * (zn[:-1] + zn[1:])
[docs]
@property
def x_extent(self) -> float:
"""Total horizontal extent of the grid [m]."""
return float(self.dx.sum())
[docs]
@property
def z_extent(self) -> float:
"""Total depth extent of the grid [m]."""
return float(self.dz.sum())
# ─── padding helpers ──────────────────────────────────────────────────
[docs]
@property
def core_x_slice(self) -> slice:
"""Column slice that selects only core (non-padding) cells."""
p = self.n_pad
return slice(p, self.nx - p) if p > 0 else slice(None)
[docs]
@property
def core_z_slice(self) -> slice:
"""Row slice that selects only core (non-padding) cells."""
p = self.n_pad
return slice(None, self.nz - p) if p > 0 else slice(None)
[docs]
@property
def core_resistivity(self) -> np.ndarray:
"""Resistivity array clipped to the core (non-padding) region."""
return self.resistivity[self.core_z_slice, self.core_x_slice]
# ─── column / row profiles for 1-D boundary conditions ───────────────
[docs]
def column_profile(
self,
col: int,
) -> tuple[np.ndarray, np.ndarray]:
"""Return the 1-D resistivity/thickness profile of column *col*.
Used by the FD solver to compute Dirichlet boundary conditions at
the left and right edges from the 1-D MT response of the edge
columns.
Parameters
----------
col : int
Column index (0 = leftmost, nx-1 = rightmost).
Returns
-------
rho : ndarray, shape (nz,)
Layer resistivities top-to-bottom [Ω·m].
thick : ndarray, shape (nz-1,)
Layer thicknesses [m]. (Bottom layer is a halfspace.)
"""
rho = self.resistivity[:, col]
thick = self.dz[:-1] # nz-1 thicknesses; last cell = halfspace
return rho, thick
[docs]
def station_cell_indices(self) -> np.ndarray:
"""Return the column index of the cell that contains each station.
Returns
-------
indices : ndarray of int, shape (n_stations,)
"""
xn = self.x_nodes
idx = np.searchsorted(xn, self.x_stations, side="right") - 1
return np.clip(idx, 0, self.nx - 1)
[docs]
def station_node_indices(self) -> np.ndarray:
"""Return the index of the nearest surface node for each station.
Surface nodes are at ``z = 0`` and numbered ``0, 1, …, nx``.
Returns
-------
indices : ndarray of int, shape (n_stations,)
"""
xn = self.x_nodes
return np.argmin(
np.abs(xn[:, None] - self.x_stations[None, :]), axis=0
)
# ─── conductivity helpers ─────────────────────────────────────────────
[docs]
@property
def conductivity(self) -> np.ndarray:
"""Cell conductivities [S/m], shape (nz, nx)."""
return 1.0 / self.resistivity
[docs]
def harmonic_mean_x(self) -> np.ndarray:
"""Harmonic-mean conductivity at x-directed cell interfaces.
Used by the TM-mode FD assembler.
Returns
-------
ndarray, shape (nz, nx-1)
``sigma_hx[i, j]`` is the harmonic mean of ``sigma[i, j]``
and ``sigma[i, j+1]``.
"""
s = self.conductivity
s_l, s_r = s[:, :-1], s[:, 1:]
return 2.0 * s_l * s_r / (s_l + s_r)
[docs]
def harmonic_mean_z(self) -> np.ndarray:
"""Harmonic-mean conductivity at z-directed cell interfaces.
Returns
-------
ndarray, shape (nz-1, nx)
``sigma_hz[i, j]`` is the harmonic mean of ``sigma[i, j]``
and ``sigma[i+1, j]``.
"""
s = self.conductivity
s_u, s_d = s[:-1, :], s[1:, :]
return 2.0 * s_u * s_d / (s_u + s_d)
# ─── model visualisation ──────────────────────────────────────────────
[docs]
def plot(
self,
ax=None,
*,
log_scale: bool = True,
cmap: str = "jet_r",
clip_core: bool = True,
show_stations: bool = True,
figsize: tuple[float, float] = (10, 5),
vmin: float | None = None,
vmax: float | None = None,
):
"""Plot the 2-D resistivity model.
Parameters
----------
ax : Axes or None
Target axes. Created when not given.
log_scale : bool
Plot log₁₀(ρ) rather than ρ.
cmap : str
Matplotlib colormap name.
clip_core : bool
Clip to the non-padding core region.
show_stations : bool
Mark station positions on the surface.
figsize : (float, float)
Figure size when *ax* is not provided.
vmin, vmax : float or None
Colormap limits in log₁₀(Ω·m) space when *log_scale* is True.
Returns
-------
ax : Axes
"""
import matplotlib.pyplot as plt
from matplotlib.colors import LogNorm
if ax is None:
_, ax = plt.subplots(figsize=figsize)
xs = self.core_x_slice if clip_core else slice(None)
zs = self.core_z_slice if clip_core else slice(None)
rho = self.resistivity[zs, xs]
xn = (
self.x_nodes[self.n_pad : self.nx + 1 - self.n_pad]
if clip_core
else self.x_nodes
)
zn = (
self.z_nodes[: self.nz + 1 - self.n_pad]
if clip_core
else self.z_nodes
)
if log_scale:
pc = ax.pcolormesh(
xn,
zn,
np.log10(rho),
cmap=cmap,
shading="flat",
vmin=vmin,
vmax=vmax,
)
cbar = ax.get_figure().colorbar(pc, ax=ax, pad=0.02)
cbar.set_label("log₁₀(ρ) [Ω·m]", fontsize=8)
else:
norm = LogNorm(
vmin=vmin or rho.min(),
vmax=vmax or rho.max(),
)
pc = ax.pcolormesh(
xn, zn, rho, cmap=cmap, norm=norm, shading="flat"
)
cbar = ax.get_figure().colorbar(pc, ax=ax, pad=0.02)
cbar.set_label("ρ [Ω·m]", fontsize=8)
if show_stations and self.n_stations > 0:
ax.plot(
self.x_stations,
np.zeros_like(self.x_stations),
"v",
color="k",
ms=5,
zorder=5,
label="stations",
)
ax.invert_yaxis()
ax.set_xlabel("Distance (m)")
ax.set_ylabel("Depth (m)")
ax.set_title(self.name or "2-D resistivity model")
return ax
# ─── repr ─────────────────────────────────────────────────────────────
def __repr__(self) -> str:
return (
f"Grid2D(nx={self.nx}, nz={self.nz}, "
f"x_extent={self.x_extent:.0f} m, z_extent={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 "")
+ ")"
)
# ─────────────────────────────────────────────────────────────────────────
# Class-method constructors
# ─────────────────────────────────────────────────────────────────────────
[docs]
@classmethod
def halfspace(
cls,
rho: float = 100.0,
*,
nx: int = 40,
nz: int = 30,
x_max: float = 10_000.0,
z_max: float = 5_000.0,
n_pad: int = 8,
pad_factor: float = 1.3,
n_stations: int = 10,
station_x_max: float | None = None,
name: str = "halfspace",
) -> Grid2D:
"""Create a uniform resistivity halfspace grid.
Parameters
----------
rho : float
Background resistivity [Ω·m].
nx : int
Number of *core* cells in x (padding added on both sides).
nz : int
Number of *core* cells in z (padding added at the bottom).
x_max : float
Total horizontal extent of the *core* region [m].
z_max : float
Total depth of the *core* region [m].
n_pad : int
Number of padding cells on each side (left, right, bottom).
pad_factor : float
Exponential growth factor for padding cell sizes.
n_stations : int
Number of equally spaced stations across the core region.
station_x_max : float or None
Right-most station position [m]. Defaults to ``x_max``.
name : str
Label for the model.
Returns
-------
Grid2D
"""
dx_core = np.full(nx, x_max / nx)
dz_core = np.full(nz, z_max / nz)
dx_pad = make_padding(dx_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])
dz_full = np.concatenate([dz_core, dz_pad])
nx_total = len(dx_full)
nz_total = len(dz_full)
rho_grid = np.full((nz_total, nx_total), rho)
x_pad_offset = dx_pad[::-1].sum()
sx_max = station_x_max if station_x_max is not None else x_max
x_st = np.linspace(0.0, sx_max, n_stations) + x_pad_offset
return cls(
dx=dx_full,
dz=dz_full,
resistivity=rho_grid,
x_stations=x_st,
n_pad=n_pad,
name=name,
)
[docs]
@classmethod
def from_1d_layers(
cls,
model, # LayeredModel
*,
nx: int = 40,
x_max: float = 10_000.0,
n_pad: int = 8,
pad_factor: float = 1.3,
n_stations: int = 10,
station_x_max: float | None = None,
dz_min: float = 10.0,
name: str = "",
) -> Grid2D:
"""Build a 2-D grid by extending a 1-D :class:`~pycsamt.forward.synthetic.LayeredModel`.
Every column has the same 1-D layer stack. This is useful as a
background model for synthetic anomaly tests and inversion starting
models.
Parameters
----------
model : LayeredModel
Source 1-D model. ``model.thickness`` defines the cell
thicknesses; the deepest cell becomes the halfspace.
nx : int
Number of core cells in x.
x_max : float
Horizontal extent of the core region [m].
n_pad : int
Padding cells on each side.
pad_factor : float
Padding growth factor.
n_stations : int
Number of equally spaced surface stations.
station_x_max : float or None
Right-most station x [m].
dz_min : float
Minimum cell height [m]; avoids excessively thin cells at depth.
name : str
Model label.
Returns
-------
Grid2D
"""
dx_core = np.full(nx, x_max / nx)
dx_pad = make_padding(dx_core[0], n_pad, pad_factor)
dx_full = np.concatenate([dx_pad[::-1], dx_core, dx_pad])
# Build dz from model thicknesses; add a deep halfspace cell
thick = np.asarray(model.thickness, dtype=float)
dz_core = np.concatenate(
[np.maximum(thick, dz_min), [np.maximum(thick[-1] * 3.0, dz_min)]]
)
len(dz_core)
dz_pad = make_padding(dz_core[-1], n_pad, pad_factor)
dz_full = np.concatenate([dz_core, dz_pad])
nx_total = len(dx_full)
len(dz_full)
# Assign resistivity: repeat 1D column across all x positions.
# model.resistivity has n_layers values; dz_core has len(thick)+1
# cells (last cell is the halfspace discretisation) — they match.
rho_col = np.asarray(model.resistivity, dtype=float)
rho_col_full = np.concatenate([rho_col, np.full(n_pad, rho_col[-1])])
rho_grid = np.tile(rho_col_full[:, None], (1, nx_total))
x_pad_offset = dx_pad[::-1].sum()
sx_max = station_x_max if station_x_max is not None else x_max
x_st = np.linspace(0.0, sx_max, n_stations) + x_pad_offset
return cls(
dx=dx_full,
dz=dz_full,
resistivity=rho_grid,
x_stations=x_st,
n_pad=n_pad,
name=name or f"1D-extended ({model.n_layers} layers)",
)
[docs]
@classmethod
def with_anomaly(
cls,
bg_rho: float = 100.0,
anomaly_rho: float = 5.0,
anomaly_bounds: tuple[float, float, float, float] = (
2_000.0,
4_000.0,
200.0,
800.0,
),
*,
nx: int = 50,
nz: int = 35,
x_max: float = 8_000.0,
z_max: float = 4_000.0,
n_pad: int = 8,
pad_factor: float = 1.3,
n_stations: int = 12,
station_x_max: float | None = None,
name: str = "",
) -> Grid2D:
"""Create a background halfspace with one rectangular anomaly.
Parameters
----------
bg_rho : float
Background resistivity [Ω·m].
anomaly_rho : float
Anomaly resistivity [Ω·m].
anomaly_bounds : (x_lo, x_hi, z_lo, z_hi)
Anomaly extent in core coordinates [m]:
horizontal from *x_lo* to *x_hi*, depth from *z_lo* to *z_hi*.
nx, nz : int
Core cell counts.
x_max, z_max : float
Core region extents [m].
n_pad : int
Padding cells per side.
pad_factor : float
Padding growth factor.
n_stations : int
Number of equally spaced surface stations.
station_x_max : float or None
Right-most station x [m].
name : str
Model label.
Returns
-------
Grid2D
Examples
--------
Resistive body at mid-depth::
>>> g = Grid2D.with_anomaly(
... bg_rho=50.0, anomaly_rho=2000.0,
... anomaly_bounds=(2000.0, 4000.0, 500.0, 1500.0),
... )
"""
g = cls.halfspace(
rho=bg_rho,
nx=nx,
nz=nz,
x_max=x_max,
z_max=z_max,
n_pad=n_pad,
pad_factor=pad_factor,
n_stations=n_stations,
station_x_max=station_x_max,
name=name or f"bg={bg_rho} + anomaly={anomaly_rho} Ω·m",
)
# Identify which cells fall inside the anomaly (core coords)
x_pad_offset = make_padding(x_max / nx, n_pad, pad_factor).sum()
xc = g.x_centers - x_pad_offset
zc = g.z_centers
x_lo, x_hi, z_lo, z_hi = anomaly_bounds
col_mask = (xc >= x_lo) & (xc <= x_hi)
row_mask = (zc >= z_lo) & (zc <= z_hi)
g.resistivity[np.ix_(row_mask, col_mask)] = anomaly_rho
return g
[docs]
@classmethod
def random(
cls,
*,
nx: int = 40,
nz: int = 30,
x_max: float = 8_000.0,
z_max: float = 4_000.0,
n_pad: int = 8,
pad_factor: float = 1.3,
n_stations: int = 10,
rho_min: float = 1.0,
rho_max: float = 10_000.0,
n_layers: int = 4,
lateral_variation: bool = True,
seed=None,
name: str = "random",
) -> Grid2D:
"""Generate a random layered 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 perturbed by ±30 % in log₁₀ space.
Parameters
----------
nx, nz : int
Core cell counts.
x_max, z_max : float
Core extents [m].
n_pad : int
Padding cells per side.
rho_min, rho_max : float
Resistivity bounds [Ω·m].
n_layers : int
Number of horizontal layers.
lateral_variation : bool
Add column-wise log-normal perturbation.
seed : int or Generator or None
name : str
Returns
-------
Grid2D
"""
rng = _ensure_rng(seed)
# Build grid geometry
g = cls.halfspace(
rho=100.0,
nx=nx,
nz=nz,
x_max=x_max,
z_max=z_max,
n_pad=n_pad,
pad_factor=pad_factor,
n_stations=n_stations,
name=name,
)
nx_tot = g.nx
nz_tot = g.nz
# Assign 1D background
layer_bounds = np.linspace(0, nz_tot, n_layers + 1).astype(int)
log_lo = np.log10(rho_min)
log_hi = np.log10(rho_max)
rho_bg = np.ones((nz_tot, nx_tot))
for k in range(n_layers):
rho_k = 10.0 ** rng.uniform(log_lo, log_hi)
rho_bg[layer_bounds[k] : layer_bounds[k + 1], :] = rho_k
if lateral_variation:
# Column-wise log-normal perturbation, spatially correlated in x
log_perturb = rng.normal(0.0, 0.3, (nz_tot, nx_tot))
# Smooth in x with a running mean over 3 cells
log_perturb = np.apply_along_axis(
lambda v: np.convolve(v, np.ones(3) / 3, mode="same"),
axis=1,
arr=log_perturb,
)
rho_bg = np.clip(rho_bg * 10.0**log_perturb, rho_min, rho_max)
g.resistivity = rho_bg
return g