# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Research-only, small-grid 3-D MT finite-difference adapter.
**Status: research-only, not for production use.** Per
``docs/source/development/adr/AI-INVERSION-M6-3D-ADR.md`` (the M6
architecture-decision record),
an in-house 3-D Maxwell solver failed the feasibility gate for
production training: direct sparse solves do not scale to realistic
3-D mesh sizes (empirically confirmed there; extrapolates to tens of
minutes per single solve at production resolution). This module exists
for small-grid research and unit validation only. Production 3-D work
should use :mod:`pycsamt.forward.maxwell.modem3d` (a trusted external
backend) once available.
Physics
-------
:class:`MT3DAdapter` discretizes the frequency-domain equation
.. math::
\\nabla \\times \\nabla \\times E + i \\omega \\mu_0 \\sigma E = 0
on a Cartesian Yee (staggered edge/face) grid, with E on cell edges
and H on cell faces. Cell widths may be non-uniform per axis (a
padded/graded tensor mesh, like
:mod:`pycsamt.forward.maxwell.mt2d` already uses) — see "Non-uniform
mesh support" below. The discrete curl operators (:func:`_curl_e2h`,
:func:`_curl_h2e`) are built and verified independently of the
physics: applying :func:`_curl_e2h` to a uniform field gives exactly
zero, to a linear field gives the exact analytic curl, and
``face_divergence @ curl_e2h`` is the exact zero matrix (the
topological identity ``div(curl(E)) = 0``), on both uniform and
non-uniform grids — see ``pycsamt/forward/tests/test_maxwell_mt3d.py``.
The overall sign convention (``H = -curl(E) / (i omega mu0)``,
assembled as
``(curl_h2e @ curl_e2h + i*omega*mu0*diag(sigma_edge)) @ E = 0``) was
anchored empirically against the analytic half-space limit, not
assumed from a textbook derivation, because this codebase's
depth-increases-downward coordinate convention does not necessarily
share a textbook right-handed frame's sign. Two independent horizontal
polarizations are solved per frequency (boundary ``Ex`` driven and
boundary ``Ey`` driven); receivers combine both to recover the full
impedance tensor.
Non-uniform mesh support
-------------------------
``supports_nonuniform_mesh=True``. Earlier versions of this module
supported uniform cell spacing only, and the *documented* consequence
was that :func:`~pycsamt.forward.maxwell.benchmarks.layered_earth_benchmark`
failed by 30-45% even after mesh refinement. Investigating that
failure (see git history / the AI-inversion project memory for the
session this was diagnosed in) found it was **not** a physics defect
in the boundary-condition approximation itself: the per-column
decay in :func:`_column_decay` is exact for a laterally uniform
half-space regardless of domain size (that is why the half-space
benchmark always passed), and for a genuinely layered earth its error
stays negligible wherever the field has already decayed close to zero
by the domain edge — the same "boundary far enough away" argument
:mod:`pycsamt.forward.maxwell.mt2d` documents for its own boundary
treatment. The real problem was that a *uniform*-only mesh cannot
reach several skin depths of lateral/vertical extent **and** resolve
a few-hundred-metre layer interface within the ``maximum_cells``
budget at the same time, because 3-D cell count scales as the cube of
resolution — refining resolution while keeping the same small domain
does not help, which is exactly the "refining the mesh does not
reduce them" symptom that was previously (incorrectly) attributed to
the boundary approximation. Generalizing the curl operators
(:func:`_curl_e2h`, :func:`_curl_h2e`) and edge-conductivity averaging
(:func:`_sigma_on_edges`) to per-axis, non-uniform cell widths lets a
padded mesh (fine cells near the receiver/structure, geometrically
growing cells outward) reach the same physical extent at a fraction
of the cell cost, matching what :mod:`pycsamt.forward.maxwell.mt2d`
already does. :func:`_curl_h2e` in particular must divide by the
*dual*-grid spacing (the average of the two neighbouring primal cell
widths) rather than either cell's own width — the two coincide on a
uniform mesh, which is why the uniform-only implementation could get
away without this distinction.
Deliberate scope reductions (all enforced, not just documented)
-----------------------------------------------------------------
* **Small grids only.** ``maximum_cells`` (default 6,000, overridable)
rejects larger problems outright rather than silently taking an
impractical amount of time, operationalizing the ADR's feasibility
finding directly in code. A padded, non-uniform mesh (see above)
substantially relaxes what this budget can achieve, but the ADR's
underlying conclusion — that a direct solve does not scale to
realistic production 3-D mesh sizes — is unaffected: this module
remains research/small-grid only.
* **Direct sparse solve only** (:func:`scipy.sparse.linalg.spsolve`);
no iterative solver or preconditioner, per the ADR's conclusion that
a real one is a research problem in itself.
* **Surface receivers only**, within the mesh's horizontal extent,
checked in :meth:`MT3DAdapter.assess`.
* **Boundary conditions are a per-column exponential approximation**
(:func:`_column_decay`, the same level of rigor as
:mod:`pycsamt.forward.em2d`'s own ``_ey_1d_profile``): each boundary
edge uses the exponential decay implied by its nearest cell column's
*single local layer*, not a full multi-layer recursion. As described
above, this is exact for a half-space and negligible-error elsewhere
once the domain is wide/deep enough, not a source of the previously
measured layered-earth bias. ``Ez`` is fixed at zero on every
boundary (plane-wave incidence has no driven vertical field far from
the domain interior).
* Isotropic conductivity, vacuum permeability, ``exp(+iwt)`` only, no
inactive-cell/topography support — same restrictions as
:class:`~pycsamt.forward.maxwell.mt2d.MT2DAdapter`.
Measured accuracy
------------------
On a padded, non-uniform 16x16x16 (4,096-cell) research-scale grid —
fine (150 m / 120 m) core cells near the receiver and shallow
interfaces, geometrically padded out to ~30 km laterally and ~8 km
vertically, comfortably beyond the deepest layer's skin depth at the
lowest benchmark frequency — this adapter passes the default
:class:`~pycsamt.forward.maxwell.benchmarks.BenchmarkThresholds` for
**both** :func:`~pycsamt.forward.maxwell.benchmarks.half_space_benchmark`
(~1.9% normalized RMS, ~1.3 degree phase error) **and**
:func:`~pycsamt.forward.maxwell.benchmarks.layered_earth_benchmark`
(~3.5% normalized RMS, ~4.5% amplitude error, <1 degree phase error);
see ``pycsamt/forward/tests/test_maxwell_mt3d.py``.
:data:`_VERIFIED_BENCHMARKS` now includes both.
"""
from __future__ import annotations
import time
import numpy as np
from scipy import sparse
from scipy.sparse.linalg import spsolve
from .adapters import AdapterPolicy, BaseMaxwellAdapter
from .backends import BackendCapabilities, CompatibilityReport
from .contracts import ForwardResult, MaxwellProblem, SolverDiagnostics
__all__ = ["MT3DAdapter"]
_MU0 = 4.0e-7 * np.pi
_SURFACE_TOLERANCE_M = 1e-6
_PERMEABILITY_RTOL = 1e-9
_DEFAULT_MAX_CELLS = 6_000
# Both benchmarks genuinely pass pycsamt/forward/tests/test_maxwell_mt3d.py's
# real benchmark run, on the padded/non-uniform calibrated mesh there
# (see "Measured accuracy" above) -- not silently rounded up.
_VERIFIED_BENCHMARKS: tuple[str, ...] = ("half-space", "layered-earth")
def _edge_shapes(nx: int, ny: int, nz: int) -> dict[str, tuple[int, ...]]:
return {
"ex": (nx, ny + 1, nz + 1),
"ey": (nx + 1, ny, nz + 1),
"ez": (nx + 1, ny + 1, nz),
"hx": (nx + 1, ny, nz),
"hy": (nx, ny + 1, nz),
"hz": (nx, ny, nz + 1),
}
def _dual_widths(h: np.ndarray) -> np.ndarray:
"""Return node-centred dual-grid spacings, length ``len(h) + 1``.
Entry ``i`` (for ``1 <= i <= len(h) - 1``) is the distance between
the centres of primal cells ``i - 1`` and ``i``, i.e.
``0.5 * (h[i - 1] + h[i])``. Entries 0 and ``len(h)`` are unused
(those nodes are mesh boundaries, handled separately) and left as
zero so an accidental use fails loudly (division by zero) rather
than silently.
"""
n = len(h)
dual = np.zeros(n + 1)
dual[1:n] = 0.5 * (h[:-1] + h[1:])
return dual
def _curl_e2h(
nx: int,
ny: int,
nz: int,
hx: np.ndarray,
hy: np.ndarray,
hz: np.ndarray,
) -> sparse.csr_matrix:
"""Discrete curl E -> H on a (possibly non-uniform) tensor grid.
``hx``/``hy``/``hz`` are the primal cell widths along each axis
(length ``nx``/``ny``/``nz``). Each finite difference here spans
exactly one primal cell, so it is divided by that cell's own
width at the relevant index -- no averaging needed.
"""
s = _edge_shapes(nx, ny, nz)
n_ex, n_ey, n_ez = (int(np.prod(s[k])) for k in ("ex", "ey", "ez"))
n_hx, n_hy, n_hz = (int(np.prod(s[k])) for k in ("hx", "hy", "hz"))
off_ey, off_ez = n_ex, n_ex + n_ey
off_hy, off_hz = n_hx, n_hx + n_hy
rows: list[int] = []
cols: list[int] = []
vals: list[complex] = []
def add(r: int, c: int, v: float) -> None:
rows.append(r)
cols.append(c)
vals.append(v)
def ex_idx(i: int, j: int, k: int) -> int:
return int(np.ravel_multi_index((i, j, k), s["ex"]))
def ey_idx(i: int, j: int, k: int) -> int:
return int(np.ravel_multi_index((i, j, k), s["ey"]))
def ez_idx(i: int, j: int, k: int) -> int:
return int(np.ravel_multi_index((i, j, k), s["ez"]))
for i in range(nx + 1):
for j in range(ny):
for k in range(nz):
r = int(np.ravel_multi_index((i, j, k), s["hx"]))
add(r, off_ez + ez_idx(i, j + 1, k), 1.0 / hy[j])
add(r, off_ez + ez_idx(i, j, k), -1.0 / hy[j])
add(r, off_ey + ey_idx(i, j, k + 1), -1.0 / hz[k])
add(r, off_ey + ey_idx(i, j, k), 1.0 / hz[k])
for i in range(nx):
for j in range(ny + 1):
for k in range(nz):
r = off_hy + int(np.ravel_multi_index((i, j, k), s["hy"]))
add(r, ex_idx(i, j, k + 1), 1.0 / hz[k])
add(r, ex_idx(i, j, k), -1.0 / hz[k])
add(r, off_ez + ez_idx(i + 1, j, k), -1.0 / hx[i])
add(r, off_ez + ez_idx(i, j, k), 1.0 / hx[i])
for i in range(nx):
for j in range(ny):
for k in range(nz + 1):
r = off_hz + int(np.ravel_multi_index((i, j, k), s["hz"]))
add(r, off_ey + ey_idx(i + 1, j, k), 1.0 / hx[i])
add(r, off_ey + ey_idx(i, j, k), -1.0 / hx[i])
add(r, ex_idx(i, j + 1, k), -1.0 / hy[j])
add(r, ex_idx(i, j, k), 1.0 / hy[j])
n_e = n_ex + n_ey + n_ez
n_h = n_hx + n_hy + n_hz
return sparse.csr_matrix((vals, (rows, cols)), shape=(n_h, n_e))
def _curl_h2e(
nx: int,
ny: int,
nz: int,
hx: np.ndarray,
hy: np.ndarray,
hz: np.ndarray,
) -> sparse.csr_matrix:
"""Discrete curl H -> E on a (possibly non-uniform) tensor grid.
Each finite difference here connects two H values that straddle
an E edge from *opposite* primal cells (e.g. ``Hz`` at cell
centres ``j - 1`` and ``j``), so the correct denominator is the
*dual*-grid spacing between those two cell centres --
``0.5 * (h[j - 1] + h[j])`` -- not either cell's own width. Using
the primal width here (as a uniform-only implementation legitimately
can, since dual and primal spacing coincide when all cells are the
same size) silently double-counts curvature on a padded mesh.
"""
s = _edge_shapes(nx, ny, nz)
n_ex, n_ey, n_ez = (int(np.prod(s[k])) for k in ("ex", "ey", "ez"))
n_hx, n_hy, n_hz = (int(np.prod(s[k])) for k in ("hx", "hy", "hz"))
off_ey, off_ez = n_ex, n_ex + n_ey
off_hy, off_hz = n_hx, n_hx + n_hy
dual_x, dual_y, dual_z = (
_dual_widths(hx),
_dual_widths(hy),
_dual_widths(hz),
)
rows: list[int] = []
cols: list[int] = []
vals: list[complex] = []
def add(r: int, c: int, v: float) -> None:
rows.append(r)
cols.append(c)
vals.append(v)
def hx_idx(i: int, j: int, k: int) -> int:
return int(np.ravel_multi_index((i, j, k), s["hx"]))
def hy_idx(i: int, j: int, k: int) -> int:
return int(np.ravel_multi_index((i, j, k), s["hy"]))
def hz_idx(i: int, j: int, k: int) -> int:
return int(np.ravel_multi_index((i, j, k), s["hz"]))
for i in range(nx):
for j in range(1, ny):
for k in range(1, nz):
r = int(np.ravel_multi_index((i, j, k), s["ex"]))
add(r, off_hz + hz_idx(i, j, k), 1.0 / dual_y[j])
add(r, off_hz + hz_idx(i, j - 1, k), -1.0 / dual_y[j])
add(r, off_hy + hy_idx(i, j, k), -1.0 / dual_z[k])
add(r, off_hy + hy_idx(i, j, k - 1), 1.0 / dual_z[k])
for i in range(1, nx):
for j in range(ny):
for k in range(1, nz):
r = off_ey + int(np.ravel_multi_index((i, j, k), s["ey"]))
add(r, hx_idx(i, j, k), 1.0 / dual_z[k])
add(r, hx_idx(i, j, k - 1), -1.0 / dual_z[k])
add(r, off_hz + hz_idx(i, j, k), -1.0 / dual_x[i])
add(r, off_hz + hz_idx(i - 1, j, k), 1.0 / dual_x[i])
for i in range(1, nx):
for j in range(1, ny):
for k in range(nz):
r = off_ez + int(np.ravel_multi_index((i, j, k), s["ez"]))
add(r, off_hy + hy_idx(i, j, k), 1.0 / dual_x[i])
add(r, off_hy + hy_idx(i - 1, j, k), -1.0 / dual_x[i])
add(r, hx_idx(i, j, k), -1.0 / dual_y[j])
add(r, hx_idx(i, j - 1, k), 1.0 / dual_y[j])
n_e = n_ex + n_ey + n_ez
n_h = n_hx + n_hy + n_hz
return sparse.csr_matrix((vals, (rows, cols)), shape=(n_e, n_h))
def _boundary_masks(
nx: int, ny: int, nz: int
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
s = _edge_shapes(nx, ny, nz)
ex_bnd = np.zeros(s["ex"], dtype=bool)
ex_bnd[:, 0, :] = ex_bnd[:, -1, :] = True
ex_bnd[:, :, 0] = ex_bnd[:, :, -1] = True
ey_bnd = np.zeros(s["ey"], dtype=bool)
ey_bnd[0, :, :] = ey_bnd[-1, :, :] = True
ey_bnd[:, :, 0] = ey_bnd[:, :, -1] = True
ez_bnd = np.zeros(s["ez"], dtype=bool)
ez_bnd[0, :, :] = ez_bnd[-1, :, :] = True
ez_bnd[:, 0, :] = ez_bnd[:, -1, :] = True
return ex_bnd, ey_bnd, ez_bnd
def _sigma_on_edges(
sigma_xyz: np.ndarray,
hx: np.ndarray,
hy: np.ndarray,
hz: np.ndarray,
nx: int,
ny: int,
nz: int,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Average cell conductivity onto edges, weighted by transverse area.
Each interior edge is surrounded by 4 cells in the plane
perpendicular to it; a plain 1/4-1/4-1/4-1/4 average (correct only
when all 4 cells have equal cross-section) silently mis-weights a
padded/non-uniform mesh in favour of whichever neighbour happens to
come first in memory. Weighting by each cell's transverse
cross-sectional area is the same convention already used by
:func:`pycsamt.forward.em2d._assemble_te`'s nodal average.
"""
sx = np.zeros((nx, ny + 1, nz + 1))
wy_l, wy_r = hy[: ny - 1, None], hy[1:ny, None]
wz_l, wz_r = hz[None, : nz - 1], hz[None, 1:nz]
w_tl, w_tr = wy_l * wz_l, wy_r * wz_l
w_bl, w_br = wy_l * wz_r, wy_r * wz_r
w_sum = w_tl + w_tr + w_bl + w_br
sx[:, 1:ny, 1:nz] = (
w_tl * sigma_xyz[:, 0 : ny - 1, 0 : nz - 1]
+ w_tr * sigma_xyz[:, 1:ny, 0 : nz - 1]
+ w_bl * sigma_xyz[:, 0 : ny - 1, 1:nz]
+ w_br * sigma_xyz[:, 1:ny, 1:nz]
) / w_sum
sy = np.zeros((nx + 1, ny, nz + 1))
wx_l, wx_r = hx[: nx - 1, None, None], hx[1:nx, None, None]
wz_l, wz_r = hz[None, None, : nz - 1], hz[None, None, 1:nz]
w_tl, w_tr = wx_l * wz_l, wx_r * wz_l
w_bl, w_br = wx_l * wz_r, wx_r * wz_r
w_sum = w_tl + w_tr + w_bl + w_br
sy[1:nx, :, 1:nz] = (
w_tl * sigma_xyz[0 : nx - 1, :, 0 : nz - 1]
+ w_tr * sigma_xyz[1:nx, :, 0 : nz - 1]
+ w_bl * sigma_xyz[0 : nx - 1, :, 1:nz]
+ w_br * sigma_xyz[1:nx, :, 1:nz]
) / w_sum
sz = np.zeros((nx + 1, ny + 1, nz))
wx_l, wx_r = hx[: nx - 1, None, None], hx[1:nx, None, None]
wy_l, wy_r = hy[None, : ny - 1, None], hy[None, 1:ny, None]
w_tl, w_tr = wx_l * wy_l, wx_r * wy_l
w_bl, w_br = wx_l * wy_r, wx_r * wy_r
w_sum = w_tl + w_tr + w_bl + w_br
sz[1:nx, 1:ny, :] = (
w_tl * sigma_xyz[0 : nx - 1, 0 : ny - 1, :]
+ w_tr * sigma_xyz[1:nx, 0 : ny - 1, :]
+ w_bl * sigma_xyz[0 : nx - 1, 1:ny, :]
+ w_br * sigma_xyz[1:nx, 1:ny, :]
) / w_sum
return sx, sy, sz
def _column_decay(
sigma_profile: np.ndarray,
dz_profile: np.ndarray,
z_query: np.ndarray,
omega: float,
) -> np.ndarray:
wavenumber = np.sqrt(1j * omega * _MU0 * sigma_profile)
top = np.concatenate([[0.0], np.cumsum(dz_profile)])
layer = np.clip(
np.searchsorted(top, z_query, side="right") - 1,
0,
len(sigma_profile) - 1,
)
return np.exp(-wavenumber[layer] * z_query)
def _nearest_column(
sigma_xyz: np.ndarray, i: int, j: int, nx: int, ny: int
) -> np.ndarray:
return sigma_xyz[min(max(i, 0), nx - 1), min(max(j, 0), ny - 1), :]
def _boundary_fields(
driven: str,
nx: int,
ny: int,
nz: int,
hz: np.ndarray,
sigma_xyz: np.ndarray,
omega: float,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Dirichlet boundary values for one polarization ("x" or "y")."""
s = _edge_shapes(nx, ny, nz)
dz_profile = hz[:-1] if nz > 1 else np.zeros(0)
z_ex = np.concatenate([[0.0], np.cumsum(hz)])
bc_ex = np.zeros(s["ex"], dtype=complex)
bc_ey = np.zeros(s["ey"], dtype=complex)
bc_ez = np.zeros(s["ez"], dtype=complex) # Ez is always 0 on boundary
target = bc_ex if driven == "x" else bc_ey
n_i, n_j = target.shape[0], target.shape[1]
for i in range(n_i):
for j in range(n_j):
column = _nearest_column(sigma_xyz, i, j, nx, ny)
target[i, j, :] = _column_decay(column, dz_profile, z_ex, omega)
return bc_ex, bc_ey, bc_ez
def _assemble(
nx: int,
ny: int,
nz: int,
hx: np.ndarray,
hy: np.ndarray,
hz: np.ndarray,
sigma_xyz: np.ndarray,
omega: float,
) -> tuple[sparse.csr_matrix, sparse.csr_matrix, np.ndarray]:
c1 = _curl_e2h(nx, ny, nz, hx, hy, hz)
c2 = _curl_h2e(nx, ny, nz, hx, hy, hz)
sx, sy, sz = _sigma_on_edges(sigma_xyz, hx, hy, hz, nx, ny, nz)
sigma_edge = np.concatenate([sx.ravel(), sy.ravel(), sz.ravel()])
a = (c2 @ c1) + sparse.diags(1j * omega * _MU0 * sigma_edge)
ex_bnd, ey_bnd, ez_bnd = _boundary_masks(nx, ny, nz)
boundary = np.concatenate([ex_bnd.ravel(), ey_bnd.ravel(), ez_bnd.ravel()])
idx = np.flatnonzero(boundary)
a = a.tocsr()
for r in idx:
a.data[a.indptr[r] : a.indptr[r + 1]] = 0.0
a = a.tolil()
for r in idx:
a[r, r] = 1.0
a = a.tocsr()
a.eliminate_zeros()
return a, c1, boundary
def _solve_polarization(
a: sparse.csr_matrix,
c1: sparse.csr_matrix,
boundary: np.ndarray,
bc_ex: np.ndarray,
bc_ey: np.ndarray,
bc_ez: np.ndarray,
omega: float,
) -> tuple[np.ndarray, float]:
b = np.zeros(a.shape[0], dtype=complex)
bc_flat = np.concatenate([bc_ex.ravel(), bc_ey.ravel(), bc_ez.ravel()])
idx = np.flatnonzero(boundary)
b[idx] = bc_flat[idx]
x = spsolve(a.tocsc(), b)
denom = np.linalg.norm(b)
residual = float(
np.linalg.norm(a @ x - b) / (denom if denom > 0 else 1e-300)
)
h = -(c1 @ x) / (1j * omega * _MU0)
return np.concatenate([x, h]), residual
def _bilinear(
values: np.ndarray,
x_centers: np.ndarray,
y_centers: np.ndarray,
x_query: float,
y_query: float,
) -> complex:
ix = np.clip(
np.searchsorted(x_centers, x_query) - 1, 0, len(x_centers) - 2
)
iy = np.clip(
np.searchsorted(y_centers, y_query) - 1, 0, len(y_centers) - 2
)
x0, x1 = x_centers[ix], x_centers[ix + 1]
y0, y1 = y_centers[iy], y_centers[iy + 1]
tx = 0.0 if x1 == x0 else (x_query - x0) / (x1 - x0)
ty = 0.0 if y1 == y0 else (y_query - y0) / (y1 - y0)
v00, v01 = values[ix, iy], values[ix, iy + 1]
v10, v11 = values[ix + 1, iy], values[ix + 1, iy + 1]
return (
v00 * (1 - tx) * (1 - ty)
+ v10 * tx * (1 - ty)
+ v01 * (1 - tx) * ty
+ v11 * tx * ty
)
_COMPONENT_INDEX = {"zxx": (0, 0), "zxy": (0, 1), "zyx": (1, 0), "zyy": (1, 1)}
[docs]
class MT3DAdapter(BaseMaxwellAdapter):
"""Research-only 3-D MT adapter (see module docstring for scope).
Parameters
----------
version : str, default="1.0-research"
Adapter version reported in every
:class:`~pycsamt.forward.maxwell.contracts.ForwardResult`.
policy : AdapterPolicy or None, optional
Solver-independent result acceptance policy.
max_cells : int, default=6000
Safety ceiling on total mesh cells
(:attr:`~pycsamt.forward.maxwell.backends.BackendCapabilities.maximum_cells`).
A direct sparse solve becomes impractically slow well before
typical production 3-D mesh sizes; see
``docs/source/development/adr/AI-INVERSION-M6-3D-ADR.md``. Raise this
only if you have
confirmed the resulting solve time is acceptable for your use.
Examples
--------
>>> import numpy as np
>>> from pycsamt.forward.maxwell import (
... MaxwellMesh,
... MaxwellProblem,
... ReceiverSet,
... )
>>> mesh = MaxwellMesh(
... np.linspace(0, 4000, 9),
... np.linspace(0, 3000, 11),
... np.linspace(0, 4000, 9),
... )
>>> problem = MaxwellProblem(
... mesh,
... np.full(mesh.shape, 1.0 / 100.0),
... [1.0],
... ReceiverSet([[2000.0, 2000.0, 0.0]], ["S00"]),
... ("zxy", "zyx"),
... )
>>> result = MT3DAdapter().solve(problem)
>>> result.shape
(1, 1, 2)
"""
def __init__(
self,
*,
version: str = "1.0-research",
policy: AdapterPolicy | None = None,
max_cells: int = _DEFAULT_MAX_CELLS,
) -> None:
capabilities = BackendCapabilities(
name="mt3d",
version=version,
dimensions=(3,),
components=("zxx", "zxy", "zyx", "zyy"),
time_conventions=("exp(+iwt)",),
supports_nonuniform_mesh=True,
supports_inactive_cells=False,
supports_topography=False,
supports_anisotropy=False,
maximum_cells=max_cells,
verified_benchmarks=_VERIFIED_BENCHMARKS,
)
super().__init__(capabilities, policy)
[docs]
def assess(self, problem: MaxwellProblem) -> CompatibilityReport:
"""Assess a problem, adding this solver's research-only checks.
Parameters
----------
problem : MaxwellProblem
Candidate simulation problem.
Returns
-------
CompatibilityReport
The generic capability report from
:meth:`~pycsamt.forward.maxwell.adapters.BaseMaxwellAdapter.assess`,
extended with surface-receiver, horizontal-bounds, and
vacuum-permeability checks.
Examples
--------
See :class:`MT3DAdapter` for a complete solve example.
"""
base = super().assess(problem)
errors = list(base.errors)
# The coordinate/permeability checks below assume a 3-D
# receiver/mesh shape; a wrong-dimension problem is already
# rejected by the base capability report, so skip them rather
# than index into a 2-D receiver array.
if problem.mesh.dimension == 3:
coordinates = problem.receivers.coordinates_m
depth = coordinates[:, 2]
if np.any(np.abs(depth) > _SURFACE_TOLERANCE_M):
errors.append(
"mt3d only evaluates receivers at the surface (z=0)"
)
x, y = coordinates[:, 0], coordinates[:, 1]
x_lo, x_hi = problem.mesh.x_edges_m[0], problem.mesh.x_edges_m[-1]
y_lo, y_hi = problem.mesh.y_edges_m[0], problem.mesh.y_edges_m[-1]
if np.any((x < x_lo) | (x > x_hi) | (y < y_lo) | (y > y_hi)):
errors.append(
"receiver x/y coordinates must lie within the mesh "
f"[{x_lo:g}, {x_hi:g}] x [{y_lo:g}, {y_hi:g}] m"
)
if not np.isclose(
problem.magnetic_permeability_h_m,
_MU0,
rtol=_PERMEABILITY_RTOL,
):
errors.append(
f"mt3d assumes vacuum magnetic permeability ({_MU0:.6g} H/m)"
)
if len(errors) == len(base.errors):
return base
return CompatibilityReport(
self.capabilities.name, False, tuple(errors), base.warnings
)
def _solve_backend(self, problem: MaxwellProblem) -> ForwardResult:
mesh = problem.mesh
nz, ny, nx = mesh.shape
widths = mesh.cell_widths_m
hx, hy, hz = widths["x"], widths["y"], widths["z"]
sigma_xyz = np.transpose(problem.conductivity_s_m, (2, 1, 0))
x_centers = mesh.cell_centres_m["x"]
y_centers = mesh.cell_centres_m["y"]
x_nodes = mesh.x_edges_m
y_nodes = mesh.y_edges_m
n_freq = len(problem.frequencies_hz)
n_station = problem.receivers.count
n_component = len(problem.components)
impedance = np.empty((n_station, n_freq, n_component), dtype=complex)
converged = np.empty((n_freq, 2), dtype=bool)
residual = np.empty((n_freq, 2))
start = time.monotonic()
n_ex = nx * (ny + 1) * (nz + 1)
n_ey = (nx + 1) * ny * (nz + 1)
n_hx = (nx + 1) * ny * nz
n_hy = nx * (ny + 1) * nz
for fi, frequency in enumerate(problem.frequencies_hz):
omega = 2.0 * np.pi * float(frequency)
a, c1, boundary = _assemble(
nx, ny, nz, hx, hy, hz, sigma_xyz, omega
)
hx_by_pol = []
hy_by_pol = []
for pi, driven in enumerate(("x", "y")):
bc_ex, bc_ey, bc_ez = _boundary_fields(
driven, nx, ny, nz, hz, sigma_xyz, omega
)
solution, res = _solve_polarization(
a, c1, boundary, bc_ex, bc_ey, bc_ez, omega
)
residual[fi, pi] = res
converged[fi, pi] = np.isfinite(res)
h = solution[n_ex + n_ey + ((nx + 1) * (ny + 1) * nz) :]
hx_field = h[:n_hx].reshape((nx + 1, ny, nz))[:, :, 0]
hy_field = h[n_hx : n_hx + n_hy].reshape((nx, ny + 1, nz))[
:, :, 0
]
hx_by_pol.append(hx_field)
hy_by_pol.append(hy_field)
# E at the surface is exactly the prescribed incident field
# (1 for the driven polarization's own axis, 0 otherwise),
# since the top boundary is uniform across x, y by
# construction; no interpolation is needed for E.
e_mat = np.eye(2, dtype=complex)
for si in range(n_station):
x_q, y_q = (
problem.receivers.coordinates_m[si, 0],
problem.receivers.coordinates_m[si, 1],
)
h_mat = np.empty((2, 2), dtype=complex)
for pi in range(2):
# Hx lives at (x-node, y-center); Hy at (x-center,
# y-node) -- the two Yee dual-grid H locations.
h_mat[0, pi] = _bilinear(
hx_by_pol[pi], x_nodes, y_centers, x_q, y_q
)
h_mat[1, pi] = _bilinear(
hy_by_pol[pi], x_centers, y_nodes, x_q, y_q
)
z_full = e_mat @ np.linalg.inv(h_mat)
for ci, name in enumerate(problem.components):
row, col = _COMPONENT_INDEX[name]
impedance[si, fi, ci] = z_full[row, col]
runtime_s = time.monotonic() - start
diagnostics = SolverDiagnostics(
converged,
np.zeros(residual.shape, dtype=int),
np.where(np.isfinite(residual), residual, 0.0),
runtime_s,
)
return ForwardResult(
problem.problem_hash,
problem.frequencies_hz,
problem.receivers.names,
problem.components,
impedance,
None,
self.capabilities.name,
self.capabilities.version,
diagnostics,
)