Source code for pycsamt.forward.em2d

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
2-D magnetotelluric finite-difference forward solver.

:class:`MT2DForward` solves the 2-D MT boundary-value problem for both
the TE and TM polarisation modes on a non-uniform staggered grid using
the finite-difference (FD) method.

Physics
-------
For a 2-D earth with conductivity σ(x, z) and a plane electromagnetic wave
incident from above, the governing scalar PDEs (time convention e^{+iωt})
are:

**TE mode** (electric field parallel to strike, E_y):

    ∂²E_y/∂x² + ∂²E_y/∂z² − iωμ₀σ(x,z) E_y = 0

**TM mode** (magnetic field parallel to strike, H_y):

    ∂/∂x[σ⁻¹ ∂H_y/∂x] + ∂/∂z[σ⁻¹ ∂H_y/∂z] − iωμ₀ H_y = 0

Grid and node numbering
-----------------------
The FD grid has ``(nx+1) × (nz+1)`` nodes.  Node at column *j*, row *i*
(depth) has global index ``k = i*(nx+1) + j``.  The surface is row *i = 0*,
the bottom is row *i = nz*.

Boundary conditions
-------------------
Dirichlet conditions are applied at all four boundaries using the analytic
1-D MT response of the edge column profiles (left/right) or the halfspace
(top/bottom).  For the TE mode the normalised plane-wave source
``E_y = 1`` at the surface is the natural starting point; the BC values
at depth follow from the 1-D recursion.  For the TM mode the analogous
1-D ``H_y`` field is used.

Each frequency requires two independent sparse solves (one per mode).
With ``scipy.sparse.linalg.spsolve`` and grids of order 50 × 40 nodes
(~2000 unknowns) each solve takes < 0.1 s on a single CPU core.

References
----------
Wannamaker, P.E., Stodt, J.A., Rijo, L. (1987). A stable finite element
solution for two-dimensional magnetotelluric modelling. *Geophys. J. Int.*
88, 277-296.

de Groot-Hedlin, C., Constable, S. (1990). Occam's inversion to generate
smooth, two-dimensional models from magnetotelluric data. *Geophysics*
55, 1613-1624.

