Source code for pycsamt.interp.uncertainty

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Monte Carlo uncertainty propagation for EM hydro-geophysical models.

Petrophysical parameters (ρ_w, Archie *m*, *n*, porosity prior) are never
known exactly from the EM data alone.  This module propagates that
parametric uncertainty through the full chain

    (ρ_w, m, n, φ)  →  :class:`~pycsamt.interp.hydromodel.EMHydroModel`
    →  K, Sw, φ, water-table depth, T, S

by drawing *N* random realisations of the free parameters from user-specified
prior distributions, running :class:`~pycsamt.interp.hydromodel.EMHydroModel`
for each, and accumulating ensemble statistics (mean, std, P10, P50, P90).

EM method context
-----------------
* **TDEM** — ρ_w uncertainty dominates (water quality unknown in advance).
  Typical range: 5–100 Ω·m for freshwater targets.
* **AMT** — *m* uncertainty is significant in fractured basement
  (cementation exponent varies 1.3–2.5 in fractured vs porous media).
* **EMAP / MT** — mixed uncertainty; wide ρ_w range for basin brines.

Typical use
-----------
>>> from pycsamt.interp.uncertainty import (
...     UncertaintyBounds, MonteCarloHydro
... )
>>> bounds = UncertaintyBounds(
...     rho_w_range=(5.0, 80.0),      # fresh water range
...     m_range=(1.5, 2.2),
...     phi_prior_range=(0.15, 0.40),
... )
>>> mc = MonteCarloHydro(resistivity_model, config, bounds, n_samples=300)
>>> unc = mc.run()
>>> print(unc.p90_wt - unc.p10_wt)   # WT depth uncertainty (m)
>>> print(unc.cv_K)                    # coefficient of variation of K

References
----------
.. [1] Gómez-Treviño, E. et al. (2014). J. Appl. Geophys., 100, 1–10.
.. [2] Slater, L. (2007). *Near Surface Geophys.*, 5, 369–384.
   (Uncertainty in shallow geophysical aquifer mapping)
