Source code for pycsamt.forward.maxwell.mt2d

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Validated adapter for the in-repo 2-D MT finite-difference solver.

:class:`MT2DAdapter` bridges the solver-neutral
:class:`~pycsamt.forward.maxwell.contracts.MaxwellProblem` /
:class:`~pycsamt.forward.maxwell.contracts.ForwardResult` contract to the
existing, tested finite-difference implementation in
:mod:`pycsamt.forward.em2d`
(:class:`~pycsamt.forward.em2d.MT2DForward`) over a
:class:`~pycsamt.forward.grid2d.Grid2D` model. No new physics is
introduced here: this module only translates problem/result shapes and
enforces the assumptions the wrapped solver actually makes.

Known solver-specific limits, declared through
:class:`~pycsamt.forward.maxwell.backends.BackendCapabilities` or checked
in :meth:`MT2DAdapter.assess`:

* only the ``zxy`` (TE) and ``zyx`` (TM) impedance components are
  produced, matching the physical 2-D TE/TM decomposition;
* every receiver must sit exactly at the surface (``z = 0``); the wrapped
  solver has no borehole or buried-receiver formulation;
* the earth model has no inactive (air) cells: the solver always treats
  the full mesh as earth, so a problem must use
  ``mark_air_inactive=False`` when built from
  :meth:`~pycsamt.forward.maxwell.mesh.SolverMeshModel.to_problem`;
* magnetic permeability is fixed at the vacuum value
  ``4*pi*1e-7`` H/m, matching ``pycsamt.forward.em2d.MU0``;
* only the ``exp(+iwt)`` phasor convention is supported, matching the
  sign convention hardcoded in :mod:`pycsamt.forward.em2d`.

A numerically degenerate solve (near-zero surface magnetic field at a
station) makes :mod:`pycsamt.forward.em2d` fall back to ``0 + 0j``
rather than raise or return ``NaN``; such an entry is currently
reported as valid because
:attr:`~pycsamt.forward.maxwell.contracts.ForwardResult.valid` defaults
to finiteness. This is a known, narrow limitation inherited from the
wrapped solver, not something this adapter can detect from its output
alone.

