Source code for pycsamt.interp.calibrate

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""ModelCalibrator — constrain a 2-D EM model with borehole ground truth.

Implements the pseudo-stratigraphic New Model (NM) construction
introduced in Kouadio et al. (2022) and generalized here to work with
any EM inversion output (Occam2D, ModEM, AI-based 1-D/2-D).

Algorithm
---------
For each station column of the Calculated Resistivity Model (CRM):

1. **Soft replace** — for every depth cell whose CRM resistivity is
   within *ptol* (default 10 %) of a borehole TRES value, replace the
   calculated value with the true value:

   .. math::

       \\xi_{\\mathrm{se}} = \\frac{|\\rho_{\\mathrm{crm}} - \\rho_k|}{\\rho_k}
       \\leq p_{\\mathrm{tol}}

2. **Autolayer** — for cells not matched in Step 1, assign the nearest
   rock from the geological database :math:`\\Gamma` in log-space.

The **misfit G (%)** between the CRM and the NM is:

.. math::

    G(\\%) = 100 \\times
    \\sqrt{\\frac{\\sum_i (\\mathrm{NM}_i - \\mathrm{CRM}_i)^2}
                 {\\sum_i \\mathrm{CRM}_i^2}}

References
----------
.. [1] Kouadio, K.L. et al. (2022). pyCSAMT: An alternative Python
   toolbox for groundwater exploration using controlled source
   audio-frequency magnetotelluric. *Journal of Applied Geophysics*,
   201, 104647. https://doi.org/10.1016/j.jappgeo.2022.104647