.. [3] Binley, A. et al. (2015). *Water Resour. Res.*, 51, 3837–3866.
"""

from __future__ import annotations

import csv
import dataclasses
from dataclasses import dataclass, field
from pathlib import Path
from typing import Union

import numpy as np

from ..api.property import PyCSAMTObject
from ._base import ResistivityModel
from .hydromodel import (
    EMHydroModel,
    PetrophysicalConfig,
)
from .petrophysics import ArchieModel, WaxmanSmitsModel

__all__ = [
    "UncertaintyBounds",
    "UncertaintyResult",
    "MonteCarloHydro",
]

PathLike = Union[str, Path]
_DIST_MODES = ("uniform", "normal")


# ─────────────────────────────────────────────────────────────────────────────
# Parameter bounds
# ─────────────────────────────────────────────────────────────────────────────


[docs] @dataclass class UncertaintyBounds(PyCSAMTObject): """Prior distribution specification for Monte Carlo sampling. Each ``*_range`` parameter activates that parameter as a free variable. Un-specified parameters are held fixed at the central :class:`~pycsamt.interp.hydromodel.PetrophysicalConfig` values. Parameters ---------- rho_w_range : (float, float), optional Pore-water resistivity range (Ω·m). *uniform*: (low, high); *normal*: (mean, std). Typical fresh-water range: ``(5, 100)``; brackish: ``(0.5, 20)``. m_range : (float, float), optional Archie cementation exponent. Typical: ``(1.3, 2.5)`` for mixed lithology. n_range : (float, float), optional Archie saturation exponent. Typical: ``(1.8, 2.5)`` for clean sand. phi_prior_range : (float, float), optional Porosity prior. Typical: ``(0.10, 0.45)``. dist : str Sampling distribution: ``'uniform'`` (default, non-informative) or ``'normal'`` (Gaussian — *range* is interpreted as *(mean, std)*). Notes ----- At least one ``*_range`` must be set. ``rho_w_range`` alone covers the dominant source of uncertainty in most shallow EM surveys. """ rho_w_range: tuple[float, float] | None = None m_range: tuple[float, float] | None = None n_range: tuple[float, float] | None = None phi_prior_range: tuple[float, float] | None = None dist: str = "uniform" def __post_init__(self) -> None: if self.dist not in _DIST_MODES: raise ValueError( f"dist must be one of {_DIST_MODES}, got {self.dist!r}." ) if self.n_free == 0: raise ValueError("At least one *_range parameter must be set.")
[docs] @property def n_free(self) -> int: """Number of free (uncertain) parameters.""" return sum( r is not None for r in [ self.rho_w_range, self.m_range, self.n_range, self.phi_prior_range, ] )
[docs] @property def free_names(self) -> list[str]: """Names of the free parameters.""" names = [] if self.rho_w_range is not None: names.append("rho_w") if self.m_range is not None: names.append("m") if self.n_range is not None: names.append("n") if self.phi_prior_range is not None: names.append("phi_prior") return names
[docs] def sample( self, cfg: PetrophysicalConfig, n: int, rng: np.random.Generator, ) -> list[PetrophysicalConfig]: """Draw *n* parameter samples and return a list of configs. Parameters ---------- cfg : PetrophysicalConfig Central (best-estimate) configuration — used as fallback for fixed parameters. n : int Number of Monte Carlo samples. rng : np.random.Generator Returns ------- list of PetrophysicalConfig, length *n* """ rho_w_vals = _draw( self.rho_w_range, cfg.rho_w, n, rng, self.dist, (1e-3, 1e4) ) m_vals = _draw( self.m_range, cfg.petro.m if hasattr(cfg.petro, "m") else 1.8, n, rng, self.dist, (1.0, 4.0), ) n_vals = _draw( self.n_range, cfg.petro.n if hasattr(cfg.petro, "n") else 2.0, n, rng, self.dist, (1.0, 4.0), ) phi_vals = _draw( self.phi_prior_range, cfg.porosity_prior, n, rng, self.dist, (0.01, 0.80), ) configs = [] for i in range(n): petro_i = _perturb_petro(cfg.petro, m=m_vals[i], n=n_vals[i]) configs.append( dataclasses.replace( cfg, petro=petro_i, rho_w=float(rho_w_vals[i]), porosity_prior=float(phi_vals[i]), ) ) return configs
# ───────────────────────────────────────────────────────────────────────────── # Uncertainty result # ─────────────────────────────────────────────────────────────────────────────
[docs] @dataclass class UncertaintyResult(PyCSAMTObject): """Ensemble statistics from a :class:`MonteCarloHydro` run. All 2-D arrays have shape ``(n_z, n_x)``; 1-D station arrays have shape ``(n_x,)``. K values are stored on the **linear** scale (m/s). Attributes ---------- resistivity_model : ResistivityModel config : PetrophysicalConfig Central configuration used to seed the Monte Carlo. bounds : UncertaintyBounds n_samples : int method_tag : str sampled_params : ndarray (n_samples, n_free) Matrix of sampled parameter values, columns in ``bounds.free_names`` order. Hydraulic conductivity K (m/s) ------------------------------ mean_K, std_K, p10_K, p50_K, p90_K : ndarray (n_z, n_x) cv_K : ndarray (n_z, n_x) Coefficient of variation std_K / mean_K. Dimensionless uncertainty map. Water saturation Sw ------------------- mean_Sw, std_Sw, p10_Sw, p90_Sw : ndarray (n_z, n_x) Porosity φ ---------- mean_phi, std_phi : ndarray (n_z, n_x) Water-table depth (m) --------------------- mean_wt, std_wt, p10_wt, p50_wt, p90_wt : ndarray (n_x,) wt_detection_rate : ndarray (n_x,) Fraction of samples in which the water table was detected (0–1). Transmissivity T (m²/s) ----------------------- mean_T, std_T, p10_T, p50_T, p90_T : ndarray (n_x,) """ resistivity_model: ResistivityModel config: PetrophysicalConfig bounds: UncertaintyBounds n_samples: int method_tag: str = "" sampled_params: np.ndarray = field(default_factory=lambda: np.array([])) # K maps mean_K: np.ndarray = field(default_factory=lambda: np.array([])) std_K: np.ndarray = field(default_factory=lambda: np.array([])) p10_K: np.ndarray = field(default_factory=lambda: np.array([])) p50_K: np.ndarray = field(default_factory=lambda: np.array([])) p90_K: np.ndarray = field(default_factory=lambda: np.array([])) cv_K: np.ndarray = field(default_factory=lambda: np.array([])) # Sw maps mean_Sw: np.ndarray = field(default_factory=lambda: np.array([])) std_Sw: np.ndarray = field(default_factory=lambda: np.array([])) p10_Sw: np.ndarray = field(default_factory=lambda: np.array([])) p90_Sw: np.ndarray = field(default_factory=lambda: np.array([])) # porosity maps mean_phi: np.ndarray = field(default_factory=lambda: np.array([])) std_phi: np.ndarray = field(default_factory=lambda: np.array([])) # water-table mean_wt: np.ndarray = field(default_factory=lambda: np.array([])) std_wt: np.ndarray = field(default_factory=lambda: np.array([])) p10_wt: np.ndarray = field(default_factory=lambda: np.array([])) p50_wt: np.ndarray = field(default_factory=lambda: np.array([])) p90_wt: np.ndarray = field(default_factory=lambda: np.array([])) wt_detection_rate: np.ndarray = field( default_factory=lambda: np.array([]) ) # transmissivity mean_T: np.ndarray = field(default_factory=lambda: np.array([])) std_T: np.ndarray = field(default_factory=lambda: np.array([])) p10_T: np.ndarray = field(default_factory=lambda: np.array([])) p50_T: np.ndarray = field(default_factory=lambda: np.array([])) p90_T: np.ndarray = field(default_factory=lambda: np.array([])) # ── derived queries ────────────────────────────────────────────────────
[docs] def prob_wt_shallower_than( self, depth_m: float, *, wt_ensemble: np.ndarray | None = None, ) -> np.ndarray: """P(water-table depth < *depth_m*) per station column. Parameters ---------- depth_m : float Reference depth (m, positive downward). wt_ensemble : ndarray (n_samples, n_x), optional Full water-table ensemble from :meth:`MonteCarloHydro.run_ensemble`. If ``None``, approximated from the stored P10/P50/P90 assuming a Gaussian distribution with mean=mean_wt, std=std_wt. Returns ------- ndarray (n_x,) — probability in [0, 1] """ if wt_ensemble is not None: return np.mean(wt_ensemble < depth_m, axis=0) from scipy.stats import norm as _norm p = np.zeros_like(self.mean_wt) valid = self.std_wt > 0 p[valid] = _norm.cdf( depth_m, loc=self.mean_wt[valid], scale=self.std_wt[valid] ) mask_fixed = ~valid & np.isfinite(self.mean_wt) p[mask_fixed] = (self.mean_wt[mask_fixed] < depth_m).astype(float) return p
[docs] def station_report(self) -> list[dict]: """Per-station uncertainty summary.""" model = self.resistivity_model rows = [] for ix, x in enumerate(model.x_centers): name = ( model.station_names[ix] if model.station_names and ix < len(model.station_names) else f"S{ix:03d}" ) rows.append( { "station": name, "x_m": float(x), "mean_wt_m": float(self.mean_wt[ix]), "std_wt_m": float(self.std_wt[ix]), "p10_wt_m": float(self.p10_wt[ix]), "p90_wt_m": float(self.p90_wt[ix]), "wt_range_m": float(self.p90_wt[ix] - self.p10_wt[ix]), "wt_detection_pct": float( self.wt_detection_rate[ix] * 100 ), "mean_T_m2s": float(self.mean_T[ix]), "std_T_m2s": float(self.std_T[ix]), "p10_T_m2s": float(self.p10_T[ix]), "p90_T_m2s": float(self.p90_T[ix]), "log10_T_range": float( np.log10(max(self.p90_T[ix], 1e-20)) - np.log10(max(self.p10_T[ix], 1e-20)) ), } ) return rows
[docs] def to_csv(self, path: PathLike) -> Path: """Write per-station uncertainty summary to CSV.""" out = Path(path) out.parent.mkdir(parents=True, exist_ok=True) rows = self.station_report() if not rows: return out with out.open("w", newline="") as fh: w = csv.DictWriter(fh, fieldnames=list(rows[0].keys())) w.writeheader() w.writerows(rows) return out
# ───────────────────────────────────────────────────────────────────────────── # Monte Carlo engine # ─────────────────────────────────────────────────────────────────────────────
[docs] class MonteCarloHydro(PyCSAMTObject): """Monte Carlo uncertainty propagation for a quantitative EM hydro model. Parameters ---------- resistivity_model : ResistivityModel Source inversion model. config : PetrophysicalConfig Central (best-estimate) petrophysical configuration. bounds : UncertaintyBounds Prior distributions for the free parameters. n_samples : int Number of Monte Carlo realisations (default 200). Typical: 100 for quick runs, 500–1000 for publication-quality. seed : int Random seed for reproducibility (default 42). method_tag : str, optional EM method label inherited by the output. verbose : bool Print progress every 50 iterations (default False). Examples -------- >>> bounds = UncertaintyBounds(rho_w_range=(5.0, 100.0), m_range=(1.4, 2.4)) >>> mc = MonteCarloHydro(rm, cfg, bounds, n_samples=300) >>> unc = mc.run() >>> unc.cv_K.max() # worst-case K uncertainty (fraction) >>> unc.p90_wt - unc.p10_wt # WT depth spread per station (m) """ def __init__( self, resistivity_model: ResistivityModel, config: PetrophysicalConfig, bounds: UncertaintyBounds, *, n_samples: int = 200, seed: int = 42, method_tag: str = "", verbose: bool = False, ) -> None: self.resistivity_model = resistivity_model self.config = config self.bounds = bounds self.n_samples = int(n_samples) self.seed = seed self.method_tag = method_tag self.verbose = verbose # ── public ─────────────────────────────────────────────────────────────
[docs] def run(self) -> UncertaintyResult: """Execute the Monte Carlo ensemble and return :class:`UncertaintyResult`. Returns ------- UncertaintyResult """ rng = np.random.default_rng(self.seed) configs = self.bounds.sample(self.config, self.n_samples, rng) n_z = self.resistivity_model.n_z n_x = self.resistivity_model.n_x # pre-allocate ensemble arrays K_ens = np.full((self.n_samples, n_z, n_x), np.nan) Sw_ens = np.full((self.n_samples, n_z, n_x), np.nan) phi_ens = np.full((self.n_samples, n_z, n_x), np.nan) wt_ens = np.full((self.n_samples, n_x), np.nan) T_ens = np.full((self.n_samples, n_x), np.nan) # sampled parameter matrix param_mat = np.full((self.n_samples, self.bounds.n_free), np.nan) for i, cfg_i in enumerate(configs): if self.verbose and i % 50 == 0: print(f" MC sample {i}/{self.n_samples} ...") try: result_i = EMHydroModel( self.resistivity_model, cfg_i, method_tag=self.method_tag, ).fit() except Exception: continue K_ens[i] = result_i.hydraulic_K Sw_ens[i] = result_i.saturation phi_ens[i] = result_i.porosity wt_ens[i] = result_i.water_table T_ens[i] = result_i.transmissivity param_mat[i] = _extract_params(cfg_i, self.bounds) # ── K statistics ─────────────────────────────────────────────────── mean_K = np.nanmean(K_ens, axis=0) std_K = np.nanstd(K_ens, axis=0) p10_K = np.nanpercentile(K_ens, 10, axis=0) p50_K = np.nanpercentile(K_ens, 50, axis=0) p90_K = np.nanpercentile(K_ens, 90, axis=0) with np.errstate(divide="ignore", invalid="ignore"): cv_K = np.where(mean_K > 0, std_K / mean_K, np.nan) # ── Sw statistics ────────────────────────────────────────────────── mean_Sw = np.nanmean(Sw_ens, axis=0) std_Sw = np.nanstd(Sw_ens, axis=0) p10_Sw = np.nanpercentile(Sw_ens, 10, axis=0) p90_Sw = np.nanpercentile(Sw_ens, 90, axis=0) # ── phi statistics ───────────────────────────────────────────────── mean_phi = np.nanmean(phi_ens, axis=0) std_phi = np.nanstd(phi_ens, axis=0) # ── water-table statistics ───────────────────────────────────────── detect_rate = np.mean(np.isfinite(wt_ens), axis=0) mean_wt = np.nanmean(wt_ens, axis=0) std_wt = np.nanstd(wt_ens, axis=0) p10_wt = np.nanpercentile(wt_ens, 10, axis=0) p50_wt = np.nanpercentile(wt_ens, 50, axis=0) p90_wt = np.nanpercentile(wt_ens, 90, axis=0) # ── transmissivity statistics ────────────────────────────────────── mean_T = np.nanmean(T_ens, axis=0) std_T = np.nanstd(T_ens, axis=0) p10_T = np.nanpercentile(T_ens, 10, axis=0) p50_T = np.nanpercentile(T_ens, 50, axis=0) p90_T = np.nanpercentile(T_ens, 90, axis=0) if self.verbose: print( f" MC complete: {self.n_samples} samples " f"n_free={self.bounds.n_free} " f"free={self.bounds.free_names}" ) return UncertaintyResult( resistivity_model=self.resistivity_model, config=self.config, bounds=self.bounds, n_samples=self.n_samples, method_tag=self.method_tag, sampled_params=param_mat, mean_K=mean_K, std_K=std_K, p10_K=p10_K, p50_K=p50_K, p90_K=p90_K, cv_K=cv_K, mean_Sw=mean_Sw, std_Sw=std_Sw, p10_Sw=p10_Sw, p90_Sw=p90_Sw, mean_phi=mean_phi, std_phi=std_phi, mean_wt=mean_wt, std_wt=std_wt, p10_wt=p10_wt, p50_wt=p50_wt, p90_wt=p90_wt, wt_detection_rate=detect_rate, mean_T=mean_T, std_T=std_T, p10_T=p10_T, p50_T=p50_T, p90_T=p90_T, )
[docs] def run_ensemble( self, ) -> tuple[UncertaintyResult, np.ndarray, np.ndarray]: """Like :meth:`run` but also returns the raw WT and T ensembles. Returns ------- unc : UncertaintyResult wt_ensemble : ndarray (n_samples, n_x) T_ensemble : ndarray (n_samples, n_x) """ rng = np.random.default_rng(self.seed) configs = self.bounds.sample(self.config, self.n_samples, rng) n_z = self.resistivity_model.n_z n_x = self.resistivity_model.n_x K_ens = np.full((self.n_samples, n_z, n_x), np.nan) Sw_ens = np.full((self.n_samples, n_z, n_x), np.nan) phi_ens = np.full((self.n_samples, n_z, n_x), np.nan) wt_ens = np.full((self.n_samples, n_x), np.nan) T_ens = np.full((self.n_samples, n_x), np.nan) param_mat = np.full((self.n_samples, self.bounds.n_free), np.nan) for i, cfg_i in enumerate(configs): try: r = EMHydroModel( self.resistivity_model, cfg_i, method_tag=self.method_tag ).fit() except Exception: continue K_ens[i] = r.hydraulic_K Sw_ens[i] = r.saturation phi_ens[i] = r.porosity wt_ens[i] = r.water_table T_ens[i] = r.transmissivity param_mat[i] = _extract_params(cfg_i, self.bounds) mean_K = np.nanmean(K_ens, axis=0) unc = UncertaintyResult( resistivity_model=self.resistivity_model, config=self.config, bounds=self.bounds, n_samples=self.n_samples, method_tag=self.method_tag, sampled_params=param_mat, mean_K=mean_K, std_K=np.nanstd(K_ens, axis=0), p10_K=np.nanpercentile(K_ens, 10, axis=0), p50_K=np.nanpercentile(K_ens, 50, axis=0), p90_K=np.nanpercentile(K_ens, 90, axis=0), cv_K=np.where( mean_K > 0, np.nanstd(K_ens, axis=0) / np.where(mean_K > 0, mean_K, np.nan), np.nan, ), mean_Sw=np.nanmean(Sw_ens, axis=0), std_Sw=np.nanstd(Sw_ens, axis=0), p10_Sw=np.nanpercentile(Sw_ens, 10, axis=0), p90_Sw=np.nanpercentile(Sw_ens, 90, axis=0), mean_phi=np.nanmean(phi_ens, axis=0), std_phi=np.nanstd(phi_ens, axis=0), mean_wt=np.nanmean(wt_ens, axis=0), std_wt=np.nanstd(wt_ens, axis=0), p10_wt=np.nanpercentile(wt_ens, 10, axis=0), p50_wt=np.nanpercentile(wt_ens, 50, axis=0), p90_wt=np.nanpercentile(wt_ens, 90, axis=0), wt_detection_rate=np.mean(np.isfinite(wt_ens), axis=0), mean_T=np.nanmean(T_ens, axis=0), std_T=np.nanstd(T_ens, axis=0), p10_T=np.nanpercentile(T_ens, 10, axis=0), p50_T=np.nanpercentile(T_ens, 50, axis=0), p90_T=np.nanpercentile(T_ens, 90, axis=0), ) return unc, wt_ens, T_ens
# ───────────────────────────────────────────────────────────────────────────── # Internal helpers # ───────────────────────────────────────────────────────────────────────────── def _draw( rng_spec: tuple[float, float] | None, central: float, n: int, rng: np.random.Generator, dist: str, physical_clip: tuple[float, float], ) -> np.ndarray: """Draw n samples from prior; return central value repeated if not free.""" if rng_spec is None: return np.full(n, central) lo, hi = rng_spec if dist == "uniform": samples = rng.uniform(lo, hi, size=n) else: samples = rng.normal(lo, hi, size=n) # (mean, std) convention return np.clip(samples, *physical_clip) def _perturb_petro( petro: ArchieModel | WaxmanSmitsModel, m: float, n: float, ) -> ArchieModel | WaxmanSmitsModel: """Return a new petrophysical model with updated m and n.""" return dataclasses.replace(petro, m=float(m), n=float(n)) def _extract_params( cfg: PetrophysicalConfig, bounds: UncertaintyBounds, ) -> np.ndarray: """Extract free-parameter values from a config as a 1-D array.""" vals = [] if bounds.rho_w_range is not None: vals.append(cfg.rho_w) if bounds.m_range is not None: vals.append(cfg.petro.m) if bounds.n_range is not None: vals.append(cfg.petro.n) if bounds.phi_prior_range is not None: vals.append(cfg.porosity_prior) return np.array(vals, dtype=float)