Scipy (a required pycsamt dependency, not optional) is imported only
through :mod:`pycsamt.forward.em2d`; this module performs no lazy or
optional-dependency handling of its own.
"""

from __future__ import annotations

import time

import numpy as np

from ..em2d import ForwardResponse2D, MT2DForward
from ..grid2d import Grid2D
from .adapters import AdapterPolicy, BaseMaxwellAdapter
from .backends import (
    BackendCapabilities,
    BackendRegistration,
    CompatibilityReport,
)
from .backends import register_backend as _register_backend
from .contracts import ForwardResult, MaxwellProblem, SolverDiagnostics

__all__ = [
    "MT2DAdapter",
    "register_mt2d_backend",
]

_MU0 = 4.0e-7 * np.pi
_SURFACE_TOLERANCE_M = 1e-6
_PERMEABILITY_RTOL = 1e-9

# Benchmarks this adapter is currently known to pass within the default
# pycsamt.forward.maxwell.benchmarks.BenchmarkThresholds(). Verified by
# pycsamt/forward/tests/test_maxwell_mt2d.py; update together with that
# test file, never in isolation.
_VERIFIED_BENCHMARKS = ("half-space", "layered-earth")


[docs] class MT2DAdapter(BaseMaxwellAdapter): """Validated 2-D MT adapter over the in-repo finite-difference solver. Parameters ---------- version : str, default="1.0" Adapter version reported in every :class:`~pycsamt.forward.maxwell.contracts.ForwardResult`. Bump this when the translation logic in this module changes in a way that could alter numerical output. policy : AdapterPolicy or None, optional Solver-independent result acceptance policy. Defaults to :class:`~pycsamt.forward.maxwell.adapters.AdapterPolicy`. verbose : bool, default=False Forwarded to :class:`~pycsamt.forward.em2d.MT2DForward`; prints per-frequency progress when true. Examples -------- >>> import numpy as np >>> from pycsamt.forward.maxwell import ( ... MaxwellMesh, ... MaxwellProblem, ... ReceiverSet, ... ) >>> mesh = MaxwellMesh( ... np.linspace(0, 10_000, 41), np.linspace(0, 5_000, 31) ... ) >>> problem = MaxwellProblem( ... mesh, ... np.full(mesh.shape, 1.0 / 100.0), ... [10.0, 1.0], ... ReceiverSet([[5_000.0, 0.0]], ["S00"]), ... ("zxy", "zyx"), ... ) >>> adapter = MT2DAdapter(verbose=False) >>> result = adapter.solve(problem) >>> result.shape (1, 2, 2) """ def __init__( self, *, version: str = "1.0", policy: AdapterPolicy | None = None, verbose: bool = False, ) -> None: capabilities = BackendCapabilities( name="mt2d", version=version, dimensions=(2,), components=("zxy", "zyx"), time_conventions=("exp(+iwt)",), supports_nonuniform_mesh=True, supports_inactive_cells=False, supports_topography=False, supports_anisotropy=False, verified_benchmarks=_VERIFIED_BENCHMARKS, ) super().__init__(capabilities, policy) self._verbose = bool(verbose)
[docs] def assess(self, problem: MaxwellProblem) -> CompatibilityReport: """Assess a problem, adding this solver's surface-only checks. Parameters ---------- problem : MaxwellProblem Candidate simulation problem. Returns ------- CompatibilityReport The generic capability report from :meth:`~pycsamt.forward.maxwell.adapters.BaseMaxwellAdapter.assess`, extended with two solver-specific checks: every receiver must be at the surface, and the permeability must be the vacuum value the wrapped solver hardcodes. Examples -------- See :class:`MT2DAdapter` for a complete solve example; a problem with a buried receiver is rejected before the solver runs. """ base = super().assess(problem) errors = list(base.errors) depth = problem.receivers.coordinates_m[:, 1] if np.any(np.abs(depth) > _SURFACE_TOLERANCE_M): errors.append("mt2d only evaluates receivers at the surface (z=0)") x = problem.receivers.coordinates_m[:, 0] x_lo, x_hi = problem.mesh.x_edges_m[0], problem.mesh.x_edges_m[-1] if np.any((x < x_lo) | (x > x_hi)): errors.append( "receiver x coordinates must lie within the mesh " f"[{x_lo:g}, {x_hi:g}] m" ) if not np.isclose( problem.magnetic_permeability_h_m, _MU0, rtol=_PERMEABILITY_RTOL, ): errors.append( f"mt2d 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: grid = _grid_from_problem(problem) solver = MT2DForward( problem.frequencies_hz, grid, verbose=self._verbose ) start = time.monotonic() response = solver.run() runtime_s = time.monotonic() - start return _result_from_response( problem, response, self.capabilities, runtime_s )
[docs] def register_mt2d_backend(*, replace: bool = False) -> None: """Register :class:`MT2DAdapter` in the process-wide backend registry. Parameters ---------- replace : bool, default=False Explicitly replace an existing ``"mt2d"`` registration. Examples -------- >>> from pycsamt.forward.maxwell import create_backend, list_backends >>> register_mt2d_backend(replace=True) >>> "mt2d" in list_backends() True >>> create_backend("mt2d").capabilities.name 'mt2d' """ capabilities = BackendCapabilities( name="mt2d", version="1.0", dimensions=(2,), components=("zxy", "zyx"), time_conventions=("exp(+iwt)",), supports_nonuniform_mesh=True, supports_inactive_cells=False, supports_topography=False, supports_anisotropy=False, verified_benchmarks=_VERIFIED_BENCHMARKS, ) _register_backend( BackendRegistration(capabilities, MT2DAdapter), replace=replace )
def _grid_from_problem(problem: MaxwellProblem) -> Grid2D: mesh = problem.mesh resistivity = 1.0 / problem.conductivity_s_m return Grid2D( dx=mesh.cell_widths_m["x"], dz=mesh.cell_widths_m["z"], resistivity=resistivity, x_stations=problem.receivers.coordinates_m[:, 0], n_pad=0, name=problem.metadata.get("benchmark", "mt2d"), ) def _result_from_response( problem: MaxwellProblem, response: ForwardResponse2D, capabilities: BackendCapabilities, runtime_s: float, ) -> ForwardResult: n_station = response.n_stations n_freq = response.n_freqs n_component = len(problem.components) impedance = np.empty((n_station, n_freq, n_component), dtype=complex) for column, name in enumerate(problem.components): source = response.zxy if name == "zxy" else response.zyx impedance[:, :, column] = source.T converged = np.stack( [ np.isfinite(response.residual_te), np.isfinite(response.residual_tm), ], axis=1, ) residual = np.stack([response.residual_te, response.residual_tm], axis=1) 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, capabilities.name, capabilities.version, diagnostics, )