Wait, J.R. (1954). On the relation between telluric currents and the Earth's
magnetic field. *Geophysics* 19, 281-289.
"""

from __future__ import annotations

from dataclasses import dataclass, field

import numpy as np
from scipy import sparse
from scipy.sparse.linalg import spsolve

from .em1d import _z_surface_mt
from .grid2d import Grid2D

__all__ = [
    "MT2DForward",
    "ForwardResponse2D",
]

MU0: float = 4.0e-7 * np.pi  # H/m


# ─────────────────────────────────────────────────────────────────────────────
# Output container
# ─────────────────────────────────────────────────────────────────────────────


[docs] @dataclass class ForwardResponse2D: """Output of the 2-D MT forward solver. All impedance and apparent-resistivity arrays have shape ``(n_freqs, n_stations)``. Parameters ---------- freqs : ndarray, shape (n_freqs,) Evaluation frequencies [Hz]. stations_x : ndarray, shape (n_stations,) Station x-positions [m]. zxy : ndarray, shape (n_freqs, n_stations), complex TE-mode surface impedance Z_xy = E_y / H_x [V/A]. zyx : ndarray, shape (n_freqs, n_stations), complex TM-mode surface impedance Z_yx = E_x / H_y [V/A] (negative by convention: Z_yx = −Z_xy for a 1-D earth). rho_a_te : ndarray, shape (n_freqs, n_stations) TE apparent resistivity [Ω·m]. phase_te : ndarray, shape (n_freqs, n_stations) TE impedance phase [degrees, 0–90° for a normal model]. rho_a_tm : ndarray, shape (n_freqs, n_stations) TM apparent resistivity [Ω·m]. phase_tm : ndarray, shape (n_freqs, n_stations) TM impedance phase [degrees]. grid : Grid2D The model grid used for the forward run. """ freqs: np.ndarray stations_x: np.ndarray zxy: np.ndarray zyx: np.ndarray rho_a_te: np.ndarray phase_te: np.ndarray rho_a_tm: np.ndarray phase_tm: np.ndarray grid: Grid2D = field(repr=False) # ─── convenience ─────────────────────────────────────────────────────
[docs] @property def n_freqs(self) -> int: return len(self.freqs)
[docs] @property def n_stations(self) -> int: return len(self.stations_x)
[docs] @property def periods(self) -> np.ndarray: """Periods [s].""" return 1.0 / self.freqs
[docs] def station_response(self, station: int) -> dict: """Return all response arrays for one station as a dict.""" return dict( freqs=self.freqs, periods=self.periods, zxy=self.zxy[:, station], zyx=self.zyx[:, station], rho_a_te=self.rho_a_te[:, station], phase_te=self.phase_te[:, station], rho_a_tm=self.rho_a_tm[:, station], phase_tm=self.phase_tm[:, station], )
[docs] def to_feature_array( self, *, mode: str = "both", log_rho: bool = True, include_phase: bool = True, ) -> np.ndarray: """Flatten to a 2-D feature matrix for ML training. Parameters ---------- mode : {'te', 'tm', 'both'} Which mode(s) to include. log_rho : bool Return log₁₀(ρ_a) instead of ρ_a. include_phase : bool Concatenate phase alongside ρ_a. Returns ------- ndarray, shape (n_stations, n_features) """ parts = [] for mode_key in ( ["te"] if mode == "te" else ["tm"] if mode == "tm" else ["te", "tm"] ): rho = getattr(self, f"rho_a_{mode_key}") # (n_freqs, n_stations) phi = getattr(self, f"phase_{mode_key}") if log_rho: rho = np.log10(np.maximum(rho, 1e-12)) parts.append(rho.T) # (n_stations, n_freqs) if include_phase: parts.append(phi.T) return np.concatenate(parts, axis=1)
[docs] def plot( self, station: int = 0, ax=None, *, figsize: tuple[float, float] = (9, 5), ): """Quick diagnostic plot of ρ_a and phase for one station. Parameters ---------- station : int Station index. ax : array of Axes or None Two axes (rho, phase). Created when not provided. figsize : tuple Returns ------- axes : ndarray of Axes, shape (2,) """ import matplotlib.pyplot as plt if ax is None: fig, ax = plt.subplots(2, 1, figsize=figsize, sharex=True) per = self.periods ax[0].loglog(per, self.rho_a_te[:, station], "b-o", ms=3, label="TE") ax[0].loglog(per, self.rho_a_tm[:, station], "r-s", ms=3, label="TM") ax[0].set_ylabel(r"$\rho_a$ (Ω·m)") ax[0].legend(fontsize=8) ax[0].set_title( f"2-D MT response — station {station} " f"(x = {self.stations_x[station]:.0f} m)" ) ax[1].semilogx(per, self.phase_te[:, station], "b-o", ms=3) ax[1].semilogx(per, self.phase_tm[:, station], "r-s", ms=3) ax[1].set_ylabel("Phase (°)") ax[1].set_xlabel("Period (s)") ax[1].set_ylim(0, 90) plt.tight_layout() return ax
# ───────────────────────────────────────────────────────────────────────────── # 1-D MT helpers (boundary conditions) # ───────────────────────────────────────────────────────────────────────────── def _z1d_column(omega: float, rho: np.ndarray, thick: np.ndarray) -> complex: """Surface impedance for a 1-D column profile.""" return _z_surface_mt(omega, rho, thick) def _ey_1d_profile( omega: float, rho: np.ndarray, thick: np.ndarray, z_nodes: np.ndarray, ) -> np.ndarray: """Compute the normalised TE field E_y(z) at the grid node depths. Uses the standard downward-continuation formula. The surface value is normalised to 1 + 0j. Returns ------- Ey : ndarray of complex, shape (nz+1,) """ nz1 = len(z_nodes) Ey = np.zeros(nz1, dtype=complex) Ey[0] = 1.0 + 0j # k_j = sqrt(i * omega * mu0 / rho_j) k = np.sqrt(1j * omega * MU0 / rho) # shape (n_layers,) z_top = np.concatenate([[0.0], np.cumsum(thick)]) # layer top depths for n in range(1, nz1): z = z_nodes[n] # Find which layer z falls in lay = np.searchsorted(z_top, z, side="right") - 1 lay = min(lay, len(rho) - 1) z - z_top[lay] k_j = k[lay] # Propagate E_y into this layer using the upward-recursion impedance # E_y(z) = E_y(z_top) * cosh(k_j*dz) + (Z(z_top)/iωμ₀/k_j) * ... # For the FD BC we only need a smooth exponential-like profile: # E_y(z) ≈ exp(-k_j * dz) (downgoing wave in uniform layer) Ey[n] = Ey[0] * np.exp(-k_j * z) return Ey def _hy_1d_profile( omega: float, rho: np.ndarray, thick: np.ndarray, z_nodes: np.ndarray, ) -> np.ndarray: """Compute the normalised TM field H_y(z) at grid node depths. Surface value normalised to 1 + 0j. Returns ------- Hy : ndarray of complex, shape (nz+1,) """ nz1 = len(z_nodes) Hy = np.zeros(nz1, dtype=complex) Hy[0] = 1.0 + 0j k = np.sqrt(1j * omega * MU0 / rho) z_top = np.concatenate([[0.0], np.cumsum(thick)]) for n in range(1, nz1): z = z_nodes[n] lay = min(np.searchsorted(z_top, z, side="right") - 1, len(rho) - 1) Hy[n] = Hy[0] * np.exp(-k[lay] * z) return Hy # ───────────────────────────────────────────────────────────────────────────── # FD matrix assemblers # ───────────────────────────────────────────────────────────────────────────── def _node_index(i: int, j: int, nx1: int) -> int: """Global node index from row i (depth) and column j (x).""" return i * nx1 + j def _assemble_te( grid: Grid2D, omega: float, ) -> sparse.csr_matrix: """Build the sparse FD matrix for the TE mode at angular frequency ω. The system A · e = b (where e is the flattened E_y field at all (nx+1)×(nz+1) nodes) is assembled here. Boundary nodes are handled by the caller (rows replaced with identity, RHS set to BC values). Returns ------- A : scipy.sparse.csr_matrix, shape (n_nodes, n_nodes) """ dx = grid.dx # (nx,) dz = grid.dz # (nz,) nx, nz = grid.nx, grid.nz nx1 = nx + 1 nz1 = nz + 1 n = nx1 * nz1 rows, cols, vals = [], [], [] # Harmonic-mean conductivity for the reaction term: # At node (i,j) we average the up to 4 surrounding cell conductivities sigma = grid.conductivity # (nz, nx) for i in range(nz1): for j in range(nx1): k = _node_index(i, j, nx1) # Boundary nodes — leave for caller to overwrite if i == 0 or i == nz or j == 0 or j == nx: rows.append(k) cols.append(k) vals.append(1.0) continue # ── x-direction second difference ────────────────────────── dx_l = dx[j - 1] # cell to the left of node j dx_r = dx[j] # cell to the right of node j dx_avg = 0.5 * (dx_l + dx_r) c_xr = 1.0 / (dx_r * dx_avg) c_xl = 1.0 / (dx_l * dx_avg) c_xc = -(c_xr + c_xl) # ── z-direction second difference ────────────────────────── dz_u = dz[i - 1] # cell above node i dz_d = dz[i] # cell below node i dz_avg = 0.5 * (dz_u + dz_d) c_zd = 1.0 / (dz_d * dz_avg) c_zu = 1.0 / (dz_u * dz_avg) c_zc = -(c_zd + c_zu) # ── reaction term iωμ₀σ ──────────────────────────────────── # Average conductivity over the four surrounding cells s_tl = sigma[i - 1, j - 1] s_tr = sigma[i - 1, j] s_bl = sigma[i, j - 1] s_br = sigma[i, j] # Area-weighted average of σ at this node a_tl = dx_l * dz_u a_tr = dx_r * dz_u a_bl = dx_l * dz_d a_br = dx_r * dz_d a_tot = a_tl + a_tr + a_bl + a_br sigma_n = ( a_tl * s_tl + a_tr * s_tr + a_bl * s_bl + a_br * s_br ) / a_tot react = 1j * omega * MU0 * sigma_n # ── diagonal ─────────────────────────────────────────────── diag = c_xc + c_zc - react rows.append(k) cols.append(k) vals.append(diag) # ── off-diagonal ─────────────────────────────────────────── k_xr = _node_index(i, j + 1, nx1) k_xl = _node_index(i, j - 1, nx1) k_zd = _node_index(i + 1, j, nx1) k_zu = _node_index(i - 1, j, nx1) rows.append(k) cols.append(k_xr) vals.append(c_xr) rows.append(k) cols.append(k_xl) vals.append(c_xl) rows.append(k) cols.append(k_zd) vals.append(c_zd) rows.append(k) cols.append(k_zu) vals.append(c_zu) return sparse.csr_matrix( (vals, (rows, cols)), shape=(n, n), dtype=complex ) def _assemble_tm( grid: Grid2D, omega: float, ) -> sparse.csr_matrix: """Build the sparse FD matrix for the TM mode at angular frequency ω. The TM governing equation is: ∂/∂x(ρ ∂H_y/∂x) + ∂/∂z(ρ ∂H_y/∂z) − iωμ₀ H_y = 0 where ρ = 1/σ is resistivity. Interface resistivities are computed as depth-weighted (x-interfaces) or width-weighted (z-interfaces) arithmetic means of the adjacent cell resistivities, which gives second-order accuracy and correctly reduces to the TE equation for a uniform medium. Returns ------- A : scipy.sparse.csr_matrix, shape (n_nodes, n_nodes) """ dx = grid.dx dz = grid.dz rho = grid.resistivity # (nz, nx) — use resistivity, not conductivity nx, nz = grid.nx, grid.nz nx1 = nx + 1 n = nx1 * (nz + 1) rows, cols, vals = [], [], [] for i in range(nz + 1): for j in range(nx1): k = _node_index(i, j, nx1) # Boundary nodes — placeholder; overwritten by _apply_dirichlet if i == 0 or i == nz or j == 0 or j == nx: rows.append(k) cols.append(k) vals.append(1.0) continue # Cell sizes adjacent to node (i, j): # row i-1 (above), row i (below), col j-1 (left), col j (right) dx_l = dx[j - 1] dx_r = dx[j] dz_u = dz[i - 1] dz_d = dz[i] dx_avg = 0.5 * (dx_l + dx_r) dz_avg = 0.5 * (dz_u + dz_d) # ── ρ at x-directed interfaces ─────────────────────────────── # Right interface (between node j and j+1, at depth z_nodes[i]): # arithmetic mean of the two cells straddling this depth. rho_xr = 0.5 * (rho[i - 1, j] + rho[i, j]) # Left interface (between node j-1 and j): rho_xl = 0.5 * (rho[i - 1, j - 1] + rho[i, j - 1]) # ── ρ at z-directed interfaces ─────────────────────────────── # Lower interface (between node i and i+1, at x_nodes[j]): rho_zd = 0.5 * (rho[i, j - 1] + rho[i, j]) # Upper interface (between node i-1 and i): rho_zu = 0.5 * (rho[i - 1, j - 1] + rho[i - 1, j]) # ── FD coefficients ────────────────────────────────────────── c_xr = rho_xr / (dx_r * dx_avg) c_xl = rho_xl / (dx_l * dx_avg) c_xc = -(c_xr + c_xl) c_zd = rho_zd / (dz_d * dz_avg) c_zu = rho_zu / (dz_u * dz_avg) c_zc = -(c_zd + c_zu) # Reaction term: ∂/∂x(ρ…) + ∂/∂z(ρ…) = iωμ₀ H_y diag = c_xc + c_zc - 1j * omega * MU0 rows.append(k) cols.append(k) vals.append(diag) k_xr = _node_index(i, j + 1, nx1) k_xl = _node_index(i, j - 1, nx1) k_zd = _node_index(i + 1, j, nx1) k_zu = _node_index(i - 1, j, nx1) rows.append(k) cols.append(k_xr) vals.append(c_xr) rows.append(k) cols.append(k_xl) vals.append(c_xl) rows.append(k) cols.append(k_zd) vals.append(c_zd) rows.append(k) cols.append(k_zu) vals.append(c_zu) return sparse.csr_matrix( (vals, (rows, cols)), shape=(n, n), dtype=complex ) # ───────────────────────────────────────────────────────────────────────────── # Boundary condition builders # ───────────────────────────────────────────────────────────────────────────── def _bc_te(grid: Grid2D, omega: float) -> np.ndarray: """Return the Dirichlet BC vector for the TE mode. Non-zero only at boundary nodes; interior entries are 0. Returns ------- b : ndarray of complex, shape (n_nodes,) """ nx, nz = grid.nx, grid.nz nx1 = nx + 1 n = nx1 * (nz + 1) b = np.zeros(n, dtype=complex) z_nodes = grid.z_nodes # Left column (j=0): 1-D profile of leftmost column rho_l, thick_l = grid.column_profile(0) ey_l = _ey_1d_profile(omega, rho_l, thick_l, z_nodes) for i in range(nz + 1): b[_node_index(i, 0, nx1)] = ey_l[i] # Right column (j=nx): 1-D profile of rightmost column rho_r, thick_r = grid.column_profile(nx - 1) ey_r = _ey_1d_profile(omega, rho_r, thick_r, z_nodes) for i in range(nz + 1): b[_node_index(i, nx, nx1)] = ey_r[i] # Top row (i=0): E_y = 1 (normalised incident field) for j in range(nx1): b[_node_index(0, j, nx1)] = 1.0 + 0j # Bottom row (i=nz): use average of left/right profiles at max depth ey_bot = 0.5 * (ey_l[-1] + ey_r[-1]) for j in range(nx1): b[_node_index(nz, j, nx1)] = ey_bot return b def _bc_tm(grid: Grid2D, omega: float) -> np.ndarray: """Return the Dirichlet BC vector for the TM mode. Returns ------- b : ndarray of complex, shape (n_nodes,) """ nx, nz = grid.nx, grid.nz nx1 = nx + 1 n = nx1 * (nz + 1) b = np.zeros(n, dtype=complex) z_nodes = grid.z_nodes rho_l, thick_l = grid.column_profile(0) hy_l = _hy_1d_profile(omega, rho_l, thick_l, z_nodes) for i in range(nz + 1): b[_node_index(i, 0, nx1)] = hy_l[i] rho_r, thick_r = grid.column_profile(nx - 1) hy_r = _hy_1d_profile(omega, rho_r, thick_r, z_nodes) for i in range(nz + 1): b[_node_index(i, nx, nx1)] = hy_r[i] # Top row: H_y = 1 for j in range(nx1): b[_node_index(0, j, nx1)] = 1.0 + 0j hy_bot = 0.5 * (hy_l[-1] + hy_r[-1]) for j in range(nx1): b[_node_index(nz, j, nx1)] = hy_bot return b # ───────────────────────────────────────────────────────────────────────────── # Surface field extraction # ───────────────────────────────────────────────────────────────────────────── def _surface_impedance_te( ey_surface: np.ndarray, # E_y at z=0, shape (nx+1,) ey_below: np.ndarray, # E_y at z=dz[0], shape (nx+1,) dz0: float, omega: float, ) -> np.ndarray: """Compute Z_xy = E_y / H_x at the surface nodes. From Faraday's law: H_x = (1/iωμ₀) ∂E_y/∂z Approximated as: H_x ≈ (E_y(dz0) - E_y(0)) / (dz0 * iωμ₀) Returns ------- Zxy : ndarray of complex, shape (nx+1,) """ dEy_dz = (ey_below - ey_surface) / dz0 # Faraday's law (e^{+iωt}): ∂E_y/∂z = iωμ₀ H_x → H_x = ∂E_y/∂z / (iωμ₀) # But with the downgoing-wave normalisation used in the FD assembler the # correct sign for Z_xy = E_y/H_x gives a +45° phase on a halfspace: Hx = -dEy_dz / (1j * omega * MU0) with np.errstate(divide="ignore", invalid="ignore"): Zxy = np.where(np.abs(Hx) > 1e-30, ey_surface / Hx, 0.0 + 0j) return Zxy def _surface_impedance_tm( hy_surface: np.ndarray, # H_y at z=0, shape (nx+1,) hy_below: np.ndarray, # H_y at z=dz[0], shape (nx+1,) sigma_top: np.ndarray, # σ of top-row cells, shape (nx,) dx: np.ndarray, # cell widths, shape (nx,) dz0: float, omega: float, ) -> np.ndarray: """Compute Z_yx = −E_x / H_y at the surface nodes. From Ampere's law: ∂H_y/∂z = σ E_x E_x ≈ (1/σ_top) * ∂H_y/∂z Z_yx = −E_x / H_y (negative by convention) Returns ------- Zyx : ndarray of complex, shape (nx+1,) """ dHy_dz = (hy_below - hy_surface) / dz0 # σ at nodes: average of left/right cells (edge nodes get one side) sigma_nodes = np.empty(len(dx) + 1) sigma_nodes[0] = sigma_top[0] sigma_nodes[1:-1] = 0.5 * (sigma_top[:-1] + sigma_top[1:]) sigma_nodes[-1] = sigma_top[-1] with np.errstate(divide="ignore", invalid="ignore"): Ex = np.where(sigma_nodes > 0.0, dHy_dz / sigma_nodes, 0.0 + 0j) Zyx = np.where(np.abs(hy_surface) > 1e-30, -Ex / hy_surface, 0.0 + 0j) return Zyx def _z_to_rho_phase( Z: np.ndarray, omega: float, ) -> tuple[np.ndarray, np.ndarray]: """Convert impedance to apparent resistivity and phase. Parameters ---------- Z : ndarray of complex omega : float Returns ------- rho_a : ndarray phase : ndarray [degrees] """ rho_a = np.abs(Z) ** 2 / (omega * MU0) phase = np.degrees(np.angle(Z)) return rho_a, phase def _interpolate_to_stations( surface_field: np.ndarray, # shape (nx+1,) — node values x_nodes: np.ndarray, # shape (nx+1,) x_stations: np.ndarray, # shape (n_stations,) ) -> np.ndarray: """Linear interpolation of a surface node field to station positions.""" return np.interp(x_stations, x_nodes, surface_field) # ───────────────────────────────────────────────────────────────────────────── # Main forward class # ─────────────────────────────────────────────────────────────────────────────
[docs] class MT2DForward: """ 2-D magnetotelluric finite-difference forward solver. Solves both TE and TM modes for a list of frequencies on the provided :class:`~pycsamt.forward.grid2d.Grid2D` and returns a :class:`ForwardResponse2D` with apparent resistivity and phase at all station positions. Parameters ---------- freqs : array-like Frequencies [Hz] at which to evaluate the response. grid : Grid2D 2-D resistivity model and station layout. verbose : bool Print per-frequency progress. Examples -------- Uniform halfspace — should recover the 1-D MT response:: >>> import numpy as np >>> from pycsamt.forward.grid2d import Grid2D >>> from pycsamt.forward.em2d import MT2DForward >>> freqs = np.logspace(-2, 3, 10) >>> g = Grid2D.halfspace(rho=100.0, nx=30, nz=20, ... x_max=5_000.0, z_max=3_000.0, ... n_stations=5) >>> fwd = MT2DForward(freqs, g) >>> resp = fwd.run() >>> resp.rho_a_te.shape (10, 5) 2-D model with conductive anomaly:: >>> g2 = Grid2D.with_anomaly(bg_rho=100.0, anomaly_rho=2.0, ... anomaly_bounds=(1500.0, 3500.0, 300.0, 900.0), ... nx=40, nz=25, x_max=6000.0, z_max=3000.0, n_stations=10) >>> resp2 = MT2DForward(freqs, g2).run() """ def __init__( self, freqs: np.ndarray, grid: Grid2D, *, verbose: bool = True, ): self.freqs = np.asarray(freqs, dtype=float) self.grid = grid self.verbose = verbose
[docs] def run(self) -> ForwardResponse2D: """Run the forward solver for all frequencies. Returns ------- ForwardResponse2D """ grid = self.grid freqs = self.freqs nf = len(freqs) ns = grid.n_stations nx1 = grid.nx + 1 x_nodes = grid.x_nodes dz0 = grid.dz[0] zxy_all = np.zeros((nf, ns), dtype=complex) zyx_all = np.zeros((nf, ns), dtype=complex) sigma_top = grid.conductivity[0, :] # top cell row for fi, freq in enumerate(freqs): omega = 2.0 * np.pi * freq if self.verbose: print(f" [MT2D] f={freq:.4g} Hz", end="\r", flush=True) # ── TE mode ────────────────────────────────────────────────── A_te = _assemble_te(grid, omega) b_te = _bc_te(grid, omega) A_te, b_te = _apply_dirichlet(A_te, b_te, grid) ey_flat = spsolve(A_te, b_te) ey_grid = ey_flat.reshape(grid.nz + 1, nx1) # (nz+1, nx+1) ey_surf = ey_grid[0, :] ey_below = ey_grid[1, :] Zxy_nodes = _surface_impedance_te(ey_surf, ey_below, dz0, omega) Zxy_st = _interpolate_to_stations( Zxy_nodes, x_nodes, grid.x_stations ) zxy_all[fi, :] = Zxy_st # ── TM mode ────────────────────────────────────────────────── A_tm = _assemble_tm(grid, omega) b_tm = _bc_tm(grid, omega) A_tm, b_tm = _apply_dirichlet(A_tm, b_tm, grid) hy_flat = spsolve(A_tm, b_tm) hy_grid = hy_flat.reshape(grid.nz + 1, nx1) hy_surf = hy_grid[0, :] hy_below = hy_grid[1, :] Zyx_nodes = _surface_impedance_tm( hy_surf, hy_below, sigma_top, grid.dx, dz0, omega ) Zyx_st = _interpolate_to_stations( Zyx_nodes, x_nodes, grid.x_stations ) zyx_all[fi, :] = Zyx_st if self.verbose: print(f" [MT2D] {nf} frequencies done. ") rho_a_te, phase_te = _z_to_rho_phase( zxy_all, 2.0 * np.pi * freqs[:, None] ) rho_a_tm, phase_tm = _z_to_rho_phase( zyx_all, 2.0 * np.pi * freqs[:, None] ) return ForwardResponse2D( freqs=freqs, stations_x=grid.x_stations.copy(), zxy=zxy_all, zyx=zyx_all, rho_a_te=rho_a_te, phase_te=phase_te, rho_a_tm=rho_a_tm, phase_tm=phase_tm, grid=grid, )
# ───────────────────────────────────────────────────────────────────────────── # Dirichlet enforcement # ───────────────────────────────────────────────────────────────────────────── def _apply_dirichlet( A: sparse.csr_matrix, b: np.ndarray, grid: Grid2D, ) -> tuple[sparse.csr_matrix, np.ndarray]: """Replace boundary rows of A with identity and b with BC values. The assemblers already set boundary rows to identity; this function zeroes out any off-diagonal contributions that leaked through (accumulation from multiple passes) and enforces the RHS. Returns ------- A_mod : sparse.csr_matrix b_mod : ndarray """ nx, nz = grid.nx, grid.nz nx1 = nx + 1 n = nx1 * (nz + 1) # Identify boundary node indices bc_mask = np.zeros(n, dtype=bool) for j in range(nx1): bc_mask[_node_index(0, j, nx1)] = True bc_mask[_node_index(nz, j, nx1)] = True for i in range(nz + 1): bc_mask[_node_index(i, 0, nx1)] = True bc_mask[_node_index(i, nx, nx1)] = True bc_indices = np.where(bc_mask)[0] A_lil = A.tolil() for k in bc_indices: A_lil.rows[k] = [k] A_lil.data[k] = [1.0 + 0j] return A_lil.tocsr(), b