# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
1-D electromagnetic forward solvers.
Three EM methods are provided, all using the same
:class:`LayeredModel` / :class:`ForwardResponse` objects:
``MT1DForward``
Magnetotelluric (plane-wave source). Uses the exact recursive
Wait (1954) impedance formula — the standard for generating
MT/AMT/CSAMT far-field training data.
``TEM1DForward``
Central-loop time-domain EM (step-off waveform). Computes the
frequency-domain H_z via a Hankel transform of the TE admittance
kernel, then converts to time via a cosine transform. Uses
``scipy.integrate`` (correct but not optimised; a DLF
acceleration layer is planned for Phase 2).
``CSAMT1DForward``
Controlled-source AMT. In the far-field limit this reduces to
MT. A near-field geometry correction factor is applied when
source–receiver distance and frequencies are provided.
References
----------
Wait, J.R. (1954). On the relation between telluric currents and the
Earth's magnetic field. *Geophysics*, 19(2), 281-289.
Nabighian, M.N. (1979). Quasi-static transient response of a
conducting half-space. *Geophysics*, 44(10), 1700-1705.
Ward, S.H. & Hohmann, G.W. (1988). Electromagnetic theory for
geophysical applications. In: *Electromagnetic Methods in Applied
Geophysics*, 1, 130-311.
Puzyrev, V. et al. (2021). Inversion of 1D frequency- and
time-domain EM data with CNNs. *Computers & Geosciences*, 149,
104681.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Sequence
from dataclasses import dataclass, field
import numpy as np
__all__ = [
"MT1DForward",
"TEM1DForward",
"CSAMT1DForward",
"ForwardResponse",
]
# Physical constants
MU0: float = 4.0e-7 * np.pi # H/m — magnetic permeability of free space
# ─────────────────────────────────────────────────────────────────────────────
# Output container
# ─────────────────────────────────────────────────────────────────────────────
[docs]
@dataclass
class ForwardResponse:
"""
Container for the output of a 1-D forward solver.
Parameters
----------
method : str
Solver identifier (``'MT1D'``, ``'TEM1D'``, ``'CSAMT1D'``).
freqs : ndarray or None
Frequencies in Hz (MT/CSAMT).
times : ndarray or None
Times in seconds (TEM).
z : ndarray or None
Complex surface impedance (n_freq,) for MT/CSAMT [V/A].
rho_a : ndarray or None
Apparent resistivity (n_freq,) [Ω·m].
phase : ndarray or None
Impedance phase (n_freq,) [degrees, 0–90° for normal models].
dBz_dt : ndarray or None
dBz/dt step-off response (n_times,) [T/s] for TEM.
hz_freq : ndarray or None
Complex frequency-domain H_z (n_freq,) for TEM.
model : LayeredModel or None
The input model that produced this response.
"""
method: str = "MT1D"
freqs: np.ndarray | None = None
times: np.ndarray | None = None
z: np.ndarray | None = None
rho_a: np.ndarray | None = None
phase: np.ndarray | None = None
dBz_dt: np.ndarray | None = None
hz_freq: np.ndarray | None = None
model: object = field(default=None, repr=False)
[docs]
def to_array(
self, *, log_rho: bool = True, include_phase: bool = True
) -> np.ndarray:
"""
Flatten to a 1-D feature vector for ML input.
For MT/CSAMT returns ``[log10(rho_a), phase_deg]`` concatenated
(length 2 × n_freq); for TEM returns ``log10(|dBz_dt|)``
(length n_times).
Parameters
----------
log_rho : bool
Apply log₁₀ to apparent resistivity (recommended).
include_phase : bool
Include phase alongside ρ_a (MT only).
"""
if self.method in ("MT1D", "CSAMT1D"):
ra = (
np.log10(np.maximum(self.rho_a, 1e-12))
if log_rho
else self.rho_a
)
if include_phase:
return np.concatenate([ra, self.phase])
return ra
# TEM
db = np.abs(self.dBz_dt)
db = np.where(db > 0, db, 1e-30)
return np.log10(db) if log_rho else db
[docs]
def plot(self, ax=None, **kwargs):
"""Quick diagnostic plot. Returns the Axes used."""
import matplotlib.pyplot as plt
if ax is None:
_, ax = plt.subplots(figsize=(5, 4))
if self.method in ("MT1D", "CSAMT1D"):
period = 1.0 / self.freqs
ax.loglog(period, self.rho_a, **kwargs)
ax.set_xlabel("Period (s)")
ax.set_ylabel(r"$\rho_a$ (Ω·m)")
ax.set_title(f"{self.method} apparent resistivity")
else:
ax.loglog(self.times, np.abs(self.dBz_dt), **kwargs)
ax.set_xlabel("Time (s)")
ax.set_ylabel(r"$|d\mathbf{B}_z/dt|$ (T/s)")
ax.set_title("TEM1D step-off response")
return ax
# ─────────────────────────────────────────────────────────────────────────────
# Internal helpers
# ─────────────────────────────────────────────────────────────────────────────
def _z_surface_mt(
omega: float, rho: np.ndarray, thick: np.ndarray
) -> complex:
"""
Compute the MT surface impedance for a single angular frequency.
Uses the Wait (1954) downward-recursion formula from the halfspace
upward through each layer.
Parameters
----------
omega : float
Angular frequency 2πf [rad/s].
rho : ndarray (n_layers,)
Layer resistivities [Ω·m], top → bottom. Last entry = halfspace.
thick : ndarray (n_layers-1,)
Layer thicknesses [m].
Returns
-------
Z_s : complex
Surface impedance [V/A].
"""
# Halfspace intrinsic impedance
k_n = np.sqrt(1j * omega * MU0 / rho[-1])
Z = 1j * omega * MU0 / k_n # = sqrt(iωμρ_N)
# Propagate upward
for j in range(len(rho) - 2, -1, -1):
k_j = np.sqrt(1j * omega * MU0 / rho[j])
Z0_j = 1j * omega * MU0 / k_j # intrinsic impedance of layer j
th = np.tanh(k_j * thick[j])
Z = Z0_j * (Z + Z0_j * th) / (Z0_j + Z * th)
return Z
def _te_admittance(
lam: np.ndarray, omega: float, rho: np.ndarray, thick: np.ndarray
) -> np.ndarray:
"""
Vectorised TE-mode surface admittance Y_TE(λ, ω) for all λ at once.
Parameters
----------
lam : ndarray (n_lam,)
Horizontal wavenumbers [1/m].
omega : float
Angular frequency [rad/s].
rho, thick : as in _z_surface_mt
Returns
-------
Y_TE : ndarray (n_lam,), complex
"""
sigma = 1.0 / rho # conductivities
# Vertical wavenumber ν_j² = λ² + iωμ₀σ_j
# Shape broadcast: (n_lam, n_layers)
nu = np.sqrt(lam[:, None] ** 2 + 1j * omega * MU0 * sigma[None, :])
# Intrinsic TE admittance Y0_j = ν_j / (iωμ₀)
Y0 = nu / (1j * omega * MU0) # (n_lam, n_layers)
# Start from halfspace
Y = Y0[:, -1].copy() # (n_lam,)
for j in range(len(rho) - 2, -1, -1):
Y0j = Y0[:, j]
nu_j = nu[:, j]
th = np.tanh(nu_j * thick[j])
Y = Y0j * (Y + Y0j * th) / (Y0j + Y * th)
return Y
def _hankel_hz_fd(
omega: float,
rho: np.ndarray,
thick: np.ndarray,
loop_radius: float,
n_lam: int = 150,
) -> complex:
"""
Frequency-domain H_z at the centre of a horizontal circular loop of
radius *loop_radius* over a 1-D earth.
The integral ∫₀^∞ λ · r_TE(λ,ω) · J₁(λa) dλ is evaluated with
Gauss-Legendre quadrature in log(λ) space.
Returns H_z per unit moment [1/m³].
"""
from numpy.polynomial.legendre import leggauss
from scipy.special import j1 as J1
a = loop_radius
lam_lo = np.log(1e-5 / a)
lam_hi = np.log(1e5 / a)
nodes, wts = leggauss(n_lam)
t = 0.5 * (lam_hi - lam_lo) * nodes + 0.5 * (lam_hi + lam_lo)
lam = np.exp(t)
jac = 0.5 * (lam_hi - lam_lo)
Y_TE = _te_admittance(lam, omega, rho, thick)
Y0_air = lam / (1j * omega * MU0) # air admittance ν₀/iωμ₀ ≈ λ/iωμ₀
r_TE = (Y0_air - Y_TE) / (Y0_air + Y_TE)
# Kernel: λ · r_TE · J₁(λa) × λ (Jacobian for log substitution)
integrand = lam * r_TE * J1(lam * a) * lam
return jac * complex(np.dot(wts, integrand)) / (4.0 * np.pi)
# ─────────────────────────────────────────────────────────────────────────────
# Abstract base solver
# ─────────────────────────────────────────────────────────────────────────────
class _Base1DForward(ABC):
@abstractmethod
def run(self, model) -> ForwardResponse: # model: LayeredModel
...
# ─────────────────────────────────────────────────────────────────────────────
# MT 1-D forward
# ─────────────────────────────────────────────────────────────────────────────
[docs]
class MT1DForward(_Base1DForward):
"""
1-D magnetotelluric forward solver (plane-wave, isotropic earth).
Uses the exact Wait (1954) recursive impedance algorithm. Runs in
O(n_freq × n_layers) time — fast enough for generating millions of
synthetic training samples.
Parameters
----------
freqs : array-like
Frequencies [Hz] at which to evaluate the response.
Typical range: 1e-4 – 1e5 Hz for MT/AMT.
Examples
--------
>>> import numpy as np
>>> from pycsamt.forward.em1d import MT1DForward
>>> from pycsamt.forward.synthetic import LayeredModel
>>> freqs = np.logspace(-3, 4, 30)
>>> model = LayeredModel(
... resistivity=[100, 10, 500],
... thickness=[500, 1000]
... )
>>> resp = MT1DForward(freqs).run(model)
>>> resp.rho_a.shape
(30,)
"""
def __init__(self, freqs: Sequence[float] | np.ndarray):
self.freqs = np.asarray(freqs, dtype=float)
[docs]
def run(self, model) -> ForwardResponse:
"""
Compute the MT 1-D response for *model*.
Parameters
----------
model : LayeredModel
Input earth model.
Returns
-------
ForwardResponse
Fields populated: ``z``, ``rho_a``, ``phase``, ``freqs``.
"""
rho = model.resistivity
thick = model.thickness
omega = 2.0 * np.pi * self.freqs
z_surf = np.array(
[_z_surface_mt(w, rho, thick) for w in omega], dtype=complex
)
rho_a = np.abs(z_surf) ** 2 / (omega * MU0)
phase = np.angle(z_surf, deg=True)
return ForwardResponse(
method="MT1D",
freqs=self.freqs,
z=z_surf,
rho_a=rho_a,
phase=phase,
model=model,
)
# ─────────────────────────────────────────────────────────────────────────────
# TEM 1-D forward
# ─────────────────────────────────────────────────────────────────────────────
[docs]
class TEM1DForward(_Base1DForward):
"""
1-D central-loop TEM forward solver (step-off waveform).
Computes the vertical magnetic field H_z(ω) via a numerical Hankel
transform of the TE admittance kernel, then converts to the step-off
time-domain dBz/dt via a cosine transform.
.. note::
This implementation uses ``scipy.integrate`` for correctness.
It is suitable for generating training datasets of moderate size
(up to ~10 000 samples). A Digital Linear Filter (DLF)
optimisation yielding 100× speed-up is planned for Phase 2.
Parameters
----------
times : array-like
Measurement times [s] for the step-off response.
Typical range: 1e-6 – 1e-2 s.
loop_radius : float
Transmitter loop radius [m]. Default 50 m.
moment : float
Transmitter magnetic moment [A·m²]. Default 1 A·m².
n_freqs : int
Number of frequency-domain evaluation points used in the
cosine transform. Higher → better accuracy at early times.
References
----------
Ward & Hohmann (1988), *Electromagnetic Methods in Applied
Geophysics*, Vol. 1.
"""
def __init__(
self,
times: Sequence[float] | np.ndarray,
loop_radius: float = 50.0,
moment: float = 1.0,
n_freqs: int = 64,
n_lam: int = 100,
):
self.times = np.asarray(times, dtype=float)
self.loop_radius = float(loop_radius)
self.moment = float(moment)
self.n_freqs = n_freqs
self.n_lam = n_lam
# Pre-compute frequency grid for the cosine transform
t_min = self.times.min()
t_max = self.times.max()
# Cover 4 decades below/above the time window
self._omega = np.logspace(
np.log10(2.0 * np.pi / (10.0 * t_max)),
np.log10(2.0 * np.pi / (t_min / 10.0)),
n_freqs,
)
[docs]
def run(self, model) -> ForwardResponse:
"""
Compute the TEM 1-D step-off response for *model*.
Parameters
----------
model : LayeredModel
Input earth model.
Returns
-------
ForwardResponse
Fields populated: ``dBz_dt``, ``hz_freq``, ``times``.
"""
rho = model.resistivity
thick = model.thickness
# Frequency-domain H_z at each ω
hz_fd = (
np.array(
[
_hankel_hz_fd(w, rho, thick, self.loop_radius, self.n_lam)
for w in self._omega
],
dtype=complex,
)
* self.moment
)
# Time-domain dBz/dt via cosine transform
# dBz/dt(t) ≈ (2/π) × ∫₀^∞ ω · Im[H_z(ω)] · cos(ωt) dω
dBz_dt = self._cosine_transform(hz_fd)
return ForwardResponse(
method="TEM1D",
times=self.times,
dBz_dt=dBz_dt,
hz_freq=hz_fd,
model=model,
)
def _cosine_transform(self, hz_fd: np.ndarray) -> np.ndarray:
"""Numerical cosine transform → dBz/dt at self.times."""
omega = self._omega
dlog_omega = np.diff(np.log(omega)) # spacings
# Trapezoidal integration in log(ω) space
# ∫ f(ω) dω ≈ Σ f(ω_k) · ω_k · Δlog(ω)
result = np.empty(len(self.times))
for i, t in enumerate(self.times):
integrand = omega * np.imag(hz_fd) * np.cos(omega * t)
# Trapezoidal in log-space: integrate f(ω)*ω d(log ω)
pts = integrand * omega # f·ω for log spacing
result[i] = (2.0 / np.pi) * np.sum(
0.5 * (pts[:-1] + pts[1:]) * dlog_omega
)
return result
# ─────────────────────────────────────────────────────────────────────────────
# CSAMT 1-D forward
# ─────────────────────────────────────────────────────────────────────────────
[docs]
class CSAMT1DForward(_Base1DForward):
"""
1-D controlled-source AMT forward solver.
In the far-field (source–receiver offset r ≫ skin depth δ) the
CSAMT response approximates the MT plane-wave response. A first-
order near-field correction factor after Zonge & Hughes (1991) is
applied when *source_offset* is supplied.
Parameters
----------
freqs : array-like
Frequencies [Hz].
source_offset : float or None
Source–receiver distance [m]. If ``None``, the far-field
approximation is used without correction.
dipole_length : float
Electric dipole length [m]. Default 1000 m.
References
----------
Zonge, K.L. & Hughes, L.J. (1991). Controlled source audio-
frequency magnetotellurics. In *Electromagnetic Methods in Applied
Geophysics*, 2B, 713-809.
"""
def __init__(
self,
freqs: Sequence[float] | np.ndarray,
source_offset: float | None = None,
dipole_length: float = 1000.0,
):
self.freqs = np.asarray(freqs, dtype=float)
self.source_offset = source_offset
self.dipole_length = float(dipole_length)
self._mt = MT1DForward(self.freqs)
[docs]
def run(self, model) -> ForwardResponse:
"""
Compute the CSAMT 1-D response for *model*.
Returns
-------
ForwardResponse
Fields populated: ``z``, ``rho_a``, ``phase``.
"""
resp = self._mt.run(model)
if self.source_offset is not None:
resp = self._apply_nearfield_correction(resp, model)
resp.method = "CSAMT1D"
return resp
def _apply_nearfield_correction(
self, resp: ForwardResponse, model
) -> ForwardResponse:
"""
First-order near-field correction after Zonge & Hughes (1991).
The correction factor f_nf = 1 / (1 + (r/δ)⁻²) modulates the
apparent resistivity so that ρ_a → ρ_true as r/δ → ∞.
"""
omega = 2.0 * np.pi * self.freqs
# Skin depth for the first layer δ = sqrt(2ρ/(ωμ₀))
rho1 = model.resistivity[0]
delta = np.sqrt(2.0 * rho1 / (omega * MU0))
r = self.source_offset
f_nf = 1.0 / (1.0 + (r / delta) ** (-2))
# Correct apparent resistivity and recompute phase-compatible Z
rho_a_corr = resp.rho_a * f_nf
z_corr = np.sqrt(rho_a_corr * omega * MU0) * np.exp(
1j * np.angle(resp.z)
)
return ForwardResponse(
method="CSAMT1D",
freqs=self.freqs,
z=z_corr,
rho_a=rho_a_corr,
phase=np.angle(z_corr, deg=True),
model=model,
)