Source code for pycsamt.ai.inversion.pinn2d

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
r"""
Physics-informed AI 2-D MT inversion.

:class:`PINNInverter2D` optimises a joint 2-D
resistivity section by minimising a physics-informed
loss that combines per-station 1-D MT data misfits
with lateral and vertical smoothness penalties.

The earth model is parameterised as
:math:`\log_{10}(\rho)` tensors with shape
``(n_stations, n_layers)`` for resistivity and
``(n_stations, n_layers-1)`` for layer thicknesses.
All parameters are optimised simultaneously via Adam
so the lateral-smoothness term couples adjacent
stations.

Forward model
-------------
The differentiable Wait (1954) MT-1D recursion
(implemented in :mod:`~pycsamt.ai.inversion.pinn1d`)
is applied in batch over all stations on a shared
frequency grid.

Loss function
-------------

.. math::

    \mathcal{L} =
    \underbrace{
      \frac{1}{N_v}
      \sum_{s,f}^{\text{valid}}
      \left[
        \left(
          \log_{10}\rho_a^{\rm pred} -
          \log_{10}\rho_a^{\rm obs}
        \right)^2
        + \left(
            \frac{\phi^{\rm pred} - \phi^{\rm obs}}{90}
          \right)^2
      \right]
    }_{\text{data}}
    + \lambda_z
      \underbrace{
        \frac{1}{S(L-1)}
        \sum_{s,k}
        (m_{s,k+1} - m_{s,k})^2
      }_{\text{vertical smooth}}
    + \lambda_x
      \underbrace{
        \frac{1}{(S-1)L}
        \sum_{s,k}
        (m_{s+1,k} - m_{s,k})^2
      }_{\text{lateral smooth}}

where :math:`m_{s,k} = \log_{10}\rho_{s,k}`,
:math:`N_v` is the number of valid (non-NaN)
station-frequency pairs, :math:`S` is the number
of stations, and :math:`L` is the number of layers.

Example
-------
>>> from pycsamt.ai.inversion import PINNInverter2D
>>> inv = PINNInverter2D(  # doctest: +SKIP
...     "edi/profile1/",
...     n_layers=12,
...     depth_max=3000.0,
...     epochs=300,
... )
>>> inv.fit()  # doctest: +SKIP
PINNInverter2D(n_stations=20, fitted)
>>> section = inv.resistivity_section()  # doctest: +SKIP
>>> df = inv.residuals()  # doctest: +SKIP
"""

from __future__ import annotations

from typing import Any

import numpy as np

from .._base import BasePINNInverter
from ._pinn_ops import fit_2d_joint
from ._sites_bridge import (
    SiteObs2D,
    _interp_to_grid,
    _make_common_grid,
    sites_to_obs_2d,
)

__all__ = ["PINNInverter2D"]


# ── backward-compat re-export (hybrid2d imports this)