Example
-------
>>> from pycsamt.interp import ResistivityModel, Borehole, ModelCalibrator
>>> model = ResistivityModel.from_occam2d(result)
>>> bh = Borehole.from_csv("boreholes/Bo.csv", name="Bo", x=1050.0)
>>> cal = ModelCalibrator(ptol=0.10).fit(model, [bh])
>>> nm = cal.calibrated_model()
>>> logs = cal.stratigraphic_logs()
"""

from __future__ import annotations

from collections.abc import Sequence

import numpy as np

from ._base import ResistivityModel
from .borehole import Borehole
from .lithology import RockDatabase, StratigraphicLog

__all__ = ["ModelCalibrator"]


[docs] class ModelCalibrator: """Constrain a 2-D EM resistivity model with borehole TRES values. Parameters ---------- ptol : float, default 0.10 Soft-error threshold — fractional tolerance between a CRM resistivity value and a TRES value for accepting a replace. The paper uses 10 % (``ptol = 0.10``). max_borehole_distance : float, default 500.0 Maximum profile-distance (metres) from a station to the nearest borehole. Stations farther than this threshold are calibrated using the rock database only (no TRES constraint). db : RockDatabase, optional Rock physics database for autolayer assignment. Defaults to :meth:`~RockDatabase.default`. verbose : bool, default True """ def __init__( self, ptol: float = 0.10, *, max_borehole_distance: float = 500.0, db: RockDatabase | None = None, verbose: bool = True, ) -> None: self.ptol = float(ptol) self.max_borehole_distance = float(max_borehole_distance) self.db = db if db is not None else RockDatabase.default() self.verbose = verbose self._model: ResistivityModel | None = None self._boreholes: list[Borehole] = [] self._nm_rho_2d: np.ndarray | None = None self._misfit_map: np.ndarray | None = None self._is_fitted: bool = False # ------------------------------------------------------------------ # Fit # ------------------------------------------------------------------
[docs] def fit( self, model: ResistivityModel, boreholes: Sequence[Borehole] | None = None, ) -> ModelCalibrator: """Calibrate *model* against *boreholes*. Parameters ---------- model : ResistivityModel The Calculated Resistivity Model (CRM) from any EM inversion. boreholes : list of Borehole, optional Ground-truth borehole logs. If ``None`` or empty, the calibration falls back to rock-database autolayer assignment for every cell. Returns ------- self """ self._model = model self._boreholes = list(boreholes) if boreholes else [] crm = model.rho_2d.copy() nm = crm.copy() for ix, x_sta in enumerate(model.x_centers): bh = self._nearest_borehole(x_sta) col_crm = crm[:, ix] if ( bh is not None and abs(bh.x - x_sta) <= self.max_borehole_distance ): nm[:, ix] = self._calibrate_column( col_crm, model.z_centers, bh ) else: nm[:, ix] = self._autolayer_column(col_crm) self._nm_rho_2d = nm self._misfit_map = self._compute_misfit(crm, nm) self._is_fitted = True if self.verbose: g_mean = float(np.nanmean(self._misfit_map)) print( f" ModelCalibrator: fitted {model.n_x} columns, " f"{len(self._boreholes)} borehole(s), " f"mean misfit G = {g_mean:.2f} %" ) return self
# ------------------------------------------------------------------ # Results # ------------------------------------------------------------------
[docs] def calibrated_model(self) -> ResistivityModel: """Return a :class:`ResistivityModel` containing the NM. Raises ------ RuntimeError If :meth:`fit` has not been called. """ self._check_fitted() m = self._model return ResistivityModel( x_centers=m.x_centers.copy(), z_centers=m.z_centers.copy(), rho_2d=self._nm_rho_2d.copy(), station_x=m.station_x.copy(), station_names=list(m.station_names), method=m.method + "+calibrated", rms=m.rms, )
[docs] def misfit_map(self) -> np.ndarray: """Return the per-column misfit G (%), shape (n_z, n_x). Values range 0 – 100 %. Low values indicate good CRM–TRES agreement; high values flag where the inversion needed the most correction. """ self._check_fitted() return self._misfit_map.copy()
[docs] def stratigraphic_logs( self, *, db: RockDatabase | None = None, model: str = "nm", merge_tolerance: float = 0.2, ) -> list[StratigraphicLog]: """Build a :class:`~pycsamt.interp.lithology.StratigraphicLog` for every station. Parameters ---------- db : RockDatabase, optional Override the classifier database. model : {'nm', 'crm'} Which model to classify: the calibrated NM or the original CRM. merge_tolerance : float Log-space merging tolerance passed to :meth:`~StratigraphicLog.from_column`. Returns ------- list of StratigraphicLog """ self._check_fitted() _db = db if db is not None else self.db m = self._model rho_2d = self._nm_rho_2d if model == "nm" else m.rho_2d logs: list[StratigraphicLog] = [] for _i, (sta_x, sta_name) in enumerate( zip(m.station_x, m.station_names) ): ix = int(np.argmin(np.abs(m.x_centers - sta_x))) col = rho_2d[:, ix] log = StratigraphicLog.from_column( station_name=sta_name, x=float(sta_x), z_centers=m.z_centers, rho_log10=col, db=_db, merge_tolerance=merge_tolerance, ) logs.append(log) # If no station list was provided, fall back to all x-columns if not logs: for ix, x_c in enumerate(m.x_centers): col = rho_2d[:, ix] log = StratigraphicLog.from_column( station_name=f"S{ix:03d}", x=float(x_c), z_centers=m.z_centers, rho_log10=col, db=_db, merge_tolerance=merge_tolerance, ) logs.append(log) return logs
# ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _nearest_borehole(self, x: float) -> Borehole | None: if not self._boreholes: return None dists = [abs(bh.x - x) for bh in self._boreholes] return self._boreholes[int(np.argmin(dists))] def _calibrate_column( self, col_crm: np.ndarray, z_centers: np.ndarray, bh: Borehole, ) -> np.ndarray: """Apply the two-step NM construction to a single column.""" tres_col = bh.tres_column(z_centers) # TRES at each depth, Ω·m rho_crm = 10.0**col_crm # linear Ω·m nm = col_crm.copy() # initialise NM = CRM for i in range(len(z_centers)): if np.isnan(col_crm[i]): continue tres_i = tres_col[i] if np.isnan(tres_i): # No TRES at this depth — autolayer from database nm[i] = self._autolayer_single(rho_crm[i]) continue # Step 1: soft replace xi_se = abs(rho_crm[i] - tres_i) / max(tres_i, 1e-6) if xi_se <= self.ptol: nm[i] = np.log10(max(tres_i, 1e-6)) else: # Step 2: autolayer (nearest rock in Γ consistent with TRES) nm[i] = self._autolayer_single(tres_i) return nm def _autolayer_single(self, rho_ohm_m: float) -> float: """Return log10-rho of the database rock nearest to *rho_ohm_m*.""" entry = self.db.classify(float(rho_ohm_m)) return entry.log_rho_mid def _autolayer_column(self, col_crm: np.ndarray) -> np.ndarray: nm = col_crm.copy() for i, v in enumerate(col_crm): if not np.isnan(v): nm[i] = self._autolayer_single(float(10.0**v)) return nm @staticmethod def _compute_misfit(crm: np.ndarray, nm: np.ndarray) -> np.ndarray: """Per-column G (%) misfit, broadcast to (n_z, n_x).""" diff_sq = (nm - crm) ** 2 crm_sq = crm**2 with np.errstate(divide="ignore", invalid="ignore"): g_col = 100.0 * np.sqrt( np.nansum(diff_sq, axis=0) / np.maximum(np.nansum(crm_sq, axis=0), 1e-12) ) # Broadcast scalar per-column misfit back to a 2-D map g_map = np.tile(g_col, (crm.shape[0], 1)) return g_map def _check_fitted(self) -> None: if not self._is_fitted: raise RuntimeError("Call fit() before accessing results.") # ------------------------------------------------------------------ def __repr__(self) -> str: status = "fitted" if self._is_fitted else "unfitted" return f"ModelCalibrator(ptol={self.ptol}, {status})"