pycsamt.forward.maxwell.modem3d#

External ModEM adapter: the trusted production 3-D backend.

Per AI-INVERSION (Decision 1), ModEM — not an in-house solver — is the recommended path for genuine 3-D production forward modeling. ModEm3DAdapter bridges the solver-neutral MaxwellProblem / ForwardResult contract to the vendored ModEM Fortran executable (pycsamt/models/modem/_source/3D/Mod3DMT.f90, v6.2.6; Egbert, Kelbert & Meqbel) via BaseExternalMaxwellAdapter, reusing the file I/O already built in pycsamt.models.modem (ModEmData, ModEmModel3D) rather than a second, competing implementation of the ModEM file formats.

Status: physics-validated against a real compiled binary (2026-07-29). No compiled Mod3DMT binary is committed to this repository (it is a local build artifact, gitignored — see pycsamt/models/modem/_source/README for build instructions), but one was built here with a MinGW-w64 gfortran/OpenBLAS toolchain and used to run this project’s own analytic benchmarks end-to-end. Doing so surfaced and fixed several real, previously-latent bugs — this adapter’s generated files, and the vendored Fortran source itself, had never actually been exercised against a live ModEM run before:

  • the vendored Makefile was missing a build rule for sg_spherical.f90 (use`d directly by ``GridCalc.f90`) and never compiled Declaration_MPI/Sub_MPI/Main_MPI.f90 (whose #ifdef MPI-guarded bodies are unconditionally use`d by several files regardless of MPI) nor passed `-cpp`` to strip those guards for a serial build — all reproduce identically on Linux/Mac, not Windows-specific;

  • resolve_executable()’s search_paths fallback checked a literal Path(directory) / name rather than applying PATHEXT the way its own PATH lookup (via shutil.which()) already did, so a bare name like "Mod3DMT" never resolved to "Mod3DMT.exe" on Windows;

  • ModEm3DAdapter._build_command() omitted the third positional argument ModEM’s own -F forward-mode requires (the predicted-data output filename), so it printed its own usage banner and exited 0 having written nothing, indistinguishable from success until ModEm3DAdapter._locate_predicted_file() found no output file;

  • ModEmModel3D’s WS-format reader/writer were missing a mandatory leading comment line ModEM’s Fortran read_modelParam_ws (WS.inc) unconditionally reads and discards before the dimensions line — a real compiled binary immediately rejected the header-less file with a Fortran runtime error;

  • ModEM’s own setup_airlayers (GridDef.f90) hardcodes 10 air layers and its default “mirror” sizing method reads that many earth-layer widths with no bounds check against the actual earth cell count — fewer than 10 earth z-cells reads past the end of the array, producing garbage/NaN values that crash the solver with “b in QMR contains NaNs”. ModEm3DAdapter.assess() now rejects this before ever writing a file (see _MIN_EARTH_Z_CELLS); this is a real latent bug in the vendored ModEM source itself, worked around here rather than patched there (patching unfamiliar, decades-old numerical Fortran to add a defensive bounds check was judged riskier than simply requiring enough earth cells).

With those fixed and at least _MIN_EARTH_Z_CELLS earth z-cells, this adapter passes both half_space_benchmark() and layered_earth_benchmark() with real margin — see “Measured accuracy” below and pycsamt/forward/tests/test_maxwell_modem3d.py’s requires_real_modem-gated tests, which are skipped (not failed) when no local binary is present, and were confirmed passing against the real one built for this validation.

Scope and mapping decisions#

  • No separate air layers (ModEmModel3D.n_air = 0): the whole MaxwellProblem mesh is written as ModEM “earth” cells, matching the same “mesh top = physical surface” convention already used by MT2DAdapter and MT3DAdapter — not a ModEM limitation (ModEM supports real air/topography), a deliberate v1 scope reduction for consistency and lower risk. Topography support is future work.

  • Non-uniform meshes are supported: ModEM’s own solver has no uniform-grid restriction, so supports_nonuniform_mesh=True (as of 2026-07-29, mt3d.py also supports non-uniform meshes, so this is no longer a point of difference between the two adapters).

  • Station and model coordinates are shifted so the mesh’s own minimum x/y edge maps to ModEM-local (0, 0) — the vendored ModEmData/ ModEmModel3D pair has no shared origin/rotation field of its own (confirmed by reading both writers), so this adapter is the thing responsible for keeping station coordinates and model-grid coordinates in the same frame.

  • Units: requests are written in ModEM’s native [mV/km]/[nT] field-unit convention (the well-established default, unlike an unverified [V/A] request), and the response is converted to SI V/A on the way back using the standard 4*pi*1e-4 factor.

  • Full impedance tensor is always requested from ModEM regardless of problem.components (no cost difference internally), then filtered down to the requested subset when building the result.

  • Diagnostics are honest about what ModEM’s plain predicted-data file does not expose: iterations is always 0 (forward mode does not iterate) and relative_residual is always 0.0 — not a measured quantity (the contract requires a finite value; 0.0 is a documented placeholder, not a claim of exact agreement). Only runtime_s (the external process’s real wall-clock time) is a genuine measurement.

Measured accuracy#

On a small uniform 8x8x10 grid (300 m cells, 10 earth z-cells — the minimum _MIN_EARTH_Z_CELLS requires), against a real compiled Mod3DMT: half_space_benchmark() and layered_earth_benchmark() both pass the default BenchmarkThresholds with real margin (normalized RMS under 5%). Unlike mt3d’s research-only solver, no padded mesh was needed here — ModEM synthesizes its own air layers and, as an iterative-solver production code, does not carry this project’s own small-grid cell-budget restriction, so a plain uniform mesh sufficed for this validation. This does not by itself validate every mesh configuration (e.g. non-uniform meshes, receivers off-centre) – only what the cited benchmarks actually exercise.

Functions

register_modem3d_backend(*[, config, replace])

Register ModEm3DAdapter in the process-wide registry.

Classes

ModEm3DAdapter(*[, config, run_policy, ...])

Adapter wrapping the external ModEM 3-D forward solver.

class pycsamt.forward.maxwell.modem3d.ModEm3DAdapter(*, config=None, run_policy=None, policy=None, predicted_data_filename=None, version='modem-v6.2.6-adapter-1.0')[source]

Bases: BaseExternalMaxwellAdapter

Adapter wrapping the external ModEM 3-D forward solver.

Parameters:
  • config (ModEmConfig or None, optional) – ModEM configuration (executable names, MPI settings, units, sign convention). Defaults to ModEmConfig(mode="3d").

  • run_policy (ExternalRunPolicy or None, optional) – Executable resolution, timeout, retry, and working-directory rules. When omitted, a policy is built from config (config.mpi_command if config.use_mpi else config.binary_3d), with the vendored pycsamt/models/modem/_source/3D directory as a search path fallback.

  • policy (AdapterPolicy or None, optional) – Solver-independent result acceptance policy.

  • predicted_data_filename (str or None, optional) – Exact name of ModEM’s predicted-response output file within the run’s working directory. When omitted, the adapter auto-detects it as the only *.dat file other than the one it wrote itself, and raises a clear error if that is ambiguous (zero or more than one candidate) — set this explicitly once you know your ModEM build’s naming convention.

  • version (str, default="modem-v6.2.6-adapter-1.0") – Adapter version reported in every ForwardResult. Encodes both the vendored ModEM release this adapter targets and this adapter code’s own revision.

Examples

>>> from pycsamt.forward.maxwell import ExternalRunPolicy
>>> adapter = ModEm3DAdapter(
...     run_policy=ExternalRunPolicy("does-not-exist-xyz")
... )
>>> adapter.capabilities.name
'modem3d'
assess(problem)[source]

Assess a problem, adding this adapter’s mapping checks.

Parameters:

problem (MaxwellProblem) – Candidate simulation problem.

Returns:

The generic capability report from assess(), extended with surface-receiver, horizontal-bounds, surface-aligned-mesh, and vacuum-permeability checks.

Return type:

CompatibilityReport

Examples

See ModEm3DAdapter for construction; a problem with a buried receiver is rejected before any file is written.

pycsamt.forward.maxwell.modem3d.register_modem3d_backend(*, config=None, replace=False)[source]

Register ModEm3DAdapter in the process-wide registry.

Parameters:
  • config (ModEmConfig or None, optional) – Configuration used both to build the registered adapter and to derive its availability probe (whether the configured executable can currently be resolved).

  • replace (bool, default=False) – Explicitly replace an existing "modem3d" registration.

Return type:

None

Examples

>>> register_modem3d_backend(replace=True)
>>> from pycsamt.forward.maxwell import list_backends
>>> "modem3d" in list_backends()
True
>>> list_backends()["modem3d"]["available"]
False