Source code for pycsamt.interp.timelapse

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Time-lapse EM monitoring for hydrogeological change detection.

Compares a sequence of EM resistivity models (acquired at different times)
to detect and quantify:

* Resistivity change Δlog₁₀(ρ) — raw geophysical signal
* Water-saturation change ΔSw — via Archie inverse
* Volumetric water-content change Δθ = φ · ΔSw
* Water-table displacement (rise/fall in metres)

Primary applications in an EM-hydro context:

* **TDEM time-lapse** — monitoring recharge, dewatering, or saline intrusion
  at 10–500 m depth.  High sensitivity to near-surface saturation changes.
* **AMT time-lapse** — seasonal or induced changes in regional aquifers and
  fractured basement (100–2 000 m).

All outputs are referenced to a *baseline* survey (default: index 0).  The
grids of all surveys must match (same ``x_centers`` and ``z_centers``).  Use
:func:`assert_compatible_grids` to check before computing.

Typical use
-----------
>>> from pycsamt.interp import ResistivityModel
>>> from pycsamt.interp.petrophysics import ArchieModel
>>> from pycsamt.interp.timelapse import TimeLapseEM
>>>
>>> tl = TimeLapseEM(
...     surveys=[model_dry, model_wet, model_recharge],
...     labels=["dry", "wet", "recharge"],
... )
>>> delta_rho = tl.resistivity_change()    # list of (n_z, n_x) arrays
>>> delta_Sw  = tl.saturation_change(ArchieModel(), rho_w=0.025)
>>> delta_wt  = tl.water_table_displacement(ArchieModel())  # (n_surveys-1, n_x)
"""

from __future__ import annotations

from collections.abc import Sequence
from typing import Union

import numpy as np

from ..api.property import PyCSAMTObject
from ._base import ResistivityModel
from .petrophysics import (
    ArchieModel,
    WaxmanSmitsModel,
    water_table_from_profile,
)

__all__ = [
    "TimeLapseEM",
    "assert_compatible_grids",
]

_PetroModel = Union[ArchieModel, WaxmanSmitsModel]


# ─────────────────────────────────────────────────────────────────────────────
# Grid compatibility check
# ─────────────────────────────────────────────────────────────────────────────


[docs] def assert_compatible_grids( surveys: Sequence[ResistivityModel], *, rtol: float = 1e-4, ) -> None: """Raise ``ValueError`` if surveys have incompatible grids. Parameters ---------- surveys : sequence of ResistivityModel rtol : float Relative tolerance for coordinate comparison (default 1e-4). """ ref = surveys[0] for i, s in enumerate(surveys[1:], start=1): if s.rho_2d.shape != ref.rho_2d.shape: raise ValueError( f"Survey {i} has shape {s.rho_2d.shape} " f"but survey 0 has shape {ref.rho_2d.shape}. " "All surveys must share the same model grid." ) if not np.allclose(s.x_centers, ref.x_centers, rtol=rtol): raise ValueError( f"Survey {i} x_centers differ from survey 0 by more than rtol={rtol}." ) if not np.allclose(s.z_centers, ref.z_centers, rtol=rtol): raise ValueError( f"Survey {i} z_centers differ from survey 0 by more than rtol={rtol}." )
# ───────────────────────────────────────────────────────────────────────────── # TimeLapseEM # ─────────────────────────────────────────────────────────────────────────────
[docs] class TimeLapseEM(PyCSAMTObject): """Time-lapse EM analysis for hydrogeological change detection. Parameters ---------- surveys : sequence of ResistivityModel Time-ordered EM inversion results. All must share the same grid (checked on construction via :func:`assert_compatible_grids`). times : sequence of float, optional Time stamps for each survey (any consistent unit: days, months, etc.). Used for labelling only — no numerical time-derivative is computed. labels : sequence of str, optional Human-readable survey labels (e.g. ``['dry2022', 'wet2023']``). Attributes ---------- n_surveys : int n_x, n_z : int """ def __init__( self, surveys: Sequence[ResistivityModel], *, times: Sequence[float] | None = None, labels: Sequence[str] | None = None, ) -> None: if len(surveys) < 2: raise ValueError("At least two surveys are required.") assert_compatible_grids(surveys) self.surveys = list(surveys) self.times = ( list(times) if times is not None else list(range(len(surveys))) ) self.labels = ( list(labels) if labels is not None else [f"T{i:02d}" for i in range(len(surveys))] ) if len(self.times) != len(surveys): raise ValueError("len(times) must equal len(surveys).") if len(self.labels) != len(surveys): raise ValueError("len(labels) must equal len(surveys).") # ── properties ────────────────────────────────────────────────────────
[docs] @property def n_surveys(self) -> int: return len(self.surveys)
[docs] @property def n_x(self) -> int: return int(self.surveys[0].rho_2d.shape[1])
[docs] @property def n_z(self) -> int: return int(self.surveys[0].rho_2d.shape[0])
# ── resistivity change ─────────────────────────────────────────────────
[docs] def resistivity_change( self, baseline_idx: int = 0, ) -> list[np.ndarray]: r"""Resistivity change relative to the baseline survey. .. math:: \Delta\log_{10}\rho_i = \log_{10}\rho_i - \log_{10}\rho_\text{baseline} Parameters ---------- baseline_idx : int Index of the baseline survey (default 0). Returns ------- list of ndarray (n_z, n_x) One array per non-baseline survey, in time order. Positive values indicate resistivity increase (drying/desaturation). Negative values indicate resistivity decrease (wetting/salinisation). """ base = self.surveys[baseline_idx].rho_2d return [ s.rho_2d - base for i, s in enumerate(self.surveys) if i != baseline_idx ]
# ── saturation change ──────────────────────────────────────────────────
[docs] def saturation_change( self, petro: _PetroModel, *, phi: float | np.ndarray = 0.25, rho_w: float = 0.025, baseline_idx: int = 0, ) -> list[np.ndarray]: r"""Water-saturation change Δ*Sw* via Archie/WS inverse. .. math:: \Delta S_{w,i} = S_w(\rho_i) - S_w(\rho_\text{baseline}) Parameters ---------- petro : ArchieModel or WaxmanSmitsModel Petrophysical model for ρ → Sw inversion. phi : float or ndarray Porosity used in the inversion. Scalar for a uniform profile; 2-D array (n_z, n_x) for a spatially varying prior. rho_w : float Pore-water resistivity (Ω·m). baseline_idx : int Index of the baseline survey (default 0). Returns ------- list of ndarray (n_z, n_x) ΔSw arrays, one per non-baseline survey. Positive = wetting (saturation increase). Negative = drying (saturation decrease). """ archie = _to_archie(petro) phi_arr = np.broadcast_to( np.asarray(phi, dtype=float), self.surveys[0].rho_2d.shape ).copy() def _sw_map(survey: ResistivityModel) -> np.ndarray: rho = 10.0**survey.rho_2d return np.clip(archie.saturation(rho, phi_arr, rho_w), 0.0, 1.0) sw_base = _sw_map(self.surveys[baseline_idx]) return [ _sw_map(s) - sw_base for i, s in enumerate(self.surveys) if i != baseline_idx ]
# ── water-content change ───────────────────────────────────────────────
[docs] def water_content_change( self, petro: _PetroModel, *, phi: float | np.ndarray = 0.25, rho_w: float = 0.025, baseline_idx: int = 0, ) -> list[np.ndarray]: r"""Volumetric water-content change Δθ = φ · ΔSw. Parameters ---------- petro, phi, rho_w, baseline_idx Same as :meth:`saturation_change`. Returns ------- list of ndarray (n_z, n_x) Δθ arrays (dimensionless, range ≈ −φ to +φ). """ phi_arr = np.broadcast_to( np.asarray(phi, dtype=float), self.surveys[0].rho_2d.shape ).copy() delta_sw_list = self.saturation_change( petro, phi=phi, rho_w=rho_w, baseline_idx=baseline_idx ) return [delta_sw * phi_arr for delta_sw in delta_sw_list]
# ── water-table displacement ───────────────────────────────────────────
[docs] def water_table_displacement( self, petro: _PetroModel, *, rho_w: float = 0.025, Sw_threshold: float = 0.85, min_depth: float = 0.5, baseline_idx: int = 0, ) -> np.ndarray: r"""Water-table depth change relative to the baseline survey. .. math:: \Delta z_{wt} = z_{wt}(t_i) - z_{wt}(t_\text{baseline}) Positive values → water table dropped (deeper, e.g. dry season). Negative values → water table rose (shallower, e.g. recharge). ``nan`` is returned where the water table cannot be detected in either the baseline or the comparison survey. Parameters ---------- petro : ArchieModel or WaxmanSmitsModel rho_w : float Sw_threshold : float Saturation level that defines the water table (default 0.85). min_depth : float Minimum search depth in metres (default 0.5). baseline_idx : int Returns ------- ndarray (n_surveys−1, n_x) Water-table displacement in metres, one row per non-baseline survey. """ archie = _to_archie(petro) base_survey = self.surveys[baseline_idx] base_wt = self._water_table_map( base_survey, archie, rho_w, Sw_threshold, min_depth ) others = [s for i, s in enumerate(self.surveys) if i != baseline_idx] rows = [] for s in others: wt_i = self._water_table_map( s, archie, rho_w, Sw_threshold, min_depth ) rows.append(wt_i - base_wt) return np.vstack(rows) if len(rows) > 1 else rows[0]
# ── specific-survey water table ────────────────────────────────────────
[docs] def water_table_map( self, petro: _PetroModel, *, rho_w: float = 0.025, Sw_threshold: float = 0.85, min_depth: float = 0.5, ) -> np.ndarray: """Water-table depth (m) for every survey and every column. Returns ------- ndarray (n_surveys, n_x) ``nan`` where the water table could not be detected. """ archie = _to_archie(petro) rows = [ self._water_table_map(s, archie, rho_w, Sw_threshold, min_depth) for s in self.surveys ] return np.vstack(rows)
# ── statistics across time ─────────────────────────────────────────────
[docs] def resistivity_stats( self, baseline_idx: int = 0, ) -> dict: """Summary statistics of resistivity change across all surveys. Returns ------- dict with keys: ``mean_delta``, ``std_delta``, ``max_increase``, ``max_decrease`` — each an ndarray (n_z, n_x). """ deltas = self.resistivity_change(baseline_idx=baseline_idx) stack = np.stack(deltas, axis=0) # (n_surveys-1, n_z, n_x) return { "mean_delta": np.nanmean(stack, axis=0), "std_delta": np.nanstd(stack, axis=0), "max_increase": np.nanmax(stack, axis=0), "max_decrease": np.nanmin(stack, axis=0), }
# ── private ──────────────────────────────────────────────────────────── def _water_table_map( self, survey: ResistivityModel, archie: ArchieModel, rho_w: float, Sw_threshold: float, min_depth: float, ) -> np.ndarray: """Water-table depth per column for one survey.""" wt = np.full(survey.n_x, np.nan) for ix in range(survey.n_x): depth = water_table_from_profile( survey.rho_2d[:, ix], survey.z_centers, archie, rho_w=rho_w, Sw_threshold=Sw_threshold, min_depth=min_depth, ) if depth is not None: wt[ix] = depth return wt def __repr__(self) -> str: return ( f"TimeLapseEM(n_surveys={self.n_surveys}, " f"n_z={self.n_z}, n_x={self.n_x}, " f"labels={self.labels})" )
# ───────────────────────────────────────────────────────────────────────────── # Internal helpers # ───────────────────────────────────────────────────────────────────────────── def _to_archie(petro: _PetroModel) -> ArchieModel: """Return an ArchieModel; if WaxmanSmitsModel, approximate as Archie.""" if isinstance(petro, ArchieModel): return petro if isinstance(petro, WaxmanSmitsModel): return ArchieModel(m=petro.m, n=petro.n, a=petro.a) raise TypeError( f"petro must be ArchieModel or WaxmanSmitsModel, got {type(petro)}" )