[docs] class PINNInverter2D(BasePINNInverter): r""" Physics-informed 2-D MT inversion. Optimises a pseudo-2D resistivity section by minimising the data misfit with the 1-D MT forward plus lateral and vertical smoothness penalties. Parameters ---------- sites : Any Path, ``EDIFile``, ``EDICollection``, ``Site``, ``Sites``, ``APISurvey``, or iterable. n_layers : int, default 10 Number of model layers per station. depth_max : float, default 2000.0 Target maximum depth in metres. n_freqs : int, default 32 Frequency-grid points for the common grid. mode : {'te', 'tm', 'both'}, default 'te' Which observed polarisation to use. ``'both'`` averages TE and TM data misfits. smoothness_weight : float, default 0.01 Vertical smoothness weight :math:`\lambda_z`. lateral_weight : float, default 0.005 Lateral smoothness weight :math:`\lambda_x`. epochs : int, default 300 Adam iterations. lr : float, default 1e-2 Adam learning rate. comp_te : str, default ``'xy'`` Impedance tensor component for TE mode. comp_tm : str, default ``'yx'`` Impedance tensor component for TM mode. device : str or None Torch compute device (auto-detects if None). recursive : bool, default True on_dup : str, default ``'replace'`` verbose : int, default 0 """ def __init__( self, sites: Any, *, n_layers: int = 10, depth_max: float = 2000.0, n_freqs: int = 32, mode: str = "te", smoothness_weight: float = 0.01, lateral_weight: float = 0.005, epochs: int = 300, lr: float = 1e-2, comp_te: str = "xy", comp_tm: str = "yx", device: str | None = None, recursive: bool = True, on_dup: str = "replace", verbose: int = 0, ) -> None: if mode not in ("te", "tm", "both"): raise ValueError( f"mode must be 'te', 'tm', or 'both'; got {mode!r}." ) super().__init__( n_layers=n_layers, depth_max=depth_max, device=device, ) self.n_freqs = int(n_freqs) self.mode = mode self.smoothness_weight = float(smoothness_weight) self.lateral_weight = float(lateral_weight) self.epochs = int(epochs) self.lr = float(lr) self.comp_te = comp_te self.comp_tm = comp_tm self.verbose = verbose self._obs: list[SiteObs2D] = sites_to_obs_2d( sites, comp_te=comp_te, comp_tm=comp_tm, recursive=recursive, on_dup=on_dup, verbose=verbose, ) self._freqs_grid = _make_common_grid( [o.freq for o in self._obs], n_freqs=n_freqs, ) self._result: dict | None = None # ── fit ──
[docs] def fit( self, *, verbose: bool = True, log_every: int = 50, ) -> PINNInverter2D: """ Run the joint 2-D physics-informed inversion. Parameters ---------- verbose : bool, default True log_every : int, default 50 Returns ------- self """ self._require_backend() dev = self._resolve_device() lr_obs, ph_obs = self._build_obs_arrays() if verbose: S = len(self._obs) print( f"PINNInverter2D: optimising " f"{S} stations x {self.n_layers} " f"layers ({self.epochs} epochs) ..." ) _every = log_every if verbose else 0 self._result = fit_2d_joint( lr_obs, ph_obs, self._freqs_grid, n_layers=self.n_layers, depth_max=self.depth_max, lam_z=self.smoothness_weight, lam_x=self.lateral_weight, lr=self.lr, epochs=self.epochs, device=dev, log_every=_every, verbose=verbose, ) self._history = self._result["history"] self._is_fitted = True return self
# ── outputs ──
[docs] def resistivity_section(self, *, as_log10: bool = True) -> np.ndarray: """ Return the 2-D resistivity section. Parameters ---------- as_log10 : bool, default True If True return log10(rho); else linear rho. Returns ------- ndarray (n_layers, n_stations) """ self._check_fitted() lr = self._result["log_rho"] # (S, L) section = lr.T # (L, S) if as_log10: return section return 10.0**section
[docs] def thickness_section(self) -> np.ndarray: """ Return layer thicknesses in metres. Returns ------- ndarray (n_layers-1, n_stations) """ self._check_fitted() lt = self._result["log_thick"] # (S, L-1) return (10.0**lt).T # (L-1, S)
[docs] def convergence_curve(self): """ Return Adam loss history. Returns ------- pandas.DataFrame Columns: epoch, loss. """ self._check_fitted() import pandas as pd return pd.DataFrame( { "epoch": range(1, len(self._result["history"]) + 1), "loss": self._result["history"], } )
[docs] def residuals(self): """ Observed vs predicted data fit. Returns ------- pandas.DataFrame Columns: station, freq, rho_obs, rho_pred, phase_obs, phase_pred. """ self._check_fitted() import pandas as pd lr_2d = self._result["log_rho"] # (S, L) lt_2d = self._result["log_thick"] # (S, L-1) rows = [] for i, obs in enumerate(self._obs): rho_i = lr_2d[i] th_i = lt_2d[i] try: from pycsamt.forward.em1d import ( MT1DForward, ) from pycsamt.forward.synthetic import ( LayeredModel, ) m = LayeredModel( resistivity=np.maximum(10.0**rho_i, 1e-3), thickness=np.maximum(10.0**th_i, 1.0), ) resp = MT1DForward(obs.freq).run(m) rp = resp.rho_a pp = resp.phase except Exception: rp = np.full_like(obs.freq, np.nan) pp = np.full_like(obs.freq, np.nan) rho_obs = obs.rho_te if self.mode in ("te", "both") else obs.rho_tm ph_obs = ( obs.phase_te if self.mode in ("te", "both") else obs.phase_tm ) for k in range(len(obs.freq)): rows.append( { "station": obs.name, "freq": obs.freq[k], "rho_obs": rho_obs[k], "rho_pred": rp[k], "phase_obs": ph_obs[k], "phase_pred": pp[k], } ) return pd.DataFrame(rows)
# ── read-only properties ──
[docs] @property def stations(self) -> list[str]: """Station names in profile order.""" return [o.name for o in self._obs]
[docs] @property def n_sites(self) -> int: """Number of loaded stations.""" return len(self._obs)
# ── internals ── def _build_obs_arrays( self, ) -> tuple[np.ndarray, np.ndarray]: """ Build (log_rho_obs, ph_obs) arrays for opt. Returns ------- log_rho_obs : ndarray (S, F) ph_obs : ndarray (S, F) NaN where interpolation is out of range. """ S = len(self._obs) F = len(self._freqs_grid) lr_obs = np.full((S, F), np.nan) ph_obs = np.full((S, F), np.nan) for i, o in enumerate(self._obs): if self.mode == "te": rho_src = o.rho_te ph_src = o.phase_te elif self.mode == "tm": rho_src = o.rho_tm ph_src = o.phase_tm else: # mode='both': average TE and TM rho_src = 0.5 * (o.rho_te + o.rho_tm) ph_src = 0.5 * (o.phase_te + o.phase_tm) lr_g, ph_g = _interp_to_grid( o.freq, rho_src, ph_src, self._freqs_grid, ) lr_obs[i] = lr_g ph_obs[i] = ph_g return lr_obs, ph_obs def __repr__(self) -> str: status = "fitted" if self._is_fitted else "unfitted" return ( f"PINNInverter2D(" f"n_stations={self.n_sites}, " f"n_layers={self.n_layers}, " f"{status